@bygghammar/cms-platform 0.52.0 → 0.54.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.
@@ -0,0 +1,438 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ArrowRight } from "lucide-react";
4
+ import { useT, useLocale } from "../i18n";
5
+ import type { LocalizedString } from "../i18n";
6
+ import { tripFieldOptions, resolveAutocomplete } from "./field-sets";
7
+ import type { FormFieldDef, PublicForm, TripSource } from "./field-sets";
8
+ import {
9
+ isFieldRequired,
10
+ isFieldVisible,
11
+ normalizeCheckbox,
12
+ validateAll,
13
+ validateField,
14
+ } from "./conditions";
15
+ import { renderLinkedText } from "./rich-text";
16
+ import { useHostPublicFormFns, useHostTripSource } from "../host";
17
+
18
+ const TOKEN_PREFIX = "form-draft-token:";
19
+
20
+ /** Stable per-visitor, per-form token so a draft updates instead of piling up. */
21
+ function draftToken(formKey: string): string {
22
+ if (typeof window === "undefined") return "";
23
+ const storageKey = TOKEN_PREFIX + formKey;
24
+ try {
25
+ const existing = window.localStorage.getItem(storageKey);
26
+ if (existing) return existing;
27
+ const token =
28
+ typeof crypto !== "undefined" && crypto.randomUUID
29
+ ? crypto.randomUUID()
30
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
31
+ window.localStorage.setItem(storageKey, token);
32
+ return token;
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Renders a managed form from an already-resolved definition.
40
+ * `preview` runs every rule and validation but never contacts the server,
41
+ * which is what the backoffice preview uses.
42
+ */
43
+ export function FormRenderer({
44
+ form,
45
+ dark = false,
46
+ className,
47
+ preview = false,
48
+ }: {
49
+ form: PublicForm;
50
+ dark?: boolean;
51
+ className?: string;
52
+ preview?: boolean;
53
+ }) {
54
+ const { t } = useT();
55
+ const locale = useLocale();
56
+ const { submitForm: send, savePartialSubmission: saveDraft } = useHostPublicFormFns();
57
+
58
+ const mountedAt = useRef(Date.now());
59
+ const [values, setValues] = useState<Record<string, string>>({});
60
+ const [consent, setConsent] = useState(false);
61
+ const [hp, setHp] = useState("");
62
+ const [busy, setBusy] = useState(false);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [done, setDone] = useState(false);
65
+ const [errors, setErrors] = useState<Record<string, string>>({});
66
+ const [touched, setTouched] = useState<Record<string, boolean>>({});
67
+
68
+ useEffect(() => {
69
+ mountedAt.current = Date.now();
70
+ }, [form.key]);
71
+
72
+ const tripItems = useHostTripSource() as TripSource[] | undefined;
73
+
74
+ // "trip"/"course" fields are dropdowns whose choices come from the central
75
+ // trips source (single source of truth), resolved at render time.
76
+ const resolved = useMemo(() => {
77
+ const trips = (tripItems ?? []) as TripSource[];
78
+ return (form.fields ?? []).map((f) =>
79
+ f.type === "trip" || f.type === "course"
80
+ ? {
81
+ ...f,
82
+ type: "select" as const,
83
+ options: tripFieldOptions(trips, f.tripYear ?? f.courseYear),
84
+ }
85
+ : f,
86
+ );
87
+ }, [form, tripItems]);
88
+
89
+ const enabled = useMemo(() => resolved.filter((f) => f.enabled), [resolved]);
90
+ /** Fields whose show-conditions currently match. */
91
+ const fields = useMemo(() => enabled.filter((f) => isFieldVisible(f, values)), [enabled, values]);
92
+
93
+ // Save what the visitor typed even if they never submit (debounced).
94
+ const dirty = useRef(false);
95
+ useEffect(() => {
96
+ if (preview || !form.savePartials || done || !dirty.current) return;
97
+ const token = draftToken(form.key);
98
+ if (!token) return;
99
+ const snapshot = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
100
+ if (!Object.values(snapshot).some((v) => v.trim())) return;
101
+ const id = window.setTimeout(() => {
102
+ void Promise.resolve(
103
+ saveDraft({
104
+ key: form.key,
105
+ clientToken: token,
106
+ locale,
107
+ pagePath: window.location.pathname,
108
+ values: snapshot,
109
+ }),
110
+ ).catch(() => {});
111
+ }, 1500);
112
+ return () => window.clearTimeout(id);
113
+ }, [values, fields, form.key, form.savePartials, locale, done, preview, saveDraft]);
114
+
115
+ const setValue = (f: FormFieldDef, value: string) => {
116
+ dirty.current = true;
117
+ setValues((v) => {
118
+ const next = { ...v, [f.key]: value };
119
+ // Answers to fields that just became hidden must not be submitted.
120
+ for (const other of enabled) {
121
+ if (other.key !== f.key && !isFieldVisible(other, next)) delete next[other.key];
122
+ }
123
+ return next;
124
+ });
125
+ if (touched[f.key] || errors[f.key]) {
126
+ setErrors((e) => {
127
+ const next = { ...e };
128
+ const msg = validateField(f, { ...values, [f.key]: value }, locale);
129
+ if (msg) next[f.key] = msg;
130
+ else delete next[f.key];
131
+ return next;
132
+ });
133
+ }
134
+ };
135
+
136
+ const blurField = (f: FormFieldDef) => {
137
+ setTouched((t) => ({ ...t, [f.key]: true }));
138
+ const msg = validateField(f, values, locale);
139
+ setErrors((e) => {
140
+ const next = { ...e };
141
+ if (msg) next[f.key] = msg;
142
+ else delete next[f.key];
143
+ return next;
144
+ });
145
+ };
146
+
147
+ if (done) {
148
+ return (
149
+ <div
150
+ className={[
151
+ className ?? "",
152
+ dark
153
+ ? "bg-paper/5 backdrop-blur border border-paper/15"
154
+ : "border border-ink/10 bg-white",
155
+ "p-8 text-center",
156
+ ].join(" ")}
157
+ >
158
+ <p className="font-serif text-2xl whitespace-pre-line">
159
+ {t(form.thankyouMessage as LocalizedString)}
160
+ </p>
161
+ {preview ? (
162
+ <button
163
+ type="button"
164
+ onClick={() => {
165
+ setDone(false);
166
+ setValues({});
167
+ setConsent(false);
168
+ setErrors({});
169
+ setTouched({});
170
+ }}
171
+ className="mt-6 border border-current/20 px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em]"
172
+ >
173
+ Reset preview
174
+ </button>
175
+ ) : null}
176
+ </div>
177
+ );
178
+ }
179
+
180
+ const inputCls = dark
181
+ ? "w-full bg-paper/10 border border-paper/25 px-4 pt-5 pb-2 text-paper placeholder-transparent focus:border-sea focus:bg-paper/15 focus:outline-none transition-colors"
182
+ : "w-full bg-white border border-ink/20 px-4 pt-5 pb-2 text-ink placeholder-transparent focus:border-sea focus:outline-none transition-colors";
183
+ const labelCls = dark
184
+ ? "pointer-events-none absolute left-4 top-1.5 text-[0.65rem] uppercase tracking-[0.2em] text-paper/75"
185
+ : "pointer-events-none absolute left-4 top-1.5 text-[0.65rem] uppercase tracking-[0.2em] text-ink/55";
186
+
187
+ const onSubmit = async (e: React.FormEvent) => {
188
+ e.preventDefault();
189
+ setError(null);
190
+
191
+ // Validate everything that is currently visible before touching the server.
192
+ const found = validateAll(enabled, values, locale);
193
+ if (form.requireConsent && !consent) {
194
+ setError(
195
+ locale === "sv" ? "Du behöver godkänna villkoren." : "Please accept the terms to continue.",
196
+ );
197
+ if (!Object.keys(found).length) return;
198
+ }
199
+ if (Object.keys(found).length) {
200
+ setErrors(found);
201
+ setTouched(Object.fromEntries(Object.keys(found).map((k) => [k, true])));
202
+ const first = document.getElementById(`${form.key}-${Object.keys(found)[0]}`);
203
+ first?.focus();
204
+ first?.scrollIntoView({ behavior: "smooth", block: "center" });
205
+ return;
206
+ }
207
+
208
+ // Preview mode stops here: rules and validation ran, nothing is stored.
209
+ if (preview) {
210
+ setDone(true);
211
+ return;
212
+ }
213
+
214
+ setBusy(true);
215
+ setError(null);
216
+ try {
217
+ // Send only the answers to fields the visitor actually saw.
218
+ const visible = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
219
+ await send({
220
+ key: form.key,
221
+ locale,
222
+ pagePath: typeof window !== "undefined" ? window.location.pathname : undefined,
223
+ consent,
224
+ elapsedMs: Date.now() - mountedAt.current,
225
+ hp,
226
+ clientToken: form.savePartials ? draftToken(form.key) : undefined,
227
+ values: visible,
228
+ });
229
+ if (form.thankyouMode === "redirect" && form.thankyouRedirect) {
230
+ window.location.href = form.thankyouRedirect;
231
+ return;
232
+ }
233
+ setDone(true);
234
+ } catch (err) {
235
+ setError(err instanceof Error ? err.message : "Something went wrong");
236
+ } finally {
237
+ setBusy(false);
238
+ }
239
+ };
240
+
241
+ const renderField = (f: FormFieldDef) => {
242
+ const label = t(f.label as LocalizedString);
243
+ const help = f.help ? t(f.help as LocalizedString) : "";
244
+ const placeholder = f.placeholder ? t(f.placeholder as LocalizedString) : label;
245
+ const id = `${form.key}-${f.key}`;
246
+ const required = isFieldRequired(f, values);
247
+ const err = errors[f.key];
248
+ const errId = `${id}-error`;
249
+ const invalidCls = err ? " border-red-500" : "";
250
+
251
+ const Message = err ? (
252
+ <p id={errId} role="alert" className="mt-1 text-xs text-red-500 whitespace-pre-line">
253
+ {err}
254
+ </p>
255
+ ) : help ? (
256
+ <p className="mt-1 text-xs opacity-60 whitespace-pre-line">{help}</p>
257
+ ) : null;
258
+
259
+ if (f.type === "checkbox") {
260
+ return (
261
+ <div key={f.key} className="sm:col-span-2">
262
+ <label className="flex items-start gap-3 text-sm">
263
+ <input
264
+ id={id}
265
+ name={f.key}
266
+ type="checkbox"
267
+ autoComplete="off"
268
+ checked={normalizeCheckbox(values[f.key] ?? "") === "true"}
269
+ aria-invalid={err ? true : undefined}
270
+ aria-describedby={err ? errId : undefined}
271
+ onChange={(e) => setValue(f, e.target.checked ? "true" : "false")}
272
+ onBlur={() => blurField(f)}
273
+ className="mt-0.5 size-4 shrink-0 accent-sea"
274
+ />
275
+ <span>
276
+ {label}
277
+ {required ? " *" : ""}
278
+ </span>
279
+ </label>
280
+ {Message}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ const auto = resolveAutocomplete(f);
286
+ const inputMode =
287
+ f.type === "tel"
288
+ ? ("tel" as const)
289
+ : f.type === "email"
290
+ ? ("email" as const)
291
+ : f.type === "number"
292
+ ? ("numeric" as const)
293
+ : undefined;
294
+
295
+ const common = {
296
+ id,
297
+ name: f.key,
298
+ autoComplete: auto,
299
+ placeholder,
300
+ maxLength: f.maxLength,
301
+ value: values[f.key] ?? "",
302
+ "aria-invalid": err ? true : undefined,
303
+ "aria-describedby": err ? errId : undefined,
304
+ onChange: (
305
+ e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
306
+ ) => setValue(f, e.target.value),
307
+ onBlur: () => blurField(f),
308
+ className: inputCls + invalidCls,
309
+ };
310
+ return (
311
+ <div key={f.key} className={["relative", f.wide ? "sm:col-span-2" : ""].join(" ")}>
312
+ {f.type === "textarea" ? (
313
+ <textarea {...common} rows={4} />
314
+ ) : f.type === "select" ? (
315
+ <select {...common}>
316
+ <option value="">—</option>
317
+ {(f.options ?? []).map((o) => (
318
+ <option key={o.value} value={o.value}>
319
+ {t(o.label as LocalizedString)}
320
+ </option>
321
+ ))}
322
+ </select>
323
+ ) : (
324
+ <input
325
+ {...common}
326
+ inputMode={inputMode}
327
+ type={f.type}
328
+ min={f.type === "date" ? f.minDate : f.min}
329
+ max={f.type === "date" ? f.maxDate : f.max}
330
+ />
331
+ )}
332
+ <label htmlFor={id} className={labelCls}>
333
+ {label}
334
+ {required ? " *" : ""}
335
+ </label>
336
+ {Message}
337
+ </div>
338
+ );
339
+ };
340
+
341
+ return (
342
+ <form
343
+ id={`form-${form.key}`}
344
+ onSubmit={onSubmit}
345
+ noValidate
346
+ className={[
347
+ className ?? "",
348
+ dark ? "bg-paper/5 backdrop-blur border border-paper/15" : "border border-ink/10 bg-white",
349
+ "p-6 md:p-8 space-y-5",
350
+ ].join(" ")}
351
+ >
352
+ <div className="grid gap-4 sm:grid-cols-2">{fields.map(renderField)}</div>
353
+
354
+ {form.honeypot ? (
355
+ <div className="hidden" aria-hidden="true">
356
+ <label>
357
+ Company
358
+ <input
359
+ tabIndex={-1}
360
+ autoComplete="off"
361
+ value={hp}
362
+ onChange={(e) => setHp(e.target.value)}
363
+ />
364
+ </label>
365
+ </div>
366
+ ) : null}
367
+
368
+ {form.requireConsent ? (
369
+ <label
370
+ className={[
371
+ "flex items-start gap-3 text-xs",
372
+ dark ? "text-paper/85" : "text-ink/70",
373
+ ].join(" ")}
374
+ >
375
+ <input
376
+ type="checkbox"
377
+ required
378
+ checked={consent}
379
+ onChange={(e) => setConsent(e.target.checked)}
380
+ className={[
381
+ "mt-0.5 size-4 shrink-0 accent-sea bg-transparent border",
382
+ dark ? "border-paper/40" : "border-ink/30",
383
+ ].join(" ")}
384
+ />
385
+ <span>
386
+ {renderLinkedText(t(form.consentText as LocalizedString), {
387
+ privacyUrl: form.privacyPolicyUrl,
388
+ })}
389
+ </span>
390
+ </label>
391
+ ) : null}
392
+
393
+ {error ? <p className="text-xs text-red-500 whitespace-pre-line">{error}</p> : null}
394
+
395
+ <button
396
+ type="submit"
397
+ disabled={busy}
398
+ className={[
399
+ "group inline-flex w-full items-center justify-center gap-2 px-6 py-4 text-xs uppercase tracking-[0.22em] transition-all disabled:opacity-60",
400
+ dark ? "bg-paper text-ink hover:bg-sea" : "bg-ink text-paper hover:bg-sea hover:text-ink",
401
+ ].join(" ")}
402
+ >
403
+ {t(form.submitLabel as LocalizedString)}
404
+ <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
405
+ </button>
406
+ </form>
407
+ );
408
+ }
409
+
410
+ /** Fetches a published form by key and renders it. */
411
+ export function ManagedForm({
412
+ formKey,
413
+ dark = false,
414
+ className,
415
+ }: {
416
+ formKey: string;
417
+ dark?: boolean;
418
+ className?: string;
419
+ }) {
420
+ const { getPublicForm } = useHostPublicFormFns();
421
+ const { data: form, isLoading } = useQuery({
422
+ queryKey: ["public-form", formKey],
423
+ queryFn: () => getPublicForm(formKey) as Promise<PublicForm | null>,
424
+ staleTime: 60_000,
425
+ });
426
+
427
+ if (isLoading) {
428
+ return (
429
+ <div className={className}>
430
+ <div className="h-64 animate-pulse border border-current/15 bg-current/5" />
431
+ </div>
432
+ );
433
+ }
434
+ if (!form) return null;
435
+ return <FormRenderer form={form} dark={dark} className={className} />;
436
+ }
437
+
438
+ export type { PublicForm };
@@ -148,3 +148,78 @@ export function useHostMediaUpload(existing: HostMediaItem[] = []): HostMediaUpl
148
148
  if (!hook) missing("useMediaUpload");
149
149
  return hook(existing);
150
150
  }
151
+
152
+ /* --------------------------- public form runtime --------------------------- */
153
+
154
+ /** Answers keyed by field key, exactly as the visitor typed them. */
155
+ export type HostFormValues = Record<string, string>;
156
+
157
+ export type HostSubmitFormInput = {
158
+ key: string;
159
+ locale: string;
160
+ pagePath?: string;
161
+ consent?: boolean;
162
+ elapsedMs?: number;
163
+ hp?: string;
164
+ clientToken?: string;
165
+ values: HostFormValues;
166
+ };
167
+
168
+ export type HostSavePartialInput = {
169
+ key: string;
170
+ clientToken: string;
171
+ locale: string;
172
+ pagePath?: string;
173
+ values: HostFormValues;
174
+ };
175
+
176
+ /**
177
+ * Server functions behind the public form runtime. Each site wraps its own
178
+ * `createServerFn` declarations (they must live in the site bundle) and hands
179
+ * plain callables to the package.
180
+ */
181
+ export type HostPublicFormFns = {
182
+ getPublicForm: (key: string) => Promise<unknown>;
183
+ submitForm: (input: HostSubmitFormInput) => Promise<unknown>;
184
+ savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
185
+ };
186
+
187
+ export type HostSiteAdapters = {
188
+ /** Hook returning the site's public form server functions. */
189
+ usePublicFormFns: () => HostPublicFormFns;
190
+ /**
191
+ * Hook returning the site's central trip/course source, used to fill
192
+ * `trip`/`course` dropdowns from a single source of truth.
193
+ */
194
+ useTripSource: () => unknown[];
195
+ };
196
+
197
+ let siteAdapters: Partial<HostSiteAdapters> = {};
198
+
199
+ export function registerSiteAdapters(adapters: Partial<HostSiteAdapters>): void {
200
+ siteAdapters = { ...siteAdapters, ...adapters };
201
+ }
202
+
203
+ /** Register public-site adapters while constructing a value the site exports. */
204
+ export function initializeSiteAdapters<T>(
205
+ adapters: Partial<HostSiteAdapters>,
206
+ initialize: () => T,
207
+ ): T {
208
+ registerSiteAdapters(adapters);
209
+ return initialize();
210
+ }
211
+
212
+ export function useHostPublicFormFns(): HostPublicFormFns {
213
+ const hook = siteAdapters.usePublicFormFns;
214
+ if (!hook) {
215
+ throw new Error(
216
+ '@bygghammar/cms-platform: site adapter "usePublicFormFns" is not registered. Call registerSiteAdapters() during site startup.',
217
+ );
218
+ }
219
+ return hook();
220
+ }
221
+
222
+ export function useHostTripSource(): unknown[] {
223
+ const hook = siteAdapters.useTripSource;
224
+ return hook ? hook() : [];
225
+ }
@@ -15,6 +15,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
15
15
 
16
16
  import { writeAudit } from "./audit-core";
17
17
  import { actorEmail, assertRole, assertStaff, type PlatformAuthContext } from "./guards";
18
+ import { logEmail } from "./email-log-core";
18
19
 
19
20
  /* eslint-disable @typescript-eslint/no-explicit-any */
20
21
 
@@ -111,6 +112,15 @@ export async function createUserCore(
111
112
  .from("user_roles")
112
113
  .insert(roles.map((role) => ({ user_id: uid, role })));
113
114
  }
115
+ if (!input.password) {
116
+ await logEmail(anyClient(admin), {
117
+ to: input.email,
118
+ subject: "Your new account",
119
+ provider: "Authentication service",
120
+ status: "sent",
121
+ purpose: "user_invite",
122
+ });
123
+ }
114
124
  await audit(deps, "user.create", "auth.users", uid, { email: input.email, roles });
115
125
  return { id: uid };
116
126
  }
@@ -153,6 +163,13 @@ export async function sendPasswordResetEmailCore(
153
163
  email: input.email,
154
164
  });
155
165
  if (error) throw new Error(error.message);
166
+ await logEmail(anyClient(admin), {
167
+ to: input.email,
168
+ subject: "Password reset",
169
+ provider: "Authentication service",
170
+ status: "sent",
171
+ purpose: "password_reset",
172
+ });
156
173
  await audit(deps, "user.send_reset", "auth.users", undefined, { email: input.email });
157
174
  return { ok: true };
158
175
  }
@@ -359,19 +376,18 @@ export async function sendTestEmailCore(
359
376
  )
360
377
  : ({ ok: false, error: "No from address configured for this provider or in the email settings." } as const);
361
378
 
362
- await anyClient(admin)
363
- .from("email_logs")
364
- .insert({
365
- to_address: input.to,
366
- from_address: fromEmail,
367
- reply_to: replyTo,
368
- subject,
369
- body_preview: text.slice(0, 200),
370
- provider: provider.name,
371
- status: result.ok ? "sent" : "failed",
372
- error: result.ok ? null : result.error,
373
- meta: { kind: provider.kind, provider_id: provider.id, test: true },
374
- });
379
+ await logEmail(anyClient(admin), {
380
+ to: input.to,
381
+ from: fromEmail,
382
+ replyTo: replyTo,
383
+ subject,
384
+ bodyPreview: text,
385
+ provider: provider.name,
386
+ status: result.ok ? "sent" : "failed",
387
+ error: result.ok ? null : result.error,
388
+ purpose: "provider_test",
389
+ meta: { kind: provider.kind, provider_id: provider.id, test: true },
390
+ });
375
391
 
376
392
  await audit(deps, "email_provider.test_send", "email_providers", provider.id, {
377
393
  to: input.to,
@@ -2,19 +2,20 @@
2
2
  * Host-agnostic audit trail writer.
3
3
  *
4
4
  * Every platform core records what an editor did in the append-only
5
- * `audit_logs` table. The host passes a privileged client because the trail must
6
- * be written even when the acting user cannot read it back, and the entry always
7
- * carries the acting user's id and email so the log stays attributable.
5
+ * `audit_logs` table. Entries always carry the acting user's id and email, and
6
+ * always carry a plain-English sentence in `meta.summary` describing what
7
+ * happened, so the Audit view never has to show raw codes.
8
8
  *
9
9
  * Auditing is deliberately best-effort: a failed log entry never fails the
10
10
  * operation the editor asked for.
11
11
  */
12
+ import { describeAudit } from "../admin/describe-log";
12
13
  import type { PlatformAuthContext } from "./guards";
13
14
  import { actorEmail } from "./guards";
14
15
 
15
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
16
17
 
17
- /** Minimal privileged-client shape the audit writer needs. */
18
+ /** Minimal client shape the audit writer needs. */
18
19
  export type AuditClient = {
19
20
  from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
20
21
  };
@@ -26,6 +27,9 @@ export type AuditEntry = {
26
27
  meta?: Record<string, unknown>;
27
28
  };
28
29
 
30
+ /** Writer shape hosts can pass into cores that need auditing. */
31
+ export type AuditWriter = (entry: AuditEntry) => Promise<void>;
32
+
29
33
  /** Write one audit entry. Never throws — logging must not break the action. */
30
34
  export async function writeAudit(
31
35
  admin: AuditClient,
@@ -33,13 +37,20 @@ export async function writeAudit(
33
37
  entry: AuditEntry,
34
38
  ): Promise<void> {
35
39
  try {
40
+ const meta = { ...(entry.meta ?? {}) } as Record<string, unknown>;
41
+ meta["summary"] = describeAudit({
42
+ action: entry.action,
43
+ entity: entry.entity ?? null,
44
+ entityId: entry.entityId ?? null,
45
+ meta,
46
+ });
36
47
  const { error } = await admin.from("audit_logs").insert({
37
48
  actor_id: context.userId,
38
49
  actor_email: actorEmail(context),
39
50
  action: entry.action,
40
51
  entity: entry.entity ?? null,
41
52
  entity_id: entry.entityId ?? null,
42
- meta: (entry.meta ?? {}) as unknown,
53
+ meta: meta as unknown,
43
54
  });
44
55
  if (error) console.warn("audit failed", error.message);
45
56
  } catch (err) {
@@ -52,3 +63,17 @@ export function auditWriter(admin: AuditClient, context: PlatformAuthContext, en
52
63
  return (action: string, entityId: string, meta: Record<string, unknown> = {}) =>
53
64
  writeAudit(admin, context, { action, entity, entityId, meta });
54
65
  }
66
+
67
+ /**
68
+ * Write one audit entry as the signed-in staff user.
69
+ *
70
+ * `audit_logs` accepts inserts from staff under row-level security, so cores
71
+ * that already ran a staff check can log through the ordinary request client
72
+ * without any privileged credential.
73
+ */
74
+ export async function auditAs(
75
+ context: PlatformAuthContext & { supabase: unknown },
76
+ entry: AuditEntry,
77
+ ): Promise<void> {
78
+ await writeAudit(context.supabase as unknown as AuditClient, context, entry);
79
+ }
@@ -10,6 +10,7 @@
10
10
  import type { Json } from "../i18n/json";
11
11
  import { stampManualEdits } from "../i18n/translation-meta";
12
12
  import { assertStaff, type PlatformAuthContext } from "./guards";
13
+ import { auditAs } from "./audit-core";
13
14
 
14
15
  /** Structural subset of a Supabase-shaped client used by this core. */
15
16
  export type ContentClient = {
@@ -64,5 +65,12 @@ export async function saveContentSectionCore(
64
65
  .from("content_sections")
65
66
  .upsert({ key: input.key, data: stamped as Json }, { onConflict: "key" });
66
67
  if (error) throw new Error(error.message);
68
+
69
+ await auditAs(context as never, {
70
+ action: "content.section.save",
71
+ entity: "content_sections",
72
+ entityId: input.key,
73
+ meta: { key: input.key },
74
+ });
67
75
  return { ok: true, key: input.key };
68
76
  }