Google Calendar
CI / ci (push) Canceled after 0s

Made google calendar auth not business specific.

global-settings.ts — new GoogleCalendarSettings section, encrypted at rest the same way as the SMTP password/VAPID private key.

google-calendar-rollout.ts — pushes the shared client ID/secret into every business's .env and recreates its container. GOOGLE_CALENDAR_REDIRECT_URI is left alone (it must keep tracking each business's own domain).

provisioning.ts / compose-template.ts — new businesses now pick up the global client automatically at creation, same as Email/Push already do.

Settings UI — new "Google Calendar" card at the bottom of Manager's Settings page (google-calendar-settings-form.tsx), with Save + "Reapply to all businesses."
This commit is contained in:
Stefan Lazic committed 2026-09-12 15:09:48 +01:00
1 parent 65e141d527
commit de32175916
11 files changed
+684 -10

No files matched your search

@@ -10,6 +10,7 @@ const baseValues: BusinessEnvValues = {
authSecret: "auth-secret",
installationToken: "the-installation-token-value",
cronSecret: "cron-secret",
calendarTokenEncryptionKey: "calendar-token-encryption-key",
};
describe("generateBusinessEnv — INSTALLATION_TOKEN", () => {
@@ -76,3 +77,47 @@ describe("generateBusinessEnv — SUPER_ADMIN_*", () => {
expect(env).not.toContain("SUPER_ADMIN_PASSWORD");
});
});
describe("generateBusinessEnv — GOOGLE_CALENDAR_* / CALENDAR_TOKEN_ENCRYPTION_KEY", () => {
it("leaves the OAuth client blank when no global default is configured", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID=""');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET=""');
});
it("bakes in the global default OAuth client when given one", () => {
const env = generateBusinessEnv({
...baseValues,
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="abc.apps.googleusercontent.com"');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shh"');
});
it("always derives the redirect URI from this business's own domain, regardless of the shared OAuth client", () => {
const env = generateBusinessEnv({
...baseValues,
domain: "acme.example.com",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
});
it("always writes the given calendarTokenEncryptionKey, distinct per business, even when the OAuth client is shared", () => {
const envA = generateBusinessEnv({
...baseValues,
calendarTokenEncryptionKey: "key-a",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
const envB = generateBusinessEnv({
...baseValues,
domain: "other.example.com",
calendarTokenEncryptionKey: "key-b",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(envA).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-a"');
expect(envB).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-b"');
expect(envA).not.toContain("key-b");
expect(envB).not.toContain("key-a");
});
});
@@ -0,0 +1,89 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
isGoogleCalendarConfigComplete,
readGoogleCalendarSettingsView,
resolveGoogleCalendarSettings,
saveGoogleCalendarSettings,
saveEmailSettings,
readEmailSettingsView,
} from "../global-settings";
describe("global-settings — Google Calendar (encrypted client secret)", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-google-calendar-test-"));
process.env.STACKS_DIR = stacksDir;
process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64);
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey;
await rm(stacksDir, { recursive: true, force: true });
});
it("returns the empty default before anything has been saved", async () => {
expect(await readGoogleCalendarSettingsView()).toEqual({ clientId: "", hasClientSecret: false });
expect(await resolveGoogleCalendarSettings()).toBeNull();
});
it("saves and reads back the client ID/secret, never exposing the secret in the view", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const view = await readGoogleCalendarSettingsView();
expect(view).toEqual({ clientId: "abc.apps.googleusercontent.com", hasClientSecret: true });
const resolved = await resolveGoogleCalendarSettings();
expect(resolved).toEqual({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
});
it("never stores the client secret in plaintext on disk", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const { readFile } = await import("node:fs/promises");
const raw = await readFile(path.join(stacksDir, "manager", "global-settings.json"), "utf8");
expect(raw).not.toContain("top-secret");
});
it("a blank clientSecret on save keeps whatever secret is already stored", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "" });
const resolved = await resolveGoogleCalendarSettings();
expect(resolved?.clientSecret).toBe("top-secret");
});
it("saving does not clobber email settings already stored in the same file", async () => {
await saveEmailSettings({
host: "smtp.example.com",
port: 587,
username: "user",
password: "pw",
encryption: "starttls",
fromAddress: "from@example.com",
fromName: "",
replyTo: "",
});
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const email = await readEmailSettingsView();
expect(email.host).toBe("smtp.example.com");
expect(email.hasPassword).toBe(true);
});
it("isGoogleCalendarConfigComplete requires both a non-blank client ID and secret", () => {
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "shh" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "shh" })).toBe(true);
});
});
@@ -0,0 +1,109 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: vi.fn(),
runMigrations: vi.fn(),
}));
describe("applyGoogleCalendarSettingsToAllBusinesses", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-gcal-rollout-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("writes the shared client ID/secret into every business and force-recreates each container", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="existing-key"'].join("\n") + "\n");
const results = await applyGoogleCalendarSettingsToAllBusinesses({
clientId: "shared-client-id",
clientSecret: "shared-client-secret",
});
expect(results).toEqual([{ slug: "acme", ok: true }]);
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="shared-client-id"');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shared-client-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("never touches GOOGLE_CALENDAR_REDIRECT_URI — it stays derived from each business's own domain", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack(
"acme",
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"'].join(
"\n",
) + "\n",
);
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
});
it("leaves an existing CALENDAR_TOKEN_ENCRYPTION_KEY untouched — it must never be shared across businesses", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"'].join("\n") + "\n");
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"');
});
it("backfills a fresh, distinct CALENDAR_TOKEN_ENCRYPTION_KEY for a business that never had one (pre-fix)", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
await createStack("other", ['BUSINESS_NAME="Other"', 'DOMAIN="other.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const envA = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
const envB = await readFile(path.join(stacksDir, "other", ".env"), "utf8");
const keyA = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envA)?.[1];
const keyB = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envB)?.[1];
expect(keyA).toBeTruthy();
expect(keyB).toBeTruthy();
expect(keyA).not.toBe(keyB);
});
it("reports per-business failures without aborting the rest", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"'].join("\n") + "\n");
await createStack("broken", ['BUSINESS_NAME="Broken"', 'DOMAIN="broken.example.com"'].join("\n") + "\n");
composeUpMock.mockImplementation(async (dir: string) => {
if (dir.includes("broken")) throw new Error("container recreate failed");
});
const results = await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
expect(results).toContainEqual({ slug: "acme", ok: true });
expect(results).toContainEqual({ slug: "broken", ok: false, error: "container recreate failed" });
});
});
+31 -3
View File
@@ -70,6 +70,11 @@ export interface BusinessEnvSuperAdminValues {
lastName: string;
}
export interface BusinessEnvGoogleCalendarValues {
clientId: string;
clientSecret: string;
}
export interface BusinessEnvValues {
name: string;
domain: string;
@@ -79,6 +84,16 @@ export interface BusinessEnvValues {
authSecret: string;
installationToken: string;
cronSecret: string;
/**
* Encrypts this business's own stored Google Calendar refresh tokens
* (see src/lib/security/token-encryption.ts) — always generated fresh
* per business, the same as authSecret/installationToken/cronSecret
* above, and deliberately NOT part of the global Google Calendar
* settings below: unlike the OAuth client itself, this key must never
* be shared across businesses, since it's what protects each
* business's own tokens in its own database.
*/
calendarTokenEncryptionKey: string;
/**
* Global platform email settings (see src/lib/global-settings.ts),
* resolved once at provisioning time and baked straight into this
@@ -92,6 +107,8 @@ export interface BusinessEnvValues {
push?: BusinessEnvPushValues;
/** Global default Super Administrator (see src/lib/global-settings.ts) — same "resolved once at provisioning time" pattern as email/push, but not a secret (no password field exists at all — sign-in is OTP-only). Omitted leaves SUPER_ADMIN_EMAIL blank, same as before this existed. */
superAdmin?: BusinessEnvSuperAdminValues;
/** Global platform Google Calendar OAuth client (see src/lib/global-settings.ts) — same pattern as `email`/`push` above. Omitted falls back to today's blank defaults. */
googleCalendar?: BusinessEnvGoogleCalendarValues;
}
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
@@ -137,6 +154,13 @@ SUPER_ADMIN_LAST_NAME="${superAdmin.lastName}"`
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""`;
const googleCalendar = values.googleCalendar;
const googleCalendarBlock = googleCalendar
? `GOOGLE_CALENDAR_CLIENT_ID="${googleCalendar.clientId}"
GOOGLE_CALENDAR_CLIENT_SECRET="${googleCalendar.clientSecret}"`
: `GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""`;
return `# Generated by the management UI. See .env.business.example for what
# each of these does.
# BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit
@@ -172,10 +196,14 @@ ${superAdminBlock}
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""
# Managed centrally — see the Manager's Settings > Google Calendar page.
# Changing it there updates every business automatically. The redirect
# URI and token encryption key below are per-business, not part of that
# shared setting — see this function's own values.calendarTokenEncryptionKey
# doc comment for why.
${googleCalendarBlock}
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
CALENDAR_TOKEN_ENCRYPTION_KEY=""
CALENDAR_TOKEN_ENCRYPTION_KEY="${values.calendarTokenEncryptionKey}"
MEDIA_MAX_UPLOAD_BYTES="5000000"
+91 -2
View File
@@ -55,10 +55,17 @@ export interface SuperAdminSettings {
lastName: string;
}
/** Persisted shape — the client secret field only ever holds the encrypted envelope, never plaintext. */
interface StoredGoogleCalendarSettings {
clientId: string;
encryptedClientSecret: EncryptedPayload | null;
}
interface StoredGlobalSettings {
email: StoredEmailSettings | null;
push: StoredPushSettings | null;
superAdmin: SuperAdminSettings | null;
googleCalendar: StoredGoogleCalendarSettings | null;
}
export interface LastTestResult {
@@ -119,6 +126,24 @@ export interface ResolvedPushSettings {
subject: string;
}
/** Safe-to-render shape — never carries the real client secret, only whether one is set. The client ID is not a secret and is shown in full. */
export interface GoogleCalendarSettingsView {
clientId: string;
hasClientSecret: boolean;
}
/** Write-side input — `clientSecret` omitted/blank means "keep whatever is already stored" (same rule as EmailSettingsInput's password). */
export interface GoogleCalendarSettingsInput {
clientId: string;
clientSecret?: string;
}
/** The fully-resolved shape (real plaintext client secret included) — used only server-side, for the actual .env push, never rendered or logged. */
export interface ResolvedGoogleCalendarSettings {
clientId: string;
clientSecret: string;
}
function settingsFilePath(): string {
return path.join(stacksRoot(), "manager", "global-settings.json");
}
@@ -127,10 +152,15 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
try {
const raw = await readFile(settingsFilePath(), "utf8");
const parsed = JSON.parse(raw) as StoredGlobalSettings;
return { email: parsed.email ?? null, push: parsed.push ?? null, superAdmin: parsed.superAdmin ?? null };
return {
email: parsed.email ?? null,
push: parsed.push ?? null,
superAdmin: parsed.superAdmin ?? null,
googleCalendar: parsed.googleCalendar ?? null,
};
} catch {
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
return { email: null, push: null, superAdmin: null };
return { email: null, push: null, superAdmin: null, googleCalendar: null };
}
}
@@ -401,3 +431,62 @@ export async function saveSuperAdminSettings(input: SuperAdminSettings): Promise
await writeStoredSettings({ ...current, superAdmin: next });
return next;
}
// ── Google Calendar ──────────────────────────────────────────────────────
const DEFAULT_GOOGLE_CALENDAR_VIEW: GoogleCalendarSettingsView = {
clientId: "",
hasClientSecret: false,
};
/** Safe for the Settings page to render directly — the client secret is never included, only whether one is set. */
export async function readGoogleCalendarSettingsView(): Promise<GoogleCalendarSettingsView> {
const { googleCalendar } = await readStoredSettings();
if (!googleCalendar) return DEFAULT_GOOGLE_CALENDAR_VIEW;
return { clientId: googleCalendar.clientId, hasClientSecret: googleCalendar.encryptedClientSecret !== null };
}
/** Server-side only — decrypts the real client secret. Used for the .env push. Returns null if nothing is configured yet. */
export async function resolveGoogleCalendarSettings(): Promise<ResolvedGoogleCalendarSettings | null> {
const { googleCalendar } = await readStoredSettings();
if (!googleCalendar) return null;
return {
clientId: googleCalendar.clientId,
clientSecret: googleCalendar.encryptedClientSecret ? decrypt(googleCalendar.encryptedClientSecret) : "",
};
}
export function isGoogleCalendarConfigComplete(settings: Pick<ResolvedGoogleCalendarSettings, "clientId" | "clientSecret">): boolean {
return Boolean(settings.clientId.trim() && settings.clientSecret.trim());
}
/**
* Persists the shared Google Calendar OAuth client — a blank/omitted
* `clientSecret` keeps whatever is already stored (see this file's own
* header, same rule as saveEmailSettings). One client is meant to cover
* every business on this installation: the client ID/secret identify the
* OAuth application itself, not any one tenant, so Google Cloud Console's
* authorized-redirect-URI list is where each business's own callback URL
* gets added, not here.
*/
export async function saveGoogleCalendarSettings(input: GoogleCalendarSettingsInput): Promise<ResolvedGoogleCalendarSettings> {
const current = await readStoredSettings();
const keepExistingSecret = !input.clientSecret || !input.clientSecret.trim();
let encryptedClientSecret = current.googleCalendar?.encryptedClientSecret ?? null;
let resolvedSecret = "";
if (!keepExistingSecret) {
encryptedClientSecret = encrypt(input.clientSecret!.trim());
resolvedSecret = input.clientSecret!.trim();
} else if (encryptedClientSecret) {
resolvedSecret = decrypt(encryptedClientSecret);
}
const next: StoredGoogleCalendarSettings = {
clientId: input.clientId.trim(),
encryptedClientSecret,
};
await writeStoredSettings({ ...current, googleCalendar: next });
return { clientId: next.clientId, clientSecret: resolvedSecret };
}
@@ -0,0 +1,64 @@
import path from "node:path";
import { composeUp } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import type { ResolvedGoogleCalendarSettings } from "./global-settings";
import { randomHex } from "./random";
import { listBusinessStacks, stackDir } from "./stacks";
export interface GoogleCalendarRolloutResult {
slug: string;
ok: boolean;
error?: string;
}
/**
* Pushes the global Google Calendar OAuth client into every business's
* own `.env` and force-recreates its container — same mechanism as
* email-rollout.ts/push-rollout.ts/super-admin-rollout.ts.
*
* GOOGLE_CALENDAR_REDIRECT_URI is deliberately never touched here: it's
* derived from each business's own domain (see provisioning.ts's
* updateBusiness), not from this global setting, and must keep pointing
* at that business's own /api/staff/calendar/google/callback regardless
* of which shared OAuth client is doing the authorizing.
*
* CALENDAR_TOKEN_ENCRYPTION_KEY is backfilled here too, but only when a
* business doesn't already have one — every business generates its own
* at provisioning time now (see compose-template.ts), but a business
* provisioned before that existed would otherwise have a blank key and
* hard-fail the moment a staff member tries to connect a calendar (see
* src/lib/security/token-encryption.ts). Unlike the OAuth client itself,
* this key must never be shared across businesses — it encrypts each
* business's own stored refresh tokens in its own database — so a
* business that already has one keeps it untouched forever, the same way
* EMAIL_REPLY_TO is only ever filled in as a fallback, never overwritten.
*/
export async function applyGoogleCalendarSettingsToAllBusinesses(
settings: ResolvedGoogleCalendarSettings,
): Promise<GoogleCalendarRolloutResult[]> {
const businesses = await listBusinessStacks();
const results: GoogleCalendarRolloutResult[] = [];
for (const business of businesses) {
try {
const dir = stackDir(business.slug);
const envPath = path.join(dir, ".env");
const currentEnv = await readEnvFile(envPath);
const currentEncryptionKey = currentEnv ? readEnvValue(currentEnv, "CALENDAR_TOKEN_ENCRYPTION_KEY") : null;
const businessHasOwnEncryptionKey = Boolean(currentEncryptionKey && currentEncryptionKey.trim());
await upsertEnvValues(envPath, {
GOOGLE_CALENDAR_CLIENT_ID: settings.clientId,
GOOGLE_CALENDAR_CLIENT_SECRET: settings.clientSecret,
...(businessHasOwnEncryptionKey ? {} : { CALENDAR_TOKEN_ENCRYPTION_KEY: randomHex(32) }),
});
await composeUp(dir, { forceRecreate: true });
results.push({ slug: business.slug, ok: true });
} catch (error) {
results.push({ slug: business.slug, ok: false, error: error instanceof Error ? error.message : String(error) });
}
}
return results;
}
+16
View File
@@ -6,10 +6,12 @@ import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import {
isEmailConfigComplete,
isGoogleCalendarConfigComplete,
isPushConfigComplete,
isSuperAdminConfigComplete,
readSuperAdminSettings,
resolveEmailSettings,
resolveGoogleCalendarSettings,
resolvePushSettings,
} from "./global-settings";
import { randomHex } from "./random";
@@ -140,6 +142,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
const authSecret = randomHex(32);
const installationToken = randomHex(32);
const cronSecret = randomHex(32);
const calendarTokenEncryptionKey = randomHex(32);
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
@@ -185,6 +188,17 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
? { email: globalSuperAdminSettings.email, firstName: globalSuperAdminSettings.firstName, lastName: globalSuperAdminSettings.lastName }
: undefined;
// Same pattern again — a shared Google Calendar OAuth client (Manager's
// Settings > Google Calendar page), baked in at creation only when
// fully configured. calendarTokenEncryptionKey above is NOT part of
// this — see compose-template.ts's own doc comment for why it's always
// freshly generated per business instead.
const globalGoogleCalendarSettings = await resolveGoogleCalendarSettings();
const googleCalendar =
globalGoogleCalendarSettings && isGoogleCalendarConfigComplete(globalGoogleCalendarSettings)
? { clientId: globalGoogleCalendarSettings.clientId, clientSecret: globalGoogleCalendarSettings.clientSecret }
: undefined;
const dir = stackDir(slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
@@ -199,9 +213,11 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
authSecret,
installationToken,
cronSecret,
calendarTokenEncryptionKey,
email,
push,
superAdmin,
googleCalendar,
}),
"utf8",
);