New features user management Google Calendar integration
This commit is contained in:
@@ -1,264 +1,100 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database } from '$lib/supabase/types';
|
||||
// Google Calendar functions for PUBLIC calendars (no OAuth needed)
|
||||
// Just needs a Google API key and a public calendar ID
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
const GOOGLE_CLIENT_SECRET = import.meta.env.VITE_GOOGLE_CLIENT_SECRET;
|
||||
|
||||
interface GoogleTokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface GoogleCalendarEvent {
|
||||
export interface GoogleCalendarEvent {
|
||||
id: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
start: { dateTime?: string; date?: string };
|
||||
end: { dateTime?: string; date?: string };
|
||||
colorId?: string;
|
||||
htmlLink?: string;
|
||||
}
|
||||
|
||||
export function getGoogleAuthUrl(redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state
|
||||
});
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
}
|
||||
/**
|
||||
* Extract calendar ID from various Google Calendar URL formats
|
||||
* Supports:
|
||||
* - Direct calendar ID (email format)
|
||||
* - Shareable URL with cid parameter (base64 encoded)
|
||||
* - Public URL with calendar ID in path
|
||||
*/
|
||||
export function extractCalendarId(input: string): string | null {
|
||||
if (!input) return null;
|
||||
|
||||
export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise<GoogleTokens> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to exchange code for tokens');
|
||||
// If it looks like an email/calendar ID already, return it
|
||||
if (input.includes('@') && !input.includes('http')) {
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
try {
|
||||
const url = new URL(input);
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
});
|
||||
// Check for cid parameter (base64 encoded calendar ID)
|
||||
const cid = url.searchParams.get('cid');
|
||||
if (cid) {
|
||||
// Decode base64 to get calendar ID
|
||||
try {
|
||||
return atob(cid);
|
||||
} catch {
|
||||
return cid; // Maybe it's not encoded
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to refresh access token');
|
||||
}
|
||||
// Check for src parameter
|
||||
const src = url.searchParams.get('src');
|
||||
if (src) return src;
|
||||
|
||||
return response.json();
|
||||
}
|
||||
// Check path for calendar ID in ical URL format
|
||||
const icalMatch = input.match(/calendar\/ical\/([^/]+)/);
|
||||
if (icalMatch) return decodeURIComponent(icalMatch[1]);
|
||||
|
||||
export async function getValidAccessToken(
|
||||
supabase: SupabaseClient<Database>,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const { data: connection } = await supabase
|
||||
.from('google_calendar_connections')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (!connection) return null;
|
||||
|
||||
const expiresAt = new Date(connection.token_expires_at);
|
||||
const now = new Date();
|
||||
|
||||
// Refresh if expires within 5 minutes
|
||||
if (expiresAt.getTime() - now.getTime() < 5 * 60 * 1000) {
|
||||
try {
|
||||
const tokens = await refreshAccessToken(connection.refresh_token);
|
||||
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
await supabase
|
||||
.from('google_calendar_connections')
|
||||
.update({
|
||||
access_token: tokens.access_token,
|
||||
token_expires_at: newExpiresAt.toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', userId);
|
||||
|
||||
return tokens.access_token;
|
||||
} catch {
|
||||
return null;
|
||||
} catch {
|
||||
// Not a valid URL, maybe it's just a calendar ID
|
||||
if (input.includes('@')) {
|
||||
return input.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return connection.access_token;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchGoogleCalendarEvents(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
/**
|
||||
* Generate a subscribe URL for a public Google Calendar
|
||||
*/
|
||||
export function getCalendarSubscribeUrl(calendarId: string): string {
|
||||
const encoded = btoa(calendarId);
|
||||
return `https://calendar.google.com/calendar/u/0?cid=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from a PUBLIC Google Calendar
|
||||
* Requires: Calendar must be set to "Make available to public" in Google Calendar settings
|
||||
*/
|
||||
export async function fetchPublicCalendarEvents(
|
||||
calendarId: string,
|
||||
apiKey: string,
|
||||
timeMin: Date,
|
||||
timeMax: Date
|
||||
): Promise<GoogleCalendarEvent[]> {
|
||||
const params = new URLSearchParams({
|
||||
key: apiKey,
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
singleEvents: 'true',
|
||||
orderBy: 'startTime'
|
||||
orderBy: 'startTime',
|
||||
maxResults: '250'
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch Google Calendar events');
|
||||
const error = await response.text();
|
||||
console.error('Google Calendar API error:', error);
|
||||
throw new Error('Failed to fetch calendar events. Make sure the calendar is set to public.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function createGoogleCalendarEvent(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
): Promise<GoogleCalendarEvent> {
|
||||
const body: Record<string, unknown> = {
|
||||
summary: event.title,
|
||||
description: event.description
|
||||
};
|
||||
|
||||
if (event.allDay) {
|
||||
const startDate = event.startTime.split('T')[0];
|
||||
const endDate = event.endTime.split('T')[0];
|
||||
body.start = { date: startDate };
|
||||
body.end = { date: endDate };
|
||||
} else {
|
||||
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create Google Calendar event');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateGoogleCalendarEvent(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
eventId: string,
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
): Promise<GoogleCalendarEvent> {
|
||||
const body: Record<string, unknown> = {
|
||||
summary: event.title,
|
||||
description: event.description
|
||||
};
|
||||
|
||||
if (event.allDay) {
|
||||
const startDate = event.startTime.split('T')[0];
|
||||
const endDate = event.endTime.split('T')[0];
|
||||
body.start = { date: startDate };
|
||||
body.end = { date: endDate };
|
||||
} else {
|
||||
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update Google Calendar event');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteGoogleCalendarEvent(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
eventId: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error('Failed to delete Google Calendar event');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGoogleCalendars(accessToken: string): Promise<{ id: string; summary: string }[]> {
|
||||
const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to list calendars');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return (data.items ?? []).map((cal: { id: string; summary: string }) => ({
|
||||
id: cal.id,
|
||||
summary: cal.summary
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user