mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
8.7 KiB
TypeScript
270 lines
8.7 KiB
TypeScript
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);
|
|
}
|
|
});
|