New features user management Google Calendar integration

This commit is contained in:
AlacrisDevs
2026-02-04 23:53:34 +02:00
parent cfec43f7ef
commit 6ec6b0753f
14 changed files with 1847 additions and 328 deletions

View File

@@ -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
}));
}