Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/calendar/services/CalendarConnectionService.server.ts
T

168 lines
7.7 KiB
TypeScript

// AK-P16a · calendar connection lifecycle (connect / callback / disconnect).
//
// OAuth tokens are sealed with the per-tenant DEK envelope (AD-8) — never stored
// plain, never logged. Writes are audited automatically by the M001 Prisma
// middleware (DoD 13); connect/disconnect are USER-account events, not CRM
// activity-stream entries (the activity model is CRM-subject only — the
// appointment-sync activity belongs to AK-P16b's booking). Webhook subscription is
// best-effort: a failure degrades the connection with a Klartext hint, it does not
// block the connect.
import { decryptTenantField, encryptTenantField } from "@hivegrid/db/crypto/tenant-envelope.server";
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
import { db } from "~/utils/db.server";
import { googleCalendarAdapter, CalendarNotConfiguredError } from "./adapters/GoogleCalendarAdapter.server";
import type { CalendarCreds } from "./adapters/CalendarAdapter";
import { signCalendarOAuthState, verifyCalendarOAuthState } from "./oauthState.server";
export class CalendarConnectError extends Error {
readonly code: "not_configured" | "invalid_state" | "exchange_failed";
constructor(code: CalendarConnectError["code"]) {
super(code);
this.name = "CalendarConnectError";
this.code = code;
}
}
/** Build the Google consent URL for the current tenant user. */
export function buildConnectUrl(input: { tenantId: string; userId: string; tenantSlug: string }): string {
if (!googleCalendarAdapter.isConfigured()) {
throw new CalendarConnectError("not_configured");
}
const state = signCalendarOAuthState(input);
return googleCalendarAdapter.buildAuthorizationUrl(state);
}
/**
* Complete the OAuth callback: verify state, exchange the code, seal the tokens,
* upsert the per-user connection, and best-effort subscribe the push channel.
* Returns the tenant slug + ids so the callback route can trigger the initial sync
* (kept out of this service to avoid a CalendarSyncService import cycle).
*/
export async function completeOAuthCallback(input: { code: string; state: string }): Promise<{ tenantSlug: string; tenantId: string; connectionId: string }> {
const verified = verifyCalendarOAuthState(input.state);
if (!verified.ok) {
throw new CalendarConnectError("invalid_state");
}
const { tenantId, userId, tenantSlug } = verified.payload;
const rlsContext = getRlsExecutionContext();
if (rlsContext?.tenantId !== tenantId || rlsContext.userId !== userId) {
throw new CalendarConnectError("invalid_state");
}
let exchange: Awaited<ReturnType<typeof googleCalendarAdapter.exchangeCode>>;
try {
exchange = await googleCalendarAdapter.exchangeCode(input.code);
} catch (error) {
if (error instanceof CalendarNotConfiguredError) {
throw new CalendarConnectError("not_configured");
}
throw new CalendarConnectError("exchange_failed");
}
const { tokens, account } = exchange;
// Seal refresh + access tokens separately (AD-8). A refresh token is only
// returned on first consent; keep an existing one if Google omits it.
const refreshCiphertext = tokens.refreshToken ? await encryptTenantField(tenantId, tokens.refreshToken) : null;
const accessCiphertext = await encryptTenantField(tenantId, tokens.accessToken);
const connection = await db.calendarConnection.upsert({
where: { tenantId_userId_provider_externalAccountId: { tenantId, userId, provider: "google", externalAccountId: account.accountId } },
create: {
tenantId,
userId,
provider: "google",
externalAccountEmail: account.email,
externalAccountId: account.accountId,
oauthRefreshTokenCiphertext: refreshCiphertext,
oauthAccessTokenCiphertext: accessCiphertext,
oauthAccessTokenExpiresAt: tokens.expiresAt,
oauthScopes: [...tokens.scopes],
status: "active",
},
update: {
externalAccountEmail: account.email,
// Preserve the stored refresh token when Google omits it on re-consent.
...(refreshCiphertext ? { oauthRefreshTokenCiphertext: refreshCiphertext } : {}),
oauthAccessTokenCiphertext: accessCiphertext,
oauthAccessTokenExpiresAt: tokens.expiresAt,
oauthScopes: [...tokens.scopes],
status: "active",
lastErrorText: null,
archivedAt: null,
},
select: { id: true },
});
await trySubscribeWebhook({ tenantId, connectionId: connection.id, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? "" });
return { tenantSlug, tenantId, connectionId: connection.id };
}
/**
* Best-effort push-channel subscription for the primary calendar. Needs a public
* webhook URL (GOOGLE_CALENDAR_WEBHOOK_URL); absent or failing it degrades the
* connection with a Klartext hint rather than failing the connect.
*/
async function trySubscribeWebhook(input: { tenantId: string; connectionId: string; accessToken: string; refreshToken: string }): Promise<void> {
const webhookUrl = process.env.GOOGLE_CALENDAR_WEBHOOK_URL;
if (!webhookUrl) {
return;
}
const creds: CalendarCreds = { accessToken: input.accessToken, refreshToken: input.refreshToken };
// The channel id doubles as the per-connection lookup; the token is the
// verification secret echoed back as X-Goog-Channel-Token.
const channelId = `cal_${input.connectionId}`;
try {
const sub = await googleCalendarAdapter.subscribeWebhook(creds, "primary", channelId, webhookUrl, input.connectionId);
await db.calendarConnection.update({
where: { id: input.connectionId },
data: { webhookSubscriptionId: sub.subscriptionId, webhookSubscriptionExpiresAt: sub.expiresAt },
});
} catch {
await db.calendarConnection.update({
where: { id: input.connectionId },
data: { status: "active", lastErrorText: "calendar.health.webhookSubscriptionFailed" },
});
}
}
/** Decrypt a connection's sealed tokens into adapter creds (server-only). */
export async function loadConnectionCreds(tenantId: string, connectionId: string): Promise<CalendarCreds | null> {
const row = await db.calendarConnection.findFirst({
where: { id: connectionId, tenantId },
select: { oauthRefreshTokenCiphertext: true, oauthAccessTokenCiphertext: true, oauthAccessTokenExpiresAt: true },
});
if (!row?.oauthRefreshTokenCiphertext || !row.oauthAccessTokenCiphertext) {
return null;
}
const [refreshToken, accessToken] = await Promise.all([
decryptTenantField(tenantId, row.oauthRefreshTokenCiphertext),
decryptTenantField(tenantId, row.oauthAccessTokenCiphertext),
]);
return { refreshToken, accessToken, expiresAt: row.oauthAccessTokenExpiresAt };
}
/**
* Disconnect a connection (owner or admin). Best-effort revoke of the push channel
* + provider tokens, then soft-revoke locally (status revoked, soft-delete). The
* external mirror events are optionally removed (M25 disconnect dialog) — deferred
* to the sync service; here we drop the local connection + its push channel.
*/
export async function disconnectConnection(input: { tenantId: string; connectionId: string }): Promise<void> {
const row = await db.calendarConnection.findFirst({
where: { id: input.connectionId, tenantId: input.tenantId, archivedAt: null },
select: { id: true, webhookSubscriptionId: true },
});
if (!row) {
return;
}
const creds = await loadConnectionCreds(input.tenantId, input.connectionId);
if (creds && row.webhookSubscriptionId) {
await googleCalendarAdapter.unsubscribeWebhook(creds, row.webhookSubscriptionId, row.webhookSubscriptionId).catch(() => undefined);
}
await db.calendarConnection.update({
where: { id: row.id },
data: { status: "revoked", archivedAt: new Date(), oauthRefreshTokenCiphertext: null, oauthAccessTokenCiphertext: null, webhookSubscriptionId: null },
});
}