import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; function jsonResponse(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } function extractUserIdFromAuthHeader(authHeader: string | null) { if (!authHeader?.startsWith("Bearer ")) return null; try { const token = authHeader.replace("Bearer ", ""); const payload = token.split(".")[1]; if (!payload) return null; const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); const decoded = JSON.parse(atob(padded)); return typeof decoded?.sub === "string" ? decoded.sub : null; } catch (error) { console.error("sync-google-calendar token decode failed", error); return null; } } function mapGoogleCalendarError(error: any) { const reason = error?.details?.find?.((detail: any) => detail?.reason)?.reason ?? error?.errors?.[0]?.reason; const message = error?.message || "Google Calendar request failed."; if (reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT" || reason === "insufficientPermissions") { return { error: "Your Google connection needs to be refreshed for Calendar access. Disconnect Google and connect it again.", code: "RECONNECT_REQUIRED", reconnectRequired: true, }; } if (reason === "SERVICE_DISABLED" || /calendar api has not been used|calendar api.*disabled/i.test(message)) { return { error: "Google Calendar API is not enabled in the connected Google project.", code: "API_DISABLED", }; } return { error: message, code: "GOOGLE_API_ERROR", }; } async function refreshAccessToken(serviceClient: any, userId: string, refreshToken: string, clientId: string, clientSecret: string) { const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: "refresh_token", }), }); const data = await res.json(); if (data.error) throw new Error(data.error_description || data.error); const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString(); await serviceClient .from("google_drive_tokens") .update({ access_token: data.access_token, token_expires_at: expiresAt, }) .eq("user_id", userId); return data.access_token; } Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { const GOOGLE_CLIENT_ID = Deno.env.get("GOOGLE_CLIENT_ID"); const GOOGLE_CLIENT_SECRET = Deno.env.get("GOOGLE_CLIENT_SECRET"); const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) { return jsonResponse({ error: "Google Calendar integration is not configured on the server.", code: "MISSING_CREDENTIALS", }); } const authHeader = req.headers.get("Authorization"); const userId = extractUserIdFromAuthHeader(authHeader); if (!userId) { return jsonResponse({ error: "Unauthorized" }, 401); } const serviceClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY); const { data: roles } = await serviceClient .from("user_roles") .select("role") .eq("user_id", userId); const isAdmin = roles?.some((role: any) => role.role === "admin" || role.role === "manager"); if (!isAdmin) { return jsonResponse({ error: "Only admins can sync Google Calendar" }, 403); } const { data: tokenRow } = await serviceClient .from("google_drive_tokens") .select("*") .eq("user_id", userId) .maybeSingle(); if (!tokenRow) { return jsonResponse({ error: "Google account not connected. Connect Google first.", code: "NOT_CONNECTED", }); } let accessToken = tokenRow.access_token; const expiresAt = new Date(tokenRow.token_expires_at); if (expiresAt <= new Date()) { if (!tokenRow.refresh_token) { return jsonResponse({ error: "Your Google connection expired. Disconnect Google and connect it again.", code: "RECONNECT_REQUIRED", reconnectRequired: true, }); } try { accessToken = await refreshAccessToken( serviceClient, userId, tokenRow.refresh_token, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, ); } catch (error: any) { return jsonResponse({ error: "Your Google connection could not be refreshed. Disconnect Google and connect it again.", code: "RECONNECT_REQUIRED", reconnectRequired: true, details: error?.message, }); } } const body = await req.json(); const { action, calendarIds } = body; if (action === "list_calendars") { const calRes = await fetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", { headers: { Authorization: `Bearer ${accessToken}` }, }); const calData = await calRes.json(); if (calData.error) { return jsonResponse(mapGoogleCalendarError(calData.error)); } return jsonResponse({ calendars: calData.items || [] }); } if (action === "sync") { const { data: assocs } = await serviceClient .from("associations") .select("id") .eq("status", "active") .limit(1); if (!assocs?.length) { return jsonResponse({ error: "No active association found" }, 400); } const defaultAssocId = assocs[0].id; const calendarsToSync = calendarIds || ["primary"]; const timeMin = new Date(); timeMin.setMonth(timeMin.getMonth() - 1); const timeMax = new Date(); timeMax.setMonth(timeMax.getMonth() + 6); const allEvents: any[] = []; for (const calId of calendarsToSync) { const params = new URLSearchParams({ timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), singleEvents: "true", orderBy: "startTime", maxResults: "250", }); const evtRes = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calId)}/events?${params}`, { headers: { Authorization: `Bearer ${accessToken}` } }, ); const evtData = await evtRes.json(); if (evtData.error) { console.error(`Error fetching calendar ${calId}:`, evtData.error); return jsonResponse(mapGoogleCalendarError(evtData.error)); } const events = (evtData.items || []).filter((event: any) => event.status !== "cancelled"); for (const event of events) { const startDate = event.start?.dateTime || event.start?.date || null; const endDate = event.end?.dateTime || event.end?.date || null; if (!startDate) continue; allEvents.push({ title: event.summary || "Untitled", description: event.description || null, start_date: startDate, end_date: endDate || startDate, location: event.location || null, event_type: "event", all_day: !event.start?.dateTime, association_id: defaultAssocId, created_by: userId, visibility: ["admin"], is_blocked: false, }); } } if (allEvents.length > 0) { await serviceClient .from("calendar_events") .delete() .eq("created_by", userId) .eq("event_type", "google_sync"); const rows = allEvents.map((event) => ({ ...event, event_type: "google_sync" })); const { error: insertError } = await serviceClient.from("calendar_events").insert(rows); if (insertError) { console.error("Insert error:", insertError); return jsonResponse({ error: `Failed to save events: ${insertError.message}` }, 500); } } return jsonResponse({ count: allEvents.length }); } return jsonResponse({ error: "Unknown action" }, 400); } catch (err: any) { console.error("sync-google-calendar error:", err); return jsonResponse({ error: err.message }, 500); } });