@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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
package/src/platform/contract.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
6
|
-
* server functions, its locale union and
|
|
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 {
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
):
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
95
|
+
* Whether `SITE_URL` — not the table — decides where the backoffice is.
|
|
97
96
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
134
|
-
* domain by clearing the flags on
|
|
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:
|
|
126
|
+
options: SaveLocaleDomainOptions = {},
|
|
140
127
|
): Promise<{ ok: true }> {
|
|
141
|
-
await
|
|
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
|
|
144
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
410
|
-
const connKey = process.env[
|
|
411
|
-
if (!
|
|
412
|
-
|
|
413
|
-
|
|
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() ||
|
|
430
|
+
const tab = opts.tab?.trim() || DEFAULT_WORKSHEET;
|
|
416
431
|
const range = `${tab}!A:Z`;
|
|
417
432
|
const res = await fetch(
|
|
418
|
-
`${
|
|
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
|
+
}
|