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."
65 lines
2.8 KiB
TypeScript
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;
|
|
}
|