Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
210 lines
7.6 KiB
JavaScript
210 lines
7.6 KiB
JavaScript
/**
|
|
* Google Calendar — Sync Operations
|
|
*
|
|
* syncFromGoogle, syncToGoogle, fullSync, syncAllAccounts
|
|
*/
|
|
|
|
import { dbGet, dbRun, dbAll, isPostgres, sqlNow, safeJsonParse } from '../../database/connection.js';
|
|
import { apiLogger } from '../../utils/logger.js';
|
|
import { fireRowCreateTriggers } from '../AutomationTriggerService.js';
|
|
import { generateBaseId, getUserTokens, loadTokens } from './helpers.js';
|
|
import { listCalendars, listEvents, createEvent, updateEvent, mapGoogleEventToCrm } from './operations.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync Operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function syncFromGoogle(userId, tableId) {
|
|
const userTokens = getUserTokens(userId);
|
|
if (!userTokens) {
|
|
throw new Error(`No Google Calendar tokens found for user ${userId}`);
|
|
}
|
|
|
|
const counts = { synced: 0, created: 0, updated: 0, errors: 0, calendarsProcessed: 0 };
|
|
|
|
// Time range: 30 days back to 90 days ahead
|
|
const timeMin = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
const timeMax = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
// Fetch ALL calendars the user has access to (including shared calendars)
|
|
let allCalendars = [];
|
|
const calendarMap = {};
|
|
try {
|
|
const calList = await listCalendars(userId);
|
|
for (const cal of calList) {
|
|
calendarMap[cal.id] = cal.summary || cal.id;
|
|
allCalendars.push(cal.id);
|
|
}
|
|
apiLogger.info(`Found ${allCalendars.length} calendars for user ${userId}: ${allCalendars.map(id => calendarMap[id] || id).join(', ')}`);
|
|
} catch (err) {
|
|
apiLogger.warn('Could not fetch calendar list, falling back to primary', err);
|
|
allCalendars = ['primary'];
|
|
}
|
|
|
|
// Use all discovered calendars (includes own + shared like girlfriend's)
|
|
const calendars = allCalendars.length > 0 ? allCalendars : (userTokens.calendars || ['primary']);
|
|
|
|
for (const calendarId of calendars) {
|
|
try {
|
|
const events = await listEvents(userId, calendarId, timeMin, timeMax);
|
|
const calendarSummary = calendarMap[calendarId] || calendarId;
|
|
|
|
for (const event of events) {
|
|
try {
|
|
const crmData = mapGoogleEventToCrm(event, calendarId, calendarSummary);
|
|
const eventId = event.id;
|
|
|
|
// Look for existing row with this event_id
|
|
const existingRow = isPostgres()
|
|
? await dbGet(
|
|
"SELECT id, data FROM table_rows WHERE table_id = ? AND data->>'event_id' = ?",
|
|
[tableId, eventId]
|
|
)
|
|
: await dbGet(
|
|
'SELECT id, data FROM table_rows WHERE table_id = ? AND data LIKE ?',
|
|
[tableId, `%"event_id":"${eventId}"%`]
|
|
);
|
|
|
|
if (existingRow) {
|
|
// Update existing row
|
|
const existingData = safeJsonParse(existingRow.data) || {};
|
|
const mergedData = { ...existingData, ...crmData };
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
|
|
[JSON.stringify(mergedData), existingRow.id]
|
|
);
|
|
counts.updated++;
|
|
} else {
|
|
// Create new row
|
|
const baseId = generateBaseId();
|
|
const insertResult = await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
|
|
[tableId, baseId, JSON.stringify(crmData), userId]
|
|
);
|
|
counts.created++;
|
|
|
|
// Fire CRM automation triggers (row_create) — so automations
|
|
// configured in CRM UI work without code changes or restarts
|
|
const newRowId = insertResult.lastID || insertResult.lastInsertRowid;
|
|
if (newRowId) {
|
|
fireRowCreateTriggers(tableId, newRowId, crmData).catch(err => {
|
|
apiLogger.warn({ err, tableId, rowId: newRowId }, 'Calendar sync: automation trigger failed (non-blocking)');
|
|
});
|
|
}
|
|
}
|
|
|
|
counts.synced++;
|
|
} catch (eventErr) {
|
|
apiLogger.error(`Error syncing event ${event.id} from Google`, eventErr);
|
|
counts.errors++;
|
|
}
|
|
}
|
|
counts.calendarsProcessed++;
|
|
} catch (calErr) {
|
|
apiLogger.error(`Error syncing calendar ${calendarId} from Google`, calErr);
|
|
counts.errors++;
|
|
}
|
|
}
|
|
|
|
apiLogger.info(`Google Calendar sync from Google complete for user ${userId} (${counts.calendarsProcessed} calendars)`, counts);
|
|
return counts;
|
|
}
|
|
|
|
export async function syncToGoogle(userId, tableId, rowId) {
|
|
const row = await dbGet(
|
|
'SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row ${rowId} not found in table ${tableId}`);
|
|
}
|
|
|
|
const data = safeJsonParse(row.data) || {};
|
|
const calendarId = data.calendar_id || 'primary';
|
|
|
|
if (data.event_id) {
|
|
// Update existing Google event
|
|
const updatedEvent = await updateEvent(userId, calendarId, data.event_id, data);
|
|
apiLogger.info(`Synced row ${rowId} -> updated Google event ${data.event_id}`);
|
|
return { action: 'updated', eventId: data.event_id, event: updatedEvent };
|
|
} else {
|
|
// Create new Google event
|
|
const createdEvent = await createEvent(userId, calendarId, data);
|
|
// Save the event_id back to the CRM row
|
|
data.event_id = createdEvent.id;
|
|
data.link = createdEvent.htmlLink || '';
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
|
|
[JSON.stringify(data), rowId]
|
|
);
|
|
apiLogger.info(`Synced row ${rowId} -> created Google event ${createdEvent.id}`);
|
|
return { action: 'created', eventId: createdEvent.id, event: createdEvent };
|
|
}
|
|
}
|
|
|
|
export async function fullSync(userId, tableId) {
|
|
apiLogger.info(`Starting full sync for user ${userId}, table ${tableId}`);
|
|
|
|
// Pull from Google first
|
|
const fromGoogle = await syncFromGoogle(userId, tableId);
|
|
|
|
// Then push all rows that have event_id back to Google
|
|
const rows = await dbAll(
|
|
'SELECT id, data FROM table_rows WHERE table_id = ?',
|
|
[tableId]
|
|
);
|
|
|
|
let pushedCount = 0;
|
|
let pushErrors = 0;
|
|
|
|
for (const row of rows) {
|
|
const data = safeJsonParse(row.data) || {};
|
|
// Only push rows that already have a Google event_id (to update them)
|
|
if (data.event_id) {
|
|
try {
|
|
await syncToGoogle(userId, tableId, row.id);
|
|
pushedCount++;
|
|
} catch (err) {
|
|
apiLogger.error(`Error pushing row ${row.id} to Google`, err);
|
|
pushErrors++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
fromGoogle,
|
|
toGoogle: { pushed: pushedCount, errors: pushErrors },
|
|
};
|
|
|
|
apiLogger.info(`Full sync complete for user ${userId}`, result);
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync All Accounts (for scheduled sync)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function syncAllAccounts(tableId) {
|
|
const eventsTableId = tableId || 2671; // Default: google_calendar_events table
|
|
const tokens = loadTokens();
|
|
const results = [];
|
|
|
|
for (const key of Object.keys(tokens)) {
|
|
if (!key.startsWith('user_')) continue;
|
|
const userId = key.replace('user_', '');
|
|
const userTokens = tokens[key];
|
|
|
|
if (!userTokens || !userTokens.refresh_token) continue;
|
|
|
|
try {
|
|
const syncResult = await syncFromGoogle(userId, eventsTableId);
|
|
results.push({ userId, status: 'success', ...syncResult });
|
|
} catch (err) {
|
|
apiLogger.error(`Scheduled sync failed for user ${userId}`, err);
|
|
results.push({ userId, status: 'error', error: err.message });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|