Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions google-calendar-sa/server/lib/sa-config-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<SAConfig[]> {
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,
}));
}
32 changes: 30 additions & 2 deletions google-calendar-sa/server/lib/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
Expand Down Expand Up @@ -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<void> {
if (!isBusinessHours()) return;

const connections = getCachedConnections();
if (connections.length === 0) return;

Expand Down Expand Up @@ -111,8 +128,19 @@ async function scanConnection(conn: CachedConnection): Promise<void> {
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,
Expand Down
27 changes: 20 additions & 7 deletions google-calendar-sa/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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:",
Expand Down Expand Up @@ -227,19 +232,27 @@ const runtime = withRuntime<Env, typeof StateSchema, Registry>({
],
});

// 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.",
);
}
Expand Down
Loading