diff --git a/google-calendar-sa/server/lib/sa-config-store.ts b/google-calendar-sa/server/lib/sa-config-store.ts new file mode 100644 index 00000000..e86834e8 --- /dev/null +++ b/google-calendar-sa/server/lib/sa-config-store.ts @@ -0,0 +1,72 @@ +/** + * Persist SA connection configs in Supabase so the scheduler survives + * pod restarts without waiting for onChange to fire. + * + * Table: calendar_sa_connections + * connection_id text primary key + * service_account_json text not null + * impersonate_emails text[] not null + * lead_minutes int not null default 10 + * updated_at timestamptz default now() + */ + +import { getSupabaseClient } from "google-calendar/supabase"; + +const TABLE = "calendar_sa_connections"; + +interface SAConfigRow { + connection_id: string; + service_account_json: string; + impersonate_emails: string[]; + lead_minutes: number; + updated_at: string; +} + +export interface SAConfig { + connectionId: string; + serviceAccountJson: string; + impersonateEmails: string[]; + leadMinutes: number; +} + +export async function saveSAConfig(config: SAConfig): Promise { + const client = getSupabaseClient(); + if (!client) return; + + const { error } = await client.from(TABLE).upsert( + { + connection_id: config.connectionId, + service_account_json: config.serviceAccountJson, + impersonate_emails: config.impersonateEmails, + lead_minutes: config.leadMinutes, + updated_at: new Date().toISOString(), + } as never, + { onConflict: "connection_id" }, + ); + + if (error) { + console.error( + `[SA Config] Failed to save ${config.connectionId}:`, + error.message, + ); + } +} + +export async function loadAllSAConfigs(): Promise { + const client = getSupabaseClient(); + if (!client) return []; + + const { data, error } = await client.from(TABLE).select("*"); + + if (error) { + console.error("[SA Config] Failed to load:", error.message); + return []; + } + + return ((data || []) as SAConfigRow[]).map((row) => ({ + connectionId: row.connection_id, + serviceAccountJson: row.service_account_json, + impersonateEmails: row.impersonate_emails, + leadMinutes: row.lead_minutes, + })); +} diff --git a/google-calendar-sa/server/lib/scheduler.ts b/google-calendar-sa/server/lib/scheduler.ts index f944a7a8..aa68e6a1 100644 --- a/google-calendar-sa/server/lib/scheduler.ts +++ b/google-calendar-sa/server/lib/scheduler.ts @@ -21,6 +21,10 @@ import { getServiceAccountAccessToken } from "./service-account.ts"; const scopes = [GOOGLE_SCOPES.CALENDAR, GOOGLE_SCOPES.CALENDAR_EVENTS]; +const BUSINESS_HOUR_START = 9; +const BUSINESS_HOUR_END = 18; +const BUSINESS_TIMEZONE = "America/Sao_Paulo"; + // Dedup: track notified events so the same event isn't sent twice. // Key format: "connectionId:email:eventId:startTime" const notified = new Map(); @@ -52,7 +56,20 @@ export function stopScheduler(): void { } } +function isBusinessHours(): boolean { + const now = new Date(); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: BUSINESS_TIMEZONE, + hour: "numeric", + hour12: false, + }); + const hour = parseInt(formatter.format(now), 10); + return hour >= BUSINESS_HOUR_START && hour < BUSINESS_HOUR_END; +} + async function tick(): Promise { + if (!isBusinessHours()) return; + const connections = getCachedConnections(); if (connections.length === 0) return; @@ -111,8 +128,19 @@ async function scanConnection(conn: CachedConnection): Promise { if (seenEventIds.has(event.id)) continue; seenEventIds.add(event.id); - const startTime = event.start?.dateTime || event.start?.date; - if (!startTime) continue; + // Skip all-day events (no dateTime = all-day) + if (!event.start?.dateTime) continue; + + // Skip events without a meeting link + if (!event.hangoutLink) continue; + + // Skip events with fewer than 2 non-bot attendees + const realAttendees = (event.attendees ?? []).filter( + (a) => !a.self && !a.resource, + ); + if (realAttendees.length < 2) continue; + + const startTime = event.start.dateTime; const minutesUntilStart = Math.round( (new Date(startTime).getTime() - now.getTime()) / 60000, diff --git a/google-calendar-sa/server/main.ts b/google-calendar-sa/server/main.ts index 7f42d642..b64dbc4e 100644 --- a/google-calendar-sa/server/main.ts +++ b/google-calendar-sa/server/main.ts @@ -15,6 +15,7 @@ import { type Env, StateSchema } from "../shared/deco.gen.ts"; import { getServiceAccountAccessToken } from "./lib/service-account.ts"; import { cacheConnection } from "./lib/connection-cache.ts"; import { startScheduler, stopScheduler } from "./lib/scheduler.ts"; +import { saveSAConfig, loadAllSAConfigs } from "./lib/sa-config-store.ts"; export type { Env }; @@ -134,12 +135,16 @@ const onChangeHandler = async (_env: Env, config: any) => { return; } - cacheConnection({ + const saConfig = { connectionId, serviceAccountJson: json, impersonateEmails: emails, leadMinutes, - }); + }; + cacheConnection(saConfig); + saveSAConfig(saConfig).catch((err) => + console.error("[onChange] Failed to persist SA config:", err), + ); } catch (error) { console.error( "[onChange] Error caching connection:", @@ -227,19 +232,27 @@ const runtime = withRuntime({ ], }); -// Bootstrap trigger credentials from Supabase +// Bootstrap from Supabase: trigger credentials + SA connection configs. +// This lets the scheduler start scanning immediately after pod restart, +// without waiting for onChange to fire per-connection. if (isSupabaseConfigured()) { try { - const allCreds = await loadAllTriggerCredentials(); + const [allCreds, allConfigs] = await Promise.all([ + loadAllTriggerCredentials(), + loadAllSAConfigs(), + ]); + for (const config of allConfigs) { + cacheConnection(config); + } console.log( - `[BOOTSTRAP] Loaded trigger credentials for ${allCreds.length} connection(s)`, + `[BOOTSTRAP] Loaded ${allCreds.length} trigger credential(s), ${allConfigs.length} SA config(s)`, ); } catch (error) { - console.error("[BOOTSTRAP] Failed to load trigger credentials:", error); + console.error("[BOOTSTRAP] Failed to load from Supabase:", error); } } else { console.warn( - "[BOOTSTRAP] Supabase not configured — calendar triggers will not persist. " + + "[BOOTSTRAP] Supabase not configured — triggers and scheduler will not persist. " + "Set SUPABASE_URL / SUPABASE_ANON_KEY.", ); }