@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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { assertStaff, type PlatformAuthContext } from "./guards";
10
+ import { auditAs } from "./audit-core";
10
11
 
11
12
  /* eslint-disable @typescript-eslint/no-explicit-any */
12
13
  /** Structural subset of a Supabase-shaped client used by this core. */
@@ -165,6 +166,18 @@ export async function saveLocaleDomainCore(
165
166
  { onConflict: "locale" },
166
167
  );
167
168
  if (error) throw new Error(error.message);
169
+
170
+ await auditAs(context as never, {
171
+ action: "domain.save",
172
+ entity: "locale_domains",
173
+ entityId: input.locale,
174
+ meta: {
175
+ locale: input.locale,
176
+ domain: normalized,
177
+ is_default: input.isDefault,
178
+ is_admin_domain: input.isAdminDomain,
179
+ },
180
+ });
168
181
  return { ok: true };
169
182
  }
170
183
 
@@ -176,5 +189,11 @@ export async function deleteLocaleDomainCore(
176
189
  await assertStaff(context);
177
190
  const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
178
191
  if (error) throw new Error(error.message);
192
+ await auditAs(context as never, {
193
+ action: "domain.delete",
194
+ entity: "locale_domains",
195
+ entityId: locale,
196
+ meta: { locale },
197
+ });
179
198
  return { ok: true };
180
199
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Central email log writer.
3
+ *
4
+ * Every message the platform attempts to send — form notifications, auto-replies,
5
+ * provider tests and account emails sent by the authentication service — is
6
+ * recorded in `email_logs`, successful or not, with a plain-English sentence in
7
+ * `meta.summary`. Logging is best-effort and never fails a send.
8
+ */
9
+ import { describeEmail } from "../admin/describe-log";
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+
13
+ export type EmailLogClient = {
14
+ from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
15
+ };
16
+
17
+ /** Why the message was sent, e.g. `form_notification` or `password_reset`. */
18
+ export type EmailPurpose =
19
+ | "form_notification"
20
+ | "form_autoreply"
21
+ | "form_test"
22
+ | "provider_test"
23
+ | "password_reset"
24
+ | "user_invite"
25
+ | (string & {});
26
+
27
+ export type EmailLogEntry = {
28
+ to: string;
29
+ from?: string | null;
30
+ replyTo?: string | null;
31
+ subject?: string | null;
32
+ bodyPreview?: string | null;
33
+ /** Provider name as shown in the backoffice, or the transport used. */
34
+ provider?: string | null;
35
+ status: "sent" | "failed" | "skipped" | string;
36
+ error?: string | null;
37
+ purpose?: EmailPurpose | null;
38
+ meta?: Record<string, unknown>;
39
+ };
40
+
41
+ /** Record one send attempt. Never throws. */
42
+ export async function logEmail(db: EmailLogClient, entry: EmailLogEntry): Promise<void> {
43
+ try {
44
+ const meta = { ...(entry.meta ?? {}) } as Record<string, unknown>;
45
+ if (entry.purpose) meta["purpose"] = entry.purpose;
46
+ meta["summary"] = describeEmail({
47
+ status: entry.status,
48
+ to: entry.to,
49
+ subject: entry.subject ?? null,
50
+ provider: entry.provider ?? null,
51
+ purpose: entry.purpose ?? null,
52
+ error: entry.error ?? null,
53
+ });
54
+ const { error } = await db.from("email_logs").insert({
55
+ to_address: entry.to,
56
+ from_address: entry.from ?? null,
57
+ reply_to: entry.replyTo ?? null,
58
+ subject: entry.subject ?? null,
59
+ body_preview: (entry.bodyPreview ?? "").slice(0, 200) || null,
60
+ provider: entry.provider ?? null,
61
+ status: entry.status,
62
+ error: entry.error ?? null,
63
+ meta: meta as unknown,
64
+ });
65
+ if (error) console.warn("email log failed", error.message);
66
+ } catch (err) {
67
+ console.warn("email log failed", err);
68
+ }
69
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { FormFieldDef } from "../forms/field-sets";
16
16
  import { sendViaProvider } from "./email-send-core";
17
+ import { logEmail } from "./email-log-core";
17
18
 
18
19
  /* eslint-disable @typescript-eslint/no-explicit-any */
19
20
  /** Structural subset of a Supabase-shaped privileged client. */
@@ -132,15 +133,16 @@ async function dispatch(
132
133
  },
133
134
  );
134
135
 
135
- await db.from("email_logs").insert({
136
- to_address: msg.to,
137
- from_address: fromEmail,
138
- reply_to: msg.replyTo,
136
+ await logEmail(db as never, {
137
+ to: msg.to,
138
+ from: fromEmail,
139
+ replyTo: msg.replyTo,
139
140
  subject: msg.subject,
140
- body_preview: msg.text.slice(0, 200),
141
+ bodyPreview: msg.text,
141
142
  provider: provider.name,
142
143
  status: result.ok ? "sent" : "failed",
143
144
  error: result.ok ? null : result.error,
145
+ purpose: typeof meta["purpose"] === "string" ? (meta["purpose"] as string) : null,
144
146
  meta: { ...meta, kind: provider.kind },
145
147
  });
146
148
 
@@ -33,6 +33,7 @@ import {
33
33
  type FormsContext,
34
34
  type FormsDb,
35
35
  } from "./forms-core";
36
+ import { auditAs } from "./audit-core";
36
37
  import {
37
38
  evaluateRule,
38
39
  isFieldVisible,
@@ -461,6 +462,12 @@ export async function setSubmissionStatusCore(
461
462
  .update({ status: data.status })
462
463
  .in("id", data.ids);
463
464
  if (error) throw new Error(error.message);
465
+ await auditAs(context as never, {
466
+ action: "submission.status",
467
+ entity: "form_submissions",
468
+ entityId: data.ids.join(", "),
469
+ meta: { status: data.status, count: data.ids.length },
470
+ });
464
471
  return { ok: true as const, updated: data.ids.length };
465
472
  }
466
473
 
@@ -800,6 +807,12 @@ export async function deletePartialsCore(context: FormsContext, ids: string[]) {
800
807
  await assertStaff(context);
801
808
  const { error } = await context.supabase.from("form_partials").delete().in("id", ids);
802
809
  if (error) throw new Error(error.message);
810
+ await auditAs(context as never, {
811
+ action: "submission.partial.delete",
812
+ entity: "form_partials",
813
+ entityId: ids.join(", "),
814
+ meta: { count: ids.length },
815
+ });
803
816
  return { ok: true as const, deleted: ids.length };
804
817
  }
805
818
 
@@ -1326,6 +1339,12 @@ export async function resolveDeliveryAttemptsCore(context: FormsContext, ids: st
1326
1339
  .update({ resolved_at: new Date().toISOString() })
1327
1340
  .in("id", ids);
1328
1341
  if (error) throw new Error(error.message);
1342
+ await auditAs(context as never, {
1343
+ action: "delivery.resolve",
1344
+ entity: "form_delivery_attempts",
1345
+ entityId: ids.join(", "),
1346
+ meta: { count: ids.length },
1347
+ });
1329
1348
  return { ok: true as const, resolved: ids.length };
1330
1349
  }
1331
1350
 
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { assertStaff, type PlatformAuthContext } from "./guards";
12
12
  import { clampFocal, focusKey, type FocusDevice, type FocusMap } from "../media/focus";
13
+ import { auditAs } from "./audit-core";
13
14
 
14
15
  /* eslint-disable @typescript-eslint/no-explicit-any */
15
16
 
@@ -95,6 +96,12 @@ export async function setMediaFocusCore(
95
96
  { onConflict: "path" },
96
97
  );
97
98
  if (error) throw new Error(error.message);
99
+ await auditAs(context as never, {
100
+ action: "media.focus.set",
101
+ entity: "media_focus",
102
+ entityId: path,
103
+ meta: { path, device, x: point.x, y: point.y },
104
+ });
98
105
  return { ok: true, path, device, ...point };
99
106
  }
100
107
 
@@ -105,6 +112,12 @@ export async function setMediaFocusCore(
105
112
  { onConflict: "path" },
106
113
  );
107
114
  if (error) throw new Error(error.message);
115
+ await auditAs(context as never, {
116
+ action: "media.focus.set",
117
+ entity: "media_focus",
118
+ entityId: path,
119
+ meta: { path, device, x: point.x, y: point.y },
120
+ });
108
121
  return { ok: true, path, device, ...point };
109
122
  }
110
123
 
@@ -127,10 +140,22 @@ export async function clearMediaFocusCore(
127
140
  .update({ mobile_x: null, mobile_y: null, updated_by: context.userId })
128
141
  .eq("path", path);
129
142
  if (error) throw new Error(error.message);
143
+ await auditAs(context as never, {
144
+ action: "media.focus.clear",
145
+ entity: "media_focus",
146
+ entityId: path,
147
+ meta: { path, device: "mobile" },
148
+ });
130
149
  return { ok: true };
131
150
  }
132
151
 
133
152
  const { error } = await context.supabase.from("media_focus").delete().eq("path", path);
134
153
  if (error) throw new Error(error.message);
154
+ await auditAs(context as never, {
155
+ action: "media.focus.clear",
156
+ entity: "media_focus",
157
+ entityId: path,
158
+ meta: { path, device: "desktop" },
159
+ });
135
160
  return { ok: true };
136
161
  }
@@ -9,6 +9,7 @@
9
9
  * public site; writes run under the host's authenticated context.
10
10
  */
11
11
  import { assertStaff, type PlatformAuthContext } from "./guards";
12
+ import { auditAs } from "./audit-core";
12
13
  import {
13
14
  cleanLocalized,
14
15
  metaKey,
@@ -77,6 +78,12 @@ export async function setMediaMetaCore(
77
78
  { onConflict: "path" },
78
79
  );
79
80
  if (error) throw new Error(error.message);
81
+ await auditAs(context as never, {
82
+ action: "media.meta.set",
83
+ entity: "media_meta",
84
+ entityId: path,
85
+ meta: { path },
86
+ });
80
87
  return { ok: true, path };
81
88
  }
82
89
 
@@ -91,5 +98,11 @@ export async function clearMediaMetaCore(
91
98
  if (!path) return { ok: true };
92
99
  const { error } = await context.supabase.from("media_meta").delete().eq("path", path);
93
100
  if (error) throw new Error(error.message);
101
+ await auditAs(context as never, {
102
+ action: "media.meta.clear",
103
+ entity: "media_meta",
104
+ entityId: path,
105
+ meta: { path },
106
+ });
94
107
  return { ok: true };
95
108
  }
@@ -6,6 +6,8 @@
6
6
  * so every site behaves identically.
7
7
  */
8
8
 
9
+ import { auditAs } from "./audit-core";
10
+
9
11
  /** Structural subset of a Supabase-shaped client used by these cores. */
10
12
  export type RedirectClient = {
11
13
  from: (table: string) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -50,6 +52,7 @@ export async function saveRedirectCore(
50
52
  client: RedirectClient,
51
53
  input: { id?: string; from_path: string; to_path: string; status: number },
52
54
  userId: string,
55
+ context?: Parameters<typeof auditAs>[0],
53
56
  ): Promise<{ ok: true }> {
54
57
  const row = {
55
58
  from_path: normalizePath(input.from_path),
@@ -61,15 +64,40 @@ export async function saveRedirectCore(
61
64
  ? await client.from("redirects").update(row).eq("id", input.id)
62
65
  : await client.from("redirects").insert({ ...row, created_by: userId });
63
66
  if (error) throw new Error(error.message);
67
+ if (context) {
68
+ await auditAs(context, {
69
+ action: "redirect.save",
70
+ entity: "redirects",
71
+ entityId: input.id ?? row.from_path,
72
+ meta: { from_path: row.from_path, to_path: row.to_path, status: row.status },
73
+ });
74
+ }
64
75
  return { ok: true };
65
76
  }
66
77
 
67
78
  export async function deleteRedirectCore(
68
79
  client: RedirectClient,
69
80
  id: string,
81
+ context?: Parameters<typeof auditAs>[0],
70
82
  ): Promise<{ ok: true }> {
83
+ const { data: existing } = await client
84
+ .from("redirects")
85
+ .select("from_path, to_path")
86
+ .eq("id", id)
87
+ .maybeSingle();
71
88
  const { error } = await client.from("redirects").delete().eq("id", id);
72
89
  if (error) throw new Error(error.message);
90
+ if (context) {
91
+ await auditAs(context, {
92
+ action: "redirect.delete",
93
+ entity: "redirects",
94
+ entityId: id,
95
+ meta: {
96
+ from_path: existing?.from_path ?? null,
97
+ to_path: existing?.to_path ?? null,
98
+ },
99
+ });
100
+ }
73
101
  return { ok: true };
74
102
  }
75
103
 
@@ -1,4 +1,5 @@
1
1
  /**
2
+ import { auditAs } from "./audit-core";
2
3
  * Host-agnostic core for the `site_settings` key/value store.
3
4
  *
4
5
  * Rows are `{ key, value jsonb }` where `key` is one of
@@ -13,6 +14,7 @@ import {
13
14
  type OverridableSettingKey,
14
15
  type SiteSettingOverrides,
15
16
  } from "../config/site-config";
17
+ import { auditAs } from "./audit-core";
16
18
 
17
19
  /* eslint-disable @typescript-eslint/no-explicit-any */
18
20
  type Client = {
@@ -20,6 +22,9 @@ type Client = {
20
22
  };
21
23
  /* eslint-enable @typescript-eslint/no-explicit-any */
22
24
 
25
+ /** Optional auth context, supplied when the caller wants the change audited. */
26
+ type AuditContext = Parameters<typeof auditAs>[0];
27
+
23
28
  export const SITE_SETTINGS_TABLE = "site_settings";
24
29
 
25
30
  /** Load every stored override, narrowed to the overridable subset. */
@@ -41,6 +46,7 @@ export async function saveSiteSettingsCore(
41
46
  client: Client,
42
47
  patch: SiteSettingOverrides,
43
48
  actorId?: string | null,
49
+ context?: AuditContext,
44
50
  ): Promise<SiteSettingOverrides> {
45
51
  const clean = parseSiteSettingOverrides(patch);
46
52
  const rows = (Object.keys(clean) as OverridableSettingKey[]).map((key) => ({
@@ -52,6 +58,14 @@ export async function saveSiteSettingsCore(
52
58
  if (rows.length) {
53
59
  const { error } = await client.from(SITE_SETTINGS_TABLE).upsert(rows, { onConflict: "key" });
54
60
  if (error) throw new Error(error.message);
61
+ if (context) {
62
+ await auditAs(context, {
63
+ action: "site_settings.update",
64
+ entity: "site_settings",
65
+ entityId: rows.map((r) => r.key).join(", "),
66
+ meta: { keys: rows.map((r) => r.key) },
67
+ });
68
+ }
55
69
  }
56
70
  return loadSiteSettingsCore(client);
57
71
  }
@@ -60,7 +74,16 @@ export async function saveSiteSettingsCore(
60
74
  export async function clearSiteSettingCore(
61
75
  client: Client,
62
76
  key: OverridableSettingKey,
77
+ context?: AuditContext,
63
78
  ): Promise<void> {
64
79
  const { error } = await client.from(SITE_SETTINGS_TABLE).delete().eq("key", key);
65
80
  if (error) throw new Error(error.message);
81
+ if (context) {
82
+ await auditAs(context, {
83
+ action: "site_settings.clear",
84
+ entity: "site_settings",
85
+ entityId: key,
86
+ meta: { key },
87
+ });
88
+ }
66
89
  }