Files
NextChair/manager/src/lib/google-calendar-rollout.ts
T
Stefan Lazic de32175916
CI / ci (push) Canceled after 0s
Google Calendar
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."
2026-09-12 15:09:48 +01:00

65 lines
2.8 KiB
TypeScript

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;
}