@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -7,9 +7,56 @@
7
7
  *
8
8
  * Bump `PLATFORM_CONTRACT_VERSION` whenever a shared screen starts requiring
9
9
  * schema or adapter capabilities an older site would not have.
10
+ *
11
+ * ## v2 — publication rows decide what is public
12
+ *
13
+ * Saving a page version, publishing, unpublishing, scheduling a countdown
14
+ * reveal, trashing a page and restoring one are each a single database
15
+ * function call now, because each is several statements that must commit
16
+ * together or not at all. There is deliberately no JavaScript fallback for a
17
+ * database that lacks them: compensating for a half-applied publish with more
18
+ * un-transacted statements is the very thing that does not work. A site on v1
19
+ * schema cannot save a draft, so this is a contract change and not a
20
+ * click-time surprise.
21
+ *
22
+ * Three canonical migrations are new since the last published `0.90.0-m7.12`
23
+ * package, so a site upgrading from that baseline is missing all three:
24
+ *
25
+ * - `20260904090000_…sql` — restates the `draft` default, records the
26
+ * `pages.status` column semantics at that migration step, and adds two
27
+ * partial `pages` indexes: one on `key` for undeleted status-published
28
+ * rows, one on `status`/`updated_at` for undeleted rows.
29
+ * - `20260904140000_…sql` — `page_publications`, the backfill for pages that
30
+ * were already live, and the first transactional page functions.
31
+ * - `20260904160000_…sql` — the rest of the transactional page lifecycle:
32
+ * reveals, trash, restore and cancellation, plus two repair `UPDATE`s.
33
+ *
34
+ * The last two are the contract-v2 publication pair — they are what this
35
+ * version number means. All three are in `SCHEMA_MIGRATIONS`, though, so
36
+ * `missingMigrations` and `compareSchema` report every one that has not been
37
+ * applied, and applying only the pair still leaves the schema out of sync.
38
+ *
39
+ * Release order, and it is not optional:
40
+ *
41
+ * 1. Apply every missing canonical migration. Upgrading from the published
42
+ * `0.90.0-m7.12` baseline, that is all three files above, in
43
+ * chronological order.
44
+ * 2. Deploy this package, and the site pinned to contract v2 with it.
45
+ * 3. Verify both: `missingMigrations(applied)` — or `compareSchema(applied)`
46
+ * behind the version screen — reports no missing canonical migration, and
47
+ * `assertPlatformContract(2)` passes. The second compares two numbers in
48
+ * the site's own code and never reads the database, so it is no substitute
49
+ * for the first.
50
+ * 4. Let editors back in.
51
+ *
52
+ * The migrations are meant to be applied before the v2 package is deployed, and
53
+ * the still-running m7.12 package stays compatible with them during that
54
+ * ordered rollout. They are not pure DDL and not function-only: the set also
55
+ * carries an `ALTER … SET DEFAULT`, indexes and a column comment, a backfill
56
+ * `INSERT`, and two repair `UPDATE`s scoped to pages already in the trash.
10
57
  */
11
58
 
12
- export const PLATFORM_CONTRACT_VERSION = 1 as const;
59
+ export const PLATFORM_CONTRACT_VERSION = 2 as const;
13
60
 
14
61
  /**
15
62
  * Canonical migration filenames, oldest first. Files live in the published
@@ -71,6 +118,10 @@ export const SCHEMA_MIGRATIONS: readonly string[] = [
71
118
  "20260818040722_1bdf1b8f-7fb0-4ae8-8ecf-73f1fadff3c2.sql",
72
119
  "20260818040808_69a5e832-57e8-4aa7-aff8-7fd7777faa7c.sql",
73
120
  "20260818040839_e6523163-bbf5-4a39-9233-91759059569a.sql",
121
+ "20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql",
122
+ "20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql",
123
+ "20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
124
+ "20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
74
125
  ];
75
126
 
76
127
  export function missingMigrations(applied: readonly string[]): string[] {
@@ -31,6 +31,11 @@ export type ServerEntryOptions = {
31
31
  * enters the SSR module graph for sites that do not use it.
32
32
  */
33
33
  injections?: () => Promise<SiteCodeInjections>;
34
+ /**
35
+ * The site's backoffice path, so a site that moved it off the default still
36
+ * keeps tracking code out of its admin pages.
37
+ */
38
+ backofficePath?: string;
34
39
  };
35
40
 
36
41
  type ServerEntry = {
@@ -86,6 +91,7 @@ async function injectSiteCode(
86
91
  request: Request,
87
92
  response: Response,
88
93
  load: () => Promise<SiteCodeInjections>,
94
+ backofficePath: string | undefined,
89
95
  ): Promise<Response> {
90
96
  if (response.status >= 400) return response;
91
97
  const contentType = response.headers.get("content-type") ?? "";
@@ -94,7 +100,7 @@ async function injectSiteCode(
94
100
  try {
95
101
  const injections = await load();
96
102
  const url = new URL(request.url);
97
- if (!injections.isInjectablePath(url.pathname)) return response;
103
+ if (!injections.isInjectablePath(url.pathname, backofficePath)) return response;
98
104
 
99
105
  const bundle = await injections.resolve(url.host);
100
106
  if (!bundle.head && !bundle.bodyStart && !bundle.bodyEnd) return response;
@@ -118,7 +124,7 @@ export function createServerEntry(options: ServerEntryOptions = {}) {
118
124
  const response = await handler.fetch(request, env, ctx);
119
125
  const normalized = await normalizeCatastrophicSsrResponse(response);
120
126
  const injected = options.injections
121
- ? await injectSiteCode(request, normalized, options.injections)
127
+ ? await injectSiteCode(request, normalized, options.injections, options.backofficePath)
122
128
  : normalized;
123
129
  return await applyEdgeHeaders(request, injected, { compress: compressionEnabled() });
124
130
  } catch (error) {
@@ -10,6 +10,7 @@
10
10
  import type { Json } from "../i18n/json";
11
11
  import { stampManualEdits } from "../i18n/translation-meta";
12
12
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
13
+ import { scheduleProblemsIn } from "../cms/visibility-schedule";
13
14
  import { assertEditable } from "./edit-locks-core";
14
15
  import { assertStaff, type PlatformAuthContext } from "./guards";
15
16
  import { auditAs } from "./audit-core";
@@ -71,6 +72,21 @@ export async function saveContentSectionCore(
71
72
  sessionId: input.editSessionId,
72
73
  });
73
74
 
75
+ /*
76
+ * The editor shows schedule problems as you type, but the editor is not a
77
+ * boundary — this core is. A window that closes before it opens describes
78
+ * content that can never be visible, so it is refused here whatever sent it,
79
+ * with the path of the offending item so the message is actionable.
80
+ */
81
+ const problems = scheduleProblemsIn(input.data);
82
+ if (problems.length > 0) {
83
+ const first = problems[0]!;
84
+ throw new Error(
85
+ `Cannot save ${input.key}: ${first.path} — ${first.problem}` +
86
+ (problems.length > 1 ? ` (${problems.length - 1} more)` : ""),
87
+ );
88
+ }
89
+
74
90
  const { data: prevRow } = await context.supabase
75
91
  .from("content_sections")
76
92
  .select("data")
@@ -1,39 +1,36 @@
1
1
  /**
2
- * Host-agnostic logic for locale → domain mapping (`locale_domains`).
2
+ * Host-agnostic database logic for locale → domain mapping (`locale_domains`).
3
3
  *
4
4
  * Reads use a privileged client because the table reveals which hostname serves
5
- * the backoffice; writes run as the signed-in staff user. The host owns the
6
- * server functions, its locale union and the request headers.
5
+ * the backoffice; writes run as the signed-in admin, matching the table's own
6
+ * row-level policies. The host owns the server functions, its locale union and
7
+ * the request headers.
8
+ *
9
+ * The routing itself — matching, the public projection, and which hostname
10
+ * serves the backoffice — is pure and lives in `src/site/locale-domains.ts`,
11
+ * re-exported here so server callers keep one import.
7
12
  */
8
13
 
9
- import { assertStaff, type PlatformAuthContext } from "./guards";
14
+ import { assertRole, type PlatformAuthContext } from "./guards";
10
15
  import { auditAs } from "./audit-core";
16
+ import {
17
+ canonicalDomainProblemMessage,
18
+ parseCanonicalDomain,
19
+ } from "../site/host";
20
+ import {
21
+ domainChangeProblemMessage,
22
+ validateDomainChange,
23
+ type AdminDomainChange,
24
+ type LocaleDomainRecord,
25
+ } from "../site/locale-domains";
26
+
27
+ export * from "../site/locale-domains";
11
28
 
12
29
  /* eslint-disable @typescript-eslint/no-explicit-any */
13
30
  /** Structural subset of a Supabase-shaped client used by this core. */
14
31
  export type DomainsClient = { from: (table: string) => any };
15
32
  /* eslint-enable @typescript-eslint/no-explicit-any */
16
33
 
17
- export type LocaleDomainRecord = {
18
- locale: string;
19
- domain: string;
20
- isDefault: boolean;
21
- isAdminDomain: boolean;
22
- };
23
-
24
- /** Strip protocol, trailing slashes and casing from a stored domain. */
25
- export function normalizeDomain(domain: string): string {
26
- return domain
27
- .replace(/^https?:\/\//, "")
28
- .replace(/\/+$/, "")
29
- .toLowerCase();
30
- }
31
-
32
- /** Strip a leading `www.` and casing from a request host. */
33
- export function normalizeHost(host: string): string {
34
- return host.replace(/^www\./, "").toLowerCase();
35
- }
36
-
37
34
  function rowToDomain(row: {
38
35
  locale: string;
39
36
  domain: string;
@@ -58,115 +55,127 @@ export async function listLocaleDomainsCore(client: DomainsClient): Promise<Loca
58
55
  return (data ?? []).map(rowToDomain);
59
56
  }
60
57
 
61
- /** The mapping whose domain matches `host`, ignoring protocol and `www.`. */
62
- export function matchDomain(
63
- domains: LocaleDomainRecord[],
64
- host: string,
65
- ): LocaleDomainRecord | undefined {
66
- const normalizedHost = normalizeHost(host);
67
- return domains.find((d) => {
68
- const normalized = normalizeDomain(d.domain);
69
- return normalizedHost === normalized || normalizedHost === `www.${normalized}`;
70
- });
71
- }
72
-
73
58
  /**
74
- * Resolve the active locale for a request host, falling back to the default
75
- * mapping, then to the first mapping, then to a synthetic host-based record.
59
+ * Refuse a change the table must not be left in after.
60
+ *
61
+ * The current rows are read here rather than accepted from the caller. A host
62
+ * that forgot to pass them would otherwise skip every check silently, and the
63
+ * checks exist for cases — two languages on aliases of one hostname, a table
64
+ * left with no default — that a screen can prevent but only the database can
65
+ * actually know about at the moment of writing.
76
66
  */
77
- export function resolveRequestLocale(
78
- domains: LocaleDomainRecord[],
79
- host: string,
80
- defaultLocale: string,
81
- ): { locale: string; domain: LocaleDomainRecord } {
82
- const match = matchDomain(domains, host);
83
- if (match) return { locale: match.locale, domain: match };
84
-
85
- const fallback = domains.find((d) => d.isDefault) ??
86
- domains[0] ?? {
87
- locale: defaultLocale,
88
- domain: host || "localhost",
89
- isDefault: true,
90
- isAdminDomain: true,
91
- };
92
- return { locale: fallback.locale, domain: fallback };
67
+ async function assertChangeIsAllowed(
68
+ client: DomainsClient,
69
+ change: AdminDomainChange,
70
+ options: { siteUrlAuthoritative: boolean; invalidDomainMessage?: string },
71
+ ): Promise<void> {
72
+ const problem = validateDomainChange(await listLocaleDomainsCore(client), change, {
73
+ siteUrlAuthoritative: options.siteUrlAuthoritative,
74
+ });
75
+ if (!problem) return;
76
+ if (problem.reason === "invalid-domain" && options.invalidDomainMessage) {
77
+ throw new Error(options.invalidDomainMessage);
78
+ }
79
+ throw new Error(domainChangeProblemMessage(problem));
93
80
  }
94
81
 
82
+ export type SaveLocaleDomainOptions = {
83
+ /** Host-supplied message replacing the generic rejection text. */
84
+ invalidDomainMessage?: string;
85
+ /** `SITE_URL`'s hostname, when this install has one. */
86
+ forcedHost?: string | null;
87
+ };
88
+
89
+ export type DeleteLocaleDomainOptions = {
90
+ /** `SITE_URL`'s hostname, when this install has one. */
91
+ forcedHost?: string | null;
92
+ };
93
+
95
94
  /**
96
- * The domain that serves the backoffice.
95
+ * Whether `SITE_URL` — not the table — decides where the backoffice is.
97
96
  *
98
- * `forcedHost` (a self-host `SITE_URL`) is authoritative: an imported row from
99
- * another deployment must never send the backoffice to that other hostname.
100
- * Otherwise the flagged database row wins, and the request host is last resort.
97
+ * The value is checked exactly as it was configured, before any normalizing.
98
+ * Normalizing first strips the scheme, so `ftp://example.com` becomes the
99
+ * perfectly valid `example.com` and a `SITE_URL` nobody can serve is treated
100
+ * as authoritative. That is not a cosmetic mistake: authority here switches
101
+ * off the lockout check below, so a malformed `SITE_URL` would let an admin
102
+ * domain of `localhost` be saved on an install where the flag is still what
103
+ * routes the backoffice — and the next administrator to sign out could not
104
+ * get back in.
101
105
  */
102
- export function resolveAdminDomain(
103
- domains: LocaleDomainRecord[],
104
- options: { forcedHost?: string | null; requestHost?: string | null; defaultLocale: string },
105
- ): LocaleDomainRecord {
106
- const dbAdmin = domains.find((d) => d.isAdminDomain) ?? domains[0];
107
-
108
- if (options.forcedHost) {
109
- return {
110
- locale: dbAdmin?.locale ?? options.defaultLocale,
111
- domain: options.forcedHost,
112
- isDefault: dbAdmin?.isDefault ?? true,
113
- isAdminDomain: true,
114
- };
115
- }
116
- if (dbAdmin) return dbAdmin;
117
- return {
118
- locale: options.defaultLocale,
119
- domain: options.requestHost || "localhost",
120
- isDefault: true,
121
- isAdminDomain: true,
122
- };
123
- }
124
-
125
- /** Absolute origin for a locale, or "" when the locale has no mapping. */
126
- export function resolveLocaleOrigin(locale: string, domains: LocaleDomainRecord[]): string {
127
- const match = domains.find((d) => d.locale === locale);
128
- if (!match) return "";
129
- return `https://${normalizeDomain(match.domain)}`;
106
+ function siteUrlDecides(forcedHost: string | null | undefined): boolean {
107
+ return parseCanonicalDomain(forcedHost ?? "").ok;
130
108
  }
131
109
 
132
110
  /**
133
- * Staff-only upsert of one mapping. Keeps at most one default and one admin
134
- * domain by clearing the flags on every other locale first.
111
+ * Admin-only upsert of one mapping, matching the table's row-level policies.
112
+ * Keeps at most one default and one admin domain by clearing the flags on
113
+ * every other locale afterwards.
114
+ *
115
+ * The order is deliberate. There is no transaction here, so the row being
116
+ * saved is written first and the flags elsewhere are cleared only once it
117
+ * exists: an upsert that fails then changes nothing, instead of stripping the
118
+ * default or the admin domain off the rows that still hold it. Every statement
119
+ * is checked, and the audit entry is written only after all of them succeeded,
120
+ * so the log never claims a change that half happened. A later migration can
121
+ * make this atomic; until then this is the order that fails safely.
135
122
  */
136
123
  export async function saveLocaleDomainCore(
137
124
  context: PlatformAuthContext & { supabase: DomainsClient },
138
125
  input: { locale: string; domain: string; isDefault: boolean; isAdminDomain: boolean },
139
- options: { invalidDomainMessage?: string } = {},
126
+ options: SaveLocaleDomainOptions = {},
140
127
  ): Promise<{ ok: true }> {
141
- await assertStaff(context);
128
+ await assertRole(context, "admin");
129
+
130
+ const parsed = parseCanonicalDomain(input.domain);
131
+ if (!parsed.ok) {
132
+ throw new Error(
133
+ options.invalidDomainMessage ?? canonicalDomainProblemMessage(parsed.reason),
134
+ );
135
+ }
136
+ const normalized = parsed.domain;
137
+
138
+ await assertChangeIsAllowed(
139
+ context.supabase,
140
+ {
141
+ kind: "save",
142
+ locale: input.locale,
143
+ domain: normalized,
144
+ isDefault: input.isDefault,
145
+ isAdminDomain: input.isAdminDomain,
146
+ },
147
+ {
148
+ siteUrlAuthoritative: siteUrlDecides(options.forcedHost),
149
+ invalidDomainMessage: options.invalidDomainMessage,
150
+ },
151
+ );
142
152
 
143
- const normalized = normalizeDomain(input.domain);
144
- if (!normalized) throw new Error(options.invalidDomainMessage ?? "Invalid domain");
153
+ const upserted = await context.supabase.from("locale_domains").upsert(
154
+ {
155
+ locale: input.locale,
156
+ domain: normalized,
157
+ is_default: input.isDefault,
158
+ is_admin_domain: input.isAdminDomain,
159
+ },
160
+ { onConflict: "locale" },
161
+ );
162
+ if (upserted.error) throw new Error(upserted.error.message);
145
163
 
146
164
  if (input.isDefault) {
147
- await context.supabase
165
+ const cleared = await context.supabase
148
166
  .from("locale_domains")
149
167
  .update({ is_default: false })
150
168
  .neq("locale", input.locale);
169
+ if (cleared.error) throw new Error(cleared.error.message);
151
170
  }
152
171
  if (input.isAdminDomain) {
153
- await context.supabase
172
+ const cleared = await context.supabase
154
173
  .from("locale_domains")
155
174
  .update({ is_admin_domain: false })
156
175
  .neq("locale", input.locale);
176
+ if (cleared.error) throw new Error(cleared.error.message);
157
177
  }
158
178
 
159
- const { error } = await context.supabase.from("locale_domains").upsert(
160
- {
161
- locale: input.locale,
162
- domain: normalized,
163
- is_default: input.isDefault,
164
- is_admin_domain: input.isAdminDomain,
165
- },
166
- { onConflict: "locale" },
167
- );
168
- if (error) throw new Error(error.message);
169
-
170
179
  await auditAs(context as never, {
171
180
  action: "domain.save",
172
181
  entity: "locale_domains",
@@ -181,12 +190,20 @@ export async function saveLocaleDomainCore(
181
190
  return { ok: true };
182
191
  }
183
192
 
184
- /** Staff-only delete of one mapping. */
193
+ /** Admin-only delete of one mapping. */
185
194
  export async function deleteLocaleDomainCore(
186
195
  context: PlatformAuthContext & { supabase: DomainsClient },
187
196
  locale: string,
197
+ options: DeleteLocaleDomainOptions = {},
188
198
  ): Promise<{ ok: true }> {
189
- await assertStaff(context);
199
+ await assertRole(context, "admin");
200
+
201
+ await assertChangeIsAllowed(
202
+ context.supabase,
203
+ { kind: "delete", locale },
204
+ { siteUrlAuthoritative: siteUrlDecides(options.forcedHost) },
205
+ );
206
+
190
207
  const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
191
208
  if (error) throw new Error(error.message);
192
209
  await auditAs(context as never, {
@@ -15,6 +15,12 @@
15
15
  import type { FormFieldDef } from "../forms/field-sets";
16
16
  import { sendViaProvider } from "./email-send-core";
17
17
  import { logEmail } from "./email-log-core";
18
+ import { appendGoogleSheetRow, DEFAULT_WORKSHEET } from "./google-sheets-core";
19
+ import {
20
+ GOOGLE_SA_EMAIL_ENV,
21
+ GOOGLE_SA_KEY_ENV,
22
+ hasGoogleServiceAccount,
23
+ } from "./google-service-account";
18
24
 
19
25
  /* eslint-disable @typescript-eslint/no-explicit-any */
20
26
  /** Structural subset of a Supabase-shaped privileged client. */
@@ -398,24 +404,33 @@ export async function sendAutoreplyCore(
398
404
  );
399
405
  }
400
406
 
401
- const GATEWAY = "https://connector-gateway.lovable.dev/google_sheets/v4";
407
+ /**
408
+ * Legacy connector gateway.
409
+ *
410
+ * Kept only so installations configured before service-account delivery keep
411
+ * working untouched; it is never used when a Google service account is
412
+ * present, and it is the path to remove once every site has migrated.
413
+ */
414
+ const LEGACY_GATEWAY = "https://connector-gateway.lovable.dev/google_sheets/v4";
402
415
 
403
- /** Append one submission row to a Google Sheet via the connector gateway. */
404
- export async function appendToSheetCore(opts: {
416
+ async function appendViaLegacyGateway(opts: {
405
417
  sheetId: string;
406
418
  tab: string;
407
419
  values: string[];
408
420
  }): Promise<DeliveryResult> {
409
- const lovableKey = process.env['LOVABLE_API_KEY'];
410
- const connKey = process.env['GOOGLE_SHEETS_API_KEY'];
411
- if (!opts.sheetId) return { ok: false, error: "No Google Sheet configured." };
412
- if (!lovableKey || !connKey)
413
- return { ok: false, error: "Google Sheets is not connected for this project." };
421
+ const lovableKey = process.env["LOVABLE_API_KEY"];
422
+ const connKey = process.env["GOOGLE_SHEETS_API_KEY"];
423
+ if (!lovableKey || !connKey) {
424
+ return {
425
+ ok: false,
426
+ error: `Google Sheets is not configured. Set ${GOOGLE_SA_EMAIL_ENV} and ${GOOGLE_SA_KEY_ENV} on the server, then share the spreadsheet with that address.`,
427
+ };
428
+ }
414
429
 
415
- const tab = opts.tab?.trim() || "Sheet1";
430
+ const tab = opts.tab?.trim() || DEFAULT_WORKSHEET;
416
431
  const range = `${tab}!A:Z`;
417
432
  const res = await fetch(
418
- `${GATEWAY}/spreadsheets/${opts.sheetId}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`,
433
+ `${LEGACY_GATEWAY}/spreadsheets/${opts.sheetId}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`,
419
434
  {
420
435
  method: "POST",
421
436
  headers: {
@@ -434,6 +449,42 @@ export async function appendToSheetCore(opts: {
434
449
  return { ok: true };
435
450
  }
436
451
 
452
+ /**
453
+ * Append one submission row to a Google Sheet.
454
+ *
455
+ * Uses the server's Google service account when one is configured — the sheet
456
+ * stays private and is simply shared with the account — and falls back to the
457
+ * legacy connector gateway for installations that have not migrated yet.
458
+ *
459
+ * Never throws: the submission is already stored and must never be lost
460
+ * because a spreadsheet was unreachable.
461
+ */
462
+ export async function appendToSheetCore(opts: {
463
+ sheetId: string;
464
+ tab: string;
465
+ values: string[];
466
+ }): Promise<DeliveryResult> {
467
+ if (!opts.sheetId) return { ok: false, error: "No Google Sheet configured." };
468
+
469
+ try {
470
+ if (hasGoogleServiceAccount()) {
471
+ const res = await appendGoogleSheetRow({
472
+ spreadsheetId: opts.sheetId,
473
+ tab: opts.tab,
474
+ values: opts.values,
475
+ });
476
+ if (res.ok) return { ok: true };
477
+ console.error(`[forms] sheets append failed [${res.code}]: ${res.error}`);
478
+ return { ok: false, error: res.error };
479
+ }
480
+ return await appendViaLegacyGateway(opts);
481
+ } catch (err) {
482
+ const message = err instanceof Error ? err.message : String(err);
483
+ console.error(`[forms] sheets append threw: ${message}`);
484
+ return { ok: false, error: `Google Sheets delivery failed: ${message.slice(0, 300)}` };
485
+ }
486
+ }
487
+
437
488
  /**
438
489
  * POST one submission to an external endpoint (CRM, Zapier, Make…).
439
490
  * Signed with an optional shared secret so the receiver can verify origin.
@@ -49,6 +49,11 @@ import {
49
49
  type PublicForm,
50
50
  } from "../forms/field-sets";
51
51
  import { assertStaff } from "./guards";
52
+ import {
53
+ normalizeWorksheet,
54
+ testGoogleSheetConnection,
55
+ type SheetsTestResult,
56
+ } from "./google-sheets-core";
52
57
 
53
58
  /* eslint-disable @typescript-eslint/no-explicit-any */
54
59
 
@@ -1403,3 +1408,45 @@ export type FormVersionRow = {
1403
1408
  created_at: string;
1404
1409
  created_by_email: string | null;
1405
1410
  };
1411
+
1412
+ /**
1413
+ * Verify a form's Google Sheets configuration without writing a row.
1414
+ *
1415
+ * Resolves the form's settings exactly the way the submit path does — same
1416
+ * per-form override, same defaults, same worksheet fallback — so a green test
1417
+ * is a statement about real submissions rather than about whatever the editor
1418
+ * happens to have typed into the form since.
1419
+ *
1420
+ * Never returns a credential: the only Google identity in the result is the
1421
+ * service account's email address, which the editor needs in order to share
1422
+ * the spreadsheet.
1423
+ */
1424
+ export async function testSheetConnectionCore(
1425
+ context: FormsContext,
1426
+ admin: FormsDb,
1427
+ input: { formKey?: string; spreadsheetId?: string; tab?: string },
1428
+ ): Promise<SheetsTestResult & { spreadsheetId: string; tab: string }> {
1429
+ await assertStaff(context);
1430
+
1431
+ let spreadsheetId = (input.spreadsheetId ?? "").trim();
1432
+ let tab = (input.tab ?? "").trim();
1433
+
1434
+ // Unsaved edits win, so the editor can test before saving; anything left
1435
+ // blank falls back to what the submit path would use.
1436
+ if (input.formKey && (!spreadsheetId || !tab)) {
1437
+ const { data: form } = await admin
1438
+ .from("forms")
1439
+ .select("*")
1440
+ .eq("key", input.formKey)
1441
+ .maybeSingle();
1442
+ if (form) {
1443
+ const eff = effectiveSettings(form, await loadDefaults(admin));
1444
+ if (!spreadsheetId) spreadsheetId = eff.sheetId;
1445
+ if (!tab) tab = eff.sheetTab;
1446
+ }
1447
+ }
1448
+ tab = normalizeWorksheet(tab);
1449
+
1450
+ const result = await testGoogleSheetConnection({ spreadsheetId, tab });
1451
+ return { ...result, spreadsheetId, tab };
1452
+ }