mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(calendar): add Google/Apple Calendar sync module
Implement external calendar synchronization: - CalDAV sync for Apple Calendar and generic CalDAV servers - Google Calendar API integration with OAuth2 flow - iCal URL import for read-only calendar feeds - Bi-directional sync with configurable direction - Scheduled background sync (every 5 minutes) - Manual sync trigger via API New endpoints: - GET/POST /api/v1/sync/external - List/connect external calendars - GET/PUT/DELETE /api/v1/sync/external/:id - Manage external calendar - POST /api/v1/sync/external/:id/sync - Trigger manual sync - POST /api/v1/sync/caldav/discover - Discover CalDAV calendars - GET /api/v1/sync/google/auth-url - Get Google OAuth URL - GET /api/v1/sync/google/callback - Handle OAuth callback - GET /api/v1/calendars/:id/export.ics - Export calendar as iCal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b50376dfdb
commit
2e71b5f1d9
10 changed files with 1858 additions and 3 deletions
|
|
@ -38,21 +38,21 @@ export function createMockDb(): jest.Mocked<Database> {
|
|||
*/
|
||||
export function setupMockDbQuery<T>(mockDb: jest.Mocked<Database>, data: T[]): void {
|
||||
// For SELECT queries - the final method in the chain resolves to data
|
||||
mockDb.where.mockResolvedValueOnce(data);
|
||||
(mockDb as any).where.mockResolvedValueOnce(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup mock database for INSERT operations
|
||||
*/
|
||||
export function setupMockDbInsert<T>(mockDb: jest.Mocked<Database>, data: T[]): void {
|
||||
mockDb.returning.mockResolvedValueOnce(data);
|
||||
(mockDb as any).returning.mockResolvedValueOnce(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup mock database for UPDATE operations
|
||||
*/
|
||||
export function setupMockDbUpdate<T>(mockDb: jest.Mocked<Database>, data: T[]): void {
|
||||
mockDb.returning.mockResolvedValueOnce(data);
|
||||
(mockDb as any).returning.mockResolvedValueOnce(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { EventTagModule } from './event-tag/event-tag.module';
|
|||
import { EventTagGroupModule } from './event-tag-group/event-tag-group.module';
|
||||
import { ReminderModule } from './reminder/reminder.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
import { NetworkModule } from './network/network.module';
|
||||
import { MetricsModule } from './metrics';
|
||||
import { EmailModule } from './email/email.module';
|
||||
|
|
@ -32,6 +33,7 @@ import { NotificationModule } from './notification/notification.module';
|
|||
EventTagGroupModule,
|
||||
ReminderModule,
|
||||
ShareModule,
|
||||
SyncModule,
|
||||
NetworkModule,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
287
apps/calendar/apps/backend/src/sync/caldav.service.ts
Normal file
287
apps/calendar/apps/backend/src/sync/caldav.service.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DAVClient, DAVCalendar, DAVCalendarObject } from 'tsdav';
|
||||
import { ICalService, ParsedEvent } from './ical.service';
|
||||
|
||||
export interface CalDavCalendar {
|
||||
url: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
ctag?: string;
|
||||
}
|
||||
|
||||
export interface CalDavSyncResult {
|
||||
events: ParsedEvent[];
|
||||
ctag?: string;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalDavService {
|
||||
private readonly logger = new Logger(CalDavService.name);
|
||||
|
||||
constructor(private readonly icalService: ICalService) {}
|
||||
|
||||
/**
|
||||
* Create a DAVClient for CalDAV operations
|
||||
*/
|
||||
private async createClient(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<DAVClient> {
|
||||
const client = new DAVClient({
|
||||
serverUrl,
|
||||
credentials: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
authMethod: 'Basic',
|
||||
defaultAccountType: 'caldav',
|
||||
});
|
||||
|
||||
await client.login();
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available calendars on a CalDAV server
|
||||
*/
|
||||
async discoverCalendars(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<CalDavCalendar[]> {
|
||||
try {
|
||||
const client = await this.createClient(serverUrl, username, password);
|
||||
const calendars = await client.fetchCalendars();
|
||||
|
||||
return calendars.map((cal: DAVCalendar) => ({
|
||||
url: cal.url,
|
||||
displayName: String(cal.displayName || 'Unnamed Calendar'),
|
||||
description: cal.description as string | undefined,
|
||||
color: this.extractColor(cal),
|
||||
ctag: cal.ctag,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to discover calendars: ${error}`);
|
||||
throw new Error(
|
||||
`CalDAV discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all events from a CalDAV calendar
|
||||
*/
|
||||
async fetchEvents(
|
||||
serverUrl: string,
|
||||
calendarUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<CalDavSyncResult> {
|
||||
try {
|
||||
const client = await this.createClient(serverUrl, username, password);
|
||||
|
||||
// Get calendar metadata
|
||||
const calendars = await client.fetchCalendars();
|
||||
const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
// Fetch calendar objects
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar,
|
||||
timeRange:
|
||||
startDate && endDate
|
||||
? {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Parse all events
|
||||
const events: ParsedEvent[] = [];
|
||||
for (const obj of objects) {
|
||||
if (obj.data) {
|
||||
try {
|
||||
const parsed = this.icalService.parseICalData(obj.data);
|
||||
events.push(...parsed);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to parse calendar object: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
ctag: calendar.ctag,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch CalDAV events: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an event on a CalDAV server
|
||||
*/
|
||||
async upsertEvent(
|
||||
serverUrl: string,
|
||||
calendarUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
event: {
|
||||
uid: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAllDay?: boolean;
|
||||
recurrenceRule?: string;
|
||||
}
|
||||
): Promise<{ etag?: string }> {
|
||||
try {
|
||||
const client = await this.createClient(serverUrl, username, password);
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
// Generate iCal data for this single event
|
||||
const icalData = this.icalService.generateICalData('Event', [
|
||||
{
|
||||
id: event.uid,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay,
|
||||
recurrenceRule: event.recurrenceRule,
|
||||
},
|
||||
]);
|
||||
|
||||
// Create or update the event
|
||||
const eventUrl = `${calendarUrl}${event.uid}.ics`;
|
||||
|
||||
const result = await client.createCalendarObject({
|
||||
calendar,
|
||||
filename: `${event.uid}.ics`,
|
||||
iCalString: icalData,
|
||||
});
|
||||
|
||||
return { etag: (result as { etag?: string } | undefined)?.etag };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to upsert CalDAV event: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event from a CalDAV server
|
||||
*/
|
||||
async deleteEvent(
|
||||
serverUrl: string,
|
||||
calendarUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
uid: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const client = await this.createClient(serverUrl, username, password);
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
// Find the calendar object
|
||||
const objects = await client.fetchCalendarObjects({ calendar });
|
||||
const calendarObject = objects.find((obj: DAVCalendarObject) => {
|
||||
if (!obj.data) return false;
|
||||
try {
|
||||
const parsed = this.icalService.parseICalData(obj.data);
|
||||
return parsed.some((e) => e.uid === uid);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (calendarObject) {
|
||||
await client.deleteCalendarObject({
|
||||
calendarObject,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete CalDAV event: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if calendar has changes (using ctag)
|
||||
*/
|
||||
async hasChanges(
|
||||
serverUrl: string,
|
||||
calendarUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
lastCtag?: string
|
||||
): Promise<{ hasChanges: boolean; ctag?: string }> {
|
||||
try {
|
||||
const client = await this.createClient(serverUrl, username, password);
|
||||
const calendars = await client.fetchCalendars();
|
||||
const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
const currentCtag = calendar.ctag;
|
||||
const hasChanges = !lastCtag || lastCtag !== currentCtag;
|
||||
|
||||
return { hasChanges, ctag: currentCtag };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to check CalDAV changes: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Apple CalDAV server URL
|
||||
*/
|
||||
getAppleCalDavUrl(): string {
|
||||
return 'https://caldav.icloud.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google CalDAV server URL
|
||||
*/
|
||||
getGoogleCalDavUrl(): string {
|
||||
return 'https://apidata.googleusercontent.com/caldav/v2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract color from CalDAV calendar properties
|
||||
*/
|
||||
private extractColor(calendar: DAVCalendar): string | undefined {
|
||||
// CalDAV calendar-color is typically in the props
|
||||
const calWithProps = calendar as DAVCalendar & { props?: Record<string, unknown> };
|
||||
if (calWithProps.props && typeof calWithProps.props['calendar-color'] === 'string') {
|
||||
return calWithProps.props['calendar-color'];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
90
apps/calendar/apps/backend/src/sync/dto/index.ts
Normal file
90
apps/calendar/apps/backend/src/sync/dto/index.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsUrl, IsBoolean } from 'class-validator';
|
||||
|
||||
export class ConnectCalendarDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEnum(['google', 'apple', 'caldav', 'ical_url'])
|
||||
provider!: 'google' | 'apple' | 'caldav' | 'ical_url';
|
||||
|
||||
@IsUrl()
|
||||
calendarUrl!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accessToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refreshToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['import', 'export', 'both'])
|
||||
syncDirection?: 'import' | 'export' | 'both';
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(1440)
|
||||
syncInterval?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class UpdateExternalCalendarDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['import', 'export', 'both'])
|
||||
syncDirection?: 'import' | 'export' | 'both';
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(1440)
|
||||
syncInterval?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
syncEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export class DiscoverCalDavDto {
|
||||
@IsUrl()
|
||||
serverUrl!: string;
|
||||
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class GoogleOAuthCallbackDto {
|
||||
@IsString()
|
||||
code!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
}
|
||||
418
apps/calendar/apps/backend/src/sync/google-calendar.service.ts
Normal file
418
apps/calendar/apps/backend/src/sync/google-calendar.service.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ParsedEvent } from './ical.service';
|
||||
|
||||
interface GoogleCalendarEvent {
|
||||
id: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start: {
|
||||
dateTime?: string;
|
||||
date?: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
end: {
|
||||
dateTime?: string;
|
||||
date?: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
recurrence?: string[];
|
||||
status?: string;
|
||||
organizer?: {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
};
|
||||
attendees?: Array<{
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus?: string;
|
||||
}>;
|
||||
updated?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
interface GoogleCalendarList {
|
||||
id: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
backgroundColor?: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService {
|
||||
private readonly logger = new Logger(GoogleCalendarService.name);
|
||||
private readonly clientId: string;
|
||||
private readonly clientSecret: string;
|
||||
private readonly redirectUri: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.clientId = this.configService.get<string>('GOOGLE_CLIENT_ID') || '';
|
||||
this.clientSecret = this.configService.get<string>('GOOGLE_CLIENT_SECRET') || '';
|
||||
this.redirectUri = this.configService.get<string>('GOOGLE_REDIRECT_URI') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Calendar is configured
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return !!(this.clientId && this.clientSecret && this.redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth2 authorization URL
|
||||
*/
|
||||
getAuthUrl(state?: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/calendar.readonly',
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
].join(' '),
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
if (state) {
|
||||
params.set('state', state);
|
||||
}
|
||||
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
async exchangeCodeForTokens(code: string): Promise<TokenResponse> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: this.redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
this.logger.error(`Token exchange failed: ${error}`);
|
||||
throw new Error('Failed to exchange authorization code');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
this.logger.error(`Token refresh failed: ${error}`);
|
||||
throw new Error('Failed to refresh access token');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List available calendars
|
||||
*/
|
||||
async listCalendars(accessToken: string): Promise<GoogleCalendarList[]> {
|
||||
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: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from a calendar
|
||||
*/
|
||||
async fetchEvents(
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<ParsedEvent[]> {
|
||||
const params = new URLSearchParams({
|
||||
singleEvents: 'true',
|
||||
orderBy: 'startTime',
|
||||
maxResults: '2500',
|
||||
});
|
||||
|
||||
if (startDate) {
|
||||
params.set('timeMin', startDate.toISOString());
|
||||
}
|
||||
if (endDate) {
|
||||
params.set('timeMax', endDate.toISOString());
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch events: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const googleEvents: GoogleCalendarEvent[] = data.items || [];
|
||||
|
||||
return googleEvents.map((event) => this.convertGoogleEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event in Google Calendar
|
||||
*/
|
||||
async createEvent(
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAllDay?: boolean;
|
||||
recurrenceRule?: string;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const googleEvent: Partial<GoogleCalendarEvent> = {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
};
|
||||
|
||||
if (event.isAllDay) {
|
||||
googleEvent.start = { date: event.startTime.toISOString().split('T')[0] };
|
||||
googleEvent.end = { date: event.endTime.toISOString().split('T')[0] };
|
||||
} else {
|
||||
googleEvent.start = { dateTime: event.startTime.toISOString() };
|
||||
googleEvent.end = { dateTime: event.endTime.toISOString() };
|
||||
}
|
||||
|
||||
if (event.recurrenceRule) {
|
||||
googleEvent.recurrence = [`RRULE:${event.recurrenceRule}`];
|
||||
}
|
||||
|
||||
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(googleEvent),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to create event: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const created = await response.json();
|
||||
return { id: created.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an event in Google Calendar
|
||||
*/
|
||||
async updateEvent(
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
eventId: string,
|
||||
event: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
isAllDay?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
// First fetch the existing event
|
||||
const getResponse = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!getResponse.ok) {
|
||||
throw new Error(`Failed to fetch event for update: ${getResponse.status}`);
|
||||
}
|
||||
|
||||
const existingEvent = await getResponse.json();
|
||||
|
||||
// Merge updates
|
||||
const updatedEvent: Partial<GoogleCalendarEvent> = {
|
||||
...existingEvent,
|
||||
summary: event.title ?? existingEvent.summary,
|
||||
description: event.description ?? existingEvent.description,
|
||||
location: event.location ?? existingEvent.location,
|
||||
};
|
||||
|
||||
if (event.startTime && event.endTime) {
|
||||
if (event.isAllDay) {
|
||||
updatedEvent.start = { date: event.startTime.toISOString().split('T')[0] };
|
||||
updatedEvent.end = { date: event.endTime.toISOString().split('T')[0] };
|
||||
} else {
|
||||
updatedEvent.start = { dateTime: event.startTime.toISOString() };
|
||||
updatedEvent.end = { dateTime: event.endTime.toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
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(updatedEvent),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update event: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event from Google Calendar
|
||||
*/
|
||||
async deleteEvent(accessToken: string, calendarId: string, 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 event: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Google Calendar event to our ParsedEvent format
|
||||
*/
|
||||
private convertGoogleEvent(event: GoogleCalendarEvent): ParsedEvent {
|
||||
const isAllDay = !!event.start.date;
|
||||
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
if (isAllDay) {
|
||||
startDate = new Date(event.start.date!);
|
||||
endDate = new Date(event.end.date!);
|
||||
} else {
|
||||
startDate = new Date(event.start.dateTime!);
|
||||
endDate = new Date(event.end.dateTime!);
|
||||
}
|
||||
|
||||
// Extract RRULE from recurrence array
|
||||
let rrule: string | undefined;
|
||||
if (event.recurrence?.length) {
|
||||
const rruleLine = event.recurrence.find((r) => r.startsWith('RRULE:'));
|
||||
if (rruleLine) {
|
||||
rrule = rruleLine.replace('RRULE:', '');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uid: event.id,
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
dtstart: startDate,
|
||||
dtend: endDate,
|
||||
isAllDay,
|
||||
rrule,
|
||||
status: this.mapGoogleStatus(event.status),
|
||||
organizer: event.organizer?.email,
|
||||
attendees: event.attendees?.map((a) => ({
|
||||
email: a.email,
|
||||
name: a.displayName,
|
||||
status: this.mapGoogleResponseStatus(a.responseStatus),
|
||||
})),
|
||||
lastModified: event.updated ? new Date(event.updated) : undefined,
|
||||
created: event.created ? new Date(event.created) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google event status to our status
|
||||
*/
|
||||
private mapGoogleStatus(status?: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
confirmed: 'confirmed',
|
||||
tentative: 'tentative',
|
||||
cancelled: 'cancelled',
|
||||
};
|
||||
return mapping[status || 'confirmed'] || 'confirmed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google response status to our attendee status
|
||||
*/
|
||||
private mapGoogleResponseStatus(status?: string): string | undefined {
|
||||
if (!status) return undefined;
|
||||
const mapping: Record<string, string> = {
|
||||
accepted: 'accepted',
|
||||
declined: 'declined',
|
||||
tentative: 'tentative',
|
||||
needsAction: 'pending',
|
||||
};
|
||||
return mapping[status] || 'pending';
|
||||
}
|
||||
}
|
||||
263
apps/calendar/apps/backend/src/sync/ical.service.ts
Normal file
263
apps/calendar/apps/backend/src/sync/ical.service.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import ICAL from 'ical.js';
|
||||
|
||||
export interface ParsedEvent {
|
||||
uid: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
dtstart: Date;
|
||||
dtend: Date;
|
||||
isAllDay: boolean;
|
||||
rrule?: string;
|
||||
status?: string;
|
||||
organizer?: string;
|
||||
attendees?: Array<{ email: string; name?: string; status?: string }>;
|
||||
lastModified?: Date;
|
||||
created?: Date;
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
export interface CalendarExport {
|
||||
name: string;
|
||||
events: ParsedEvent[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ICalService {
|
||||
private readonly logger = new Logger(ICalService.name);
|
||||
|
||||
/**
|
||||
* Parse iCal/ICS data into structured events
|
||||
*/
|
||||
parseICalData(icalData: string): ParsedEvent[] {
|
||||
const events: ParsedEvent[] = [];
|
||||
|
||||
try {
|
||||
const jcalData = ICAL.parse(icalData);
|
||||
const vcalendar = new ICAL.Component(jcalData);
|
||||
const vevents = vcalendar.getAllSubcomponents('vevent');
|
||||
|
||||
for (const vevent of vevents) {
|
||||
try {
|
||||
const event = this.parseVEvent(vevent);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to parse event: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse iCal data: ${error}`);
|
||||
throw new Error('Invalid iCal data format');
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single VEVENT component
|
||||
*/
|
||||
private parseVEvent(vevent: ICAL.Component): ParsedEvent | null {
|
||||
const event = new ICAL.Event(vevent);
|
||||
|
||||
const uid = event.uid;
|
||||
const summary = event.summary;
|
||||
|
||||
if (!uid || !summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dtstart = event.startDate;
|
||||
const dtend = event.endDate;
|
||||
|
||||
if (!dtstart || !dtend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if all-day event (DATE vs DATE-TIME)
|
||||
const isAllDay = dtstart.isDate;
|
||||
|
||||
// Extract RRULE if present
|
||||
let rrule: string | undefined;
|
||||
const rruleProp = vevent.getFirstProperty('rrule');
|
||||
if (rruleProp) {
|
||||
rrule = rruleProp.toICALString().replace('RRULE:', '');
|
||||
}
|
||||
|
||||
// Extract attendees
|
||||
const attendees: Array<{ email: string; name?: string; status?: string }> = [];
|
||||
const attendeeProps = vevent.getAllProperties('attendee');
|
||||
for (const attendee of attendeeProps) {
|
||||
const email = attendee.getFirstValue()?.toString().replace('mailto:', '') || '';
|
||||
const cn = attendee.getParameter('cn');
|
||||
const partstat = attendee.getParameter('partstat');
|
||||
if (email) {
|
||||
attendees.push({
|
||||
email,
|
||||
name: cn?.toString(),
|
||||
status: this.mapPartstat(partstat?.toString()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract organizer
|
||||
let organizer: string | undefined;
|
||||
const organizerProp = vevent.getFirstProperty('organizer');
|
||||
if (organizerProp) {
|
||||
organizer = organizerProp.getFirstValue()?.toString().replace('mailto:', '');
|
||||
}
|
||||
|
||||
// Extract timestamps
|
||||
const lastModifiedProp = vevent.getFirstProperty('last-modified');
|
||||
const createdProp = vevent.getFirstProperty('created');
|
||||
const sequenceProp = vevent.getFirstProperty('sequence');
|
||||
|
||||
return {
|
||||
uid,
|
||||
summary,
|
||||
description: event.description || undefined,
|
||||
location: event.location || undefined,
|
||||
dtstart: dtstart.toJSDate(),
|
||||
dtend: dtend.toJSDate(),
|
||||
isAllDay,
|
||||
rrule,
|
||||
status: this.mapStatus(vevent.getFirstPropertyValue('status')?.toString()),
|
||||
organizer,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
lastModified: lastModifiedProp?.getFirstValue()
|
||||
? new Date(lastModifiedProp.getFirstValue()?.toString() || '')
|
||||
: undefined,
|
||||
created: createdProp?.getFirstValue()
|
||||
? new Date(createdProp.getFirstValue()?.toString() || '')
|
||||
: undefined,
|
||||
sequence: sequenceProp
|
||||
? parseInt(sequenceProp.getFirstValue()?.toString() || '0', 10)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map iCal PARTSTAT to our status
|
||||
*/
|
||||
private mapPartstat(partstat?: string): string | undefined {
|
||||
if (!partstat) return undefined;
|
||||
const mapping: Record<string, string> = {
|
||||
ACCEPTED: 'accepted',
|
||||
DECLINED: 'declined',
|
||||
TENTATIVE: 'tentative',
|
||||
'NEEDS-ACTION': 'pending',
|
||||
};
|
||||
return mapping[partstat.toUpperCase()] || 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map iCal STATUS to our status
|
||||
*/
|
||||
private mapStatus(status?: string): string | undefined {
|
||||
if (!status) return 'confirmed';
|
||||
const mapping: Record<string, string> = {
|
||||
CONFIRMED: 'confirmed',
|
||||
TENTATIVE: 'tentative',
|
||||
CANCELLED: 'cancelled',
|
||||
};
|
||||
return mapping[status.toUpperCase()] || 'confirmed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate iCal/ICS data from events
|
||||
*/
|
||||
generateICalData(
|
||||
calendarName: string,
|
||||
events: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAllDay?: boolean | null;
|
||||
recurrenceRule?: string | null;
|
||||
status?: string | null;
|
||||
}>
|
||||
): string {
|
||||
const vcalendar = new ICAL.Component(['vcalendar', [], []]);
|
||||
|
||||
// Set calendar properties
|
||||
vcalendar.addPropertyWithValue('version', '2.0');
|
||||
vcalendar.addPropertyWithValue('prodid', '-//ManaCore Calendar//EN');
|
||||
vcalendar.addPropertyWithValue('calscale', 'GREGORIAN');
|
||||
vcalendar.addPropertyWithValue('method', 'PUBLISH');
|
||||
vcalendar.addPropertyWithValue('x-wr-calname', calendarName);
|
||||
|
||||
for (const event of events) {
|
||||
const vevent = new ICAL.Component('vevent');
|
||||
|
||||
// Required properties
|
||||
vevent.addPropertyWithValue('uid', `${event.id}@manacore.app`);
|
||||
vevent.addPropertyWithValue('summary', event.title);
|
||||
|
||||
// Timestamps
|
||||
const dtstart = ICAL.Time.fromJSDate(event.startTime, false);
|
||||
const dtend = ICAL.Time.fromJSDate(event.endTime, false);
|
||||
|
||||
if (event.isAllDay) {
|
||||
dtstart.isDate = true;
|
||||
dtend.isDate = true;
|
||||
}
|
||||
|
||||
vevent.addPropertyWithValue('dtstart', dtstart);
|
||||
vevent.addPropertyWithValue('dtend', dtend);
|
||||
|
||||
// Optional properties
|
||||
if (event.description) {
|
||||
vevent.addPropertyWithValue('description', event.description);
|
||||
}
|
||||
if (event.location) {
|
||||
vevent.addPropertyWithValue('location', event.location);
|
||||
}
|
||||
if (event.recurrenceRule) {
|
||||
const rruleProp = new ICAL.Property('rrule');
|
||||
rruleProp.setValue(ICAL.Recur.fromString(event.recurrenceRule));
|
||||
vevent.addProperty(rruleProp);
|
||||
}
|
||||
|
||||
// Status
|
||||
const status = (event.status || 'confirmed').toUpperCase();
|
||||
vevent.addPropertyWithValue('status', status);
|
||||
|
||||
// Metadata
|
||||
vevent.addPropertyWithValue('dtstamp', ICAL.Time.now());
|
||||
vevent.addPropertyWithValue('created', ICAL.Time.fromJSDate(new Date(), false));
|
||||
|
||||
vcalendar.addSubcomponent(vevent);
|
||||
}
|
||||
|
||||
return vcalendar.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse iCal from URL
|
||||
*/
|
||||
async fetchAndParseICalUrl(url: string): Promise<ParsedEvent[]> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'text/calendar',
|
||||
'User-Agent': 'ManaCore Calendar/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch iCal: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const icalData = await response.text();
|
||||
return this.parseICalData(icalData);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch iCal from ${url}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/calendar/apps/backend/src/sync/index.ts
Normal file
7
apps/calendar/apps/backend/src/sync/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from './sync.module';
|
||||
export * from './sync.service';
|
||||
export * from './sync.controller';
|
||||
export * from './ical.service';
|
||||
export * from './caldav.service';
|
||||
export * from './google-calendar.service';
|
||||
export * from './dto';
|
||||
141
apps/calendar/apps/backend/src/sync/sync.controller.ts
Normal file
141
apps/calendar/apps/backend/src/sync/sync.controller.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SyncService } from './sync.service';
|
||||
import { ConnectCalendarDto, UpdateExternalCalendarDto, DiscoverCalDavDto } from './dto';
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
/**
|
||||
* List all external calendars for the current user
|
||||
*/
|
||||
@Get('sync/external')
|
||||
async listExternalCalendars(@CurrentUser() user: CurrentUserData) {
|
||||
const calendars = await this.syncService.findByUser(user.userId);
|
||||
return { calendars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a new external calendar
|
||||
*/
|
||||
@Post('sync/external')
|
||||
async connectCalendar(@CurrentUser() user: CurrentUserData, @Body() dto: ConnectCalendarDto) {
|
||||
const calendar = await this.syncService.connect(user.userId, dto);
|
||||
return { calendar };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific external calendar
|
||||
*/
|
||||
@Get('sync/external/:id')
|
||||
async getExternalCalendar(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const calendar = await this.syncService.findOne(id, user.userId);
|
||||
return { calendar };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update external calendar settings
|
||||
*/
|
||||
@Put('sync/external/:id')
|
||||
async updateExternalCalendar(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateExternalCalendarDto
|
||||
) {
|
||||
const calendar = await this.syncService.update(id, user.userId, dto);
|
||||
return { calendar };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect an external calendar
|
||||
*/
|
||||
@Delete('sync/external/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async disconnectCalendar(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.syncService.disconnect(id, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual sync for an external calendar
|
||||
*/
|
||||
@Post('sync/external/:id/sync')
|
||||
async triggerSync(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
// Verify ownership
|
||||
await this.syncService.findOne(id, user.userId);
|
||||
|
||||
const result = await this.syncService.syncCalendar(id);
|
||||
return {
|
||||
success: result.success,
|
||||
eventsImported: result.eventsImported,
|
||||
eventsExported: result.eventsExported,
|
||||
errors: result.errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover CalDAV calendars on a server
|
||||
*/
|
||||
@Post('sync/caldav/discover')
|
||||
async discoverCalDav(@Body() dto: DiscoverCalDavDto) {
|
||||
return this.syncService.discoverCalDav(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google OAuth authorization URL
|
||||
*/
|
||||
@Get('sync/google/auth-url')
|
||||
async getGoogleAuthUrl(@Query('state') state?: string) {
|
||||
const url = this.syncService.getGoogleAuthUrl(state);
|
||||
return { url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Google OAuth callback
|
||||
*/
|
||||
@Get('sync/google/callback')
|
||||
async handleGoogleCallback(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('code') code: string,
|
||||
@Query('state') state?: string
|
||||
) {
|
||||
const result = await this.syncService.handleGoogleCallback(code, user.userId);
|
||||
return {
|
||||
...result,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a local calendar as iCal file
|
||||
*/
|
||||
@Get('calendars/:calendarId/export.ics')
|
||||
async exportCalendar(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('calendarId') calendarId: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const icalData = await this.syncService.exportCalendarAsIcal(calendarId, user.userId);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="calendar-${calendarId}.ics"`,
|
||||
});
|
||||
res.send(icalData);
|
||||
}
|
||||
}
|
||||
15
apps/calendar/apps/backend/src/sync/sync.module.ts
Normal file
15
apps/calendar/apps/backend/src/sync/sync.module.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { ICalService } from './ical.service';
|
||||
import { CalDavService } from './caldav.service';
|
||||
import { GoogleCalendarService } from './google-calendar.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, ICalService, CalDavService, GoogleCalendarService],
|
||||
exports: [SyncService, ICalService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
632
apps/calendar/apps/backend/src/sync/sync.service.ts
Normal file
632
apps/calendar/apps/backend/src/sync/sync.service.ts
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { eq, and, lte, isNull, or } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { externalCalendars, ExternalCalendar, events, calendars } from '../db/schema';
|
||||
import { ICalService, ParsedEvent } from './ical.service';
|
||||
import { CalDavService } from './caldav.service';
|
||||
import { GoogleCalendarService } from './google-calendar.service';
|
||||
import { ConnectCalendarDto, UpdateExternalCalendarDto, DiscoverCalDavDto } from './dto';
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
eventsImported: number;
|
||||
eventsExported: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
private readonly icalService: ICalService,
|
||||
private readonly caldavService: CalDavService,
|
||||
private readonly googleCalendarService: GoogleCalendarService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Connect an external calendar
|
||||
*/
|
||||
async connect(userId: string, dto: ConnectCalendarDto): Promise<ExternalCalendar> {
|
||||
// Validate connection based on provider
|
||||
if (dto.provider === 'caldav' || dto.provider === 'apple') {
|
||||
if (!dto.username || !dto.password) {
|
||||
throw new BadRequestException('CalDAV requires username and password');
|
||||
}
|
||||
// Test connection
|
||||
await this.caldavService.discoverCalendars(
|
||||
dto.provider === 'apple' ? this.caldavService.getAppleCalDavUrl() : dto.calendarUrl,
|
||||
dto.username,
|
||||
dto.password
|
||||
);
|
||||
} else if (dto.provider === 'google') {
|
||||
if (!dto.accessToken) {
|
||||
throw new BadRequestException('Google Calendar requires OAuth tokens');
|
||||
}
|
||||
} else if (dto.provider === 'ical_url') {
|
||||
// Test that we can fetch the URL
|
||||
await this.icalService.fetchAndParseICalUrl(dto.calendarUrl);
|
||||
}
|
||||
|
||||
const [externalCalendar] = await this.db
|
||||
.insert(externalCalendars)
|
||||
.values({
|
||||
userId,
|
||||
name: dto.name,
|
||||
provider: dto.provider,
|
||||
calendarUrl: dto.calendarUrl,
|
||||
username: dto.username,
|
||||
encryptedPassword: dto.password, // TODO: Encrypt in production
|
||||
accessToken: dto.accessToken,
|
||||
refreshToken: dto.refreshToken,
|
||||
tokenExpiresAt: dto.accessToken ? new Date(Date.now() + 3600 * 1000) : null,
|
||||
syncDirection: dto.syncDirection || 'both',
|
||||
syncInterval: dto.syncInterval || 15,
|
||||
color: dto.color || '#6B7280',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Trigger initial sync
|
||||
this.syncCalendar(externalCalendar.id).catch((err) => {
|
||||
this.logger.error(`Initial sync failed for ${externalCalendar.id}: ${err}`);
|
||||
});
|
||||
|
||||
return externalCalendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect an external calendar
|
||||
*/
|
||||
async disconnect(id: string, userId: string): Promise<void> {
|
||||
const [externalCalendar] = await this.db
|
||||
.select()
|
||||
.from(externalCalendars)
|
||||
.where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId)));
|
||||
|
||||
if (!externalCalendar) {
|
||||
throw new NotFoundException('External calendar not found');
|
||||
}
|
||||
|
||||
// Delete synced events first
|
||||
await this.db.delete(events).where(eq(events.externalCalendarId, id));
|
||||
|
||||
// Delete the external calendar
|
||||
await this.db.delete(externalCalendars).where(eq(externalCalendars.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update external calendar settings
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
dto: UpdateExternalCalendarDto
|
||||
): Promise<ExternalCalendar> {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(externalCalendars)
|
||||
.where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId)));
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('External calendar not found');
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(externalCalendars)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(externalCalendars.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all external calendars for a user
|
||||
*/
|
||||
async findByUser(userId: string): Promise<ExternalCalendar[]> {
|
||||
return this.db.select().from(externalCalendars).where(eq(externalCalendars.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single external calendar
|
||||
*/
|
||||
async findOne(id: string, userId: string): Promise<ExternalCalendar> {
|
||||
const [externalCalendar] = await this.db
|
||||
.select()
|
||||
.from(externalCalendars)
|
||||
.where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId)));
|
||||
|
||||
if (!externalCalendar) {
|
||||
throw new NotFoundException('External calendar not found');
|
||||
}
|
||||
|
||||
return externalCalendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover CalDAV calendars
|
||||
*/
|
||||
async discoverCalDav(dto: DiscoverCalDavDto) {
|
||||
const discovered = await this.caldavService.discoverCalendars(
|
||||
dto.serverUrl,
|
||||
dto.username,
|
||||
dto.password
|
||||
);
|
||||
|
||||
return {
|
||||
calendars: discovered.map((cal) => ({
|
||||
url: cal.url,
|
||||
name: cal.displayName,
|
||||
color: cal.color,
|
||||
description: cal.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google OAuth URL
|
||||
*/
|
||||
getGoogleAuthUrl(state?: string): string {
|
||||
if (!this.googleCalendarService.isConfigured()) {
|
||||
throw new BadRequestException('Google Calendar is not configured');
|
||||
}
|
||||
return this.googleCalendarService.getAuthUrl(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Google OAuth callback
|
||||
*/
|
||||
async handleGoogleCallback(code: string, userId: string) {
|
||||
const tokens = await this.googleCalendarService.exchangeCodeForTokens(code);
|
||||
|
||||
// List available calendars
|
||||
const calendarList = await this.googleCalendarService.listCalendars(tokens.access_token);
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
calendars: calendarList.map((cal) => ({
|
||||
id: cal.id,
|
||||
name: cal.summary,
|
||||
description: cal.description,
|
||||
color: cal.backgroundColor,
|
||||
primary: cal.primary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a specific external calendar
|
||||
*/
|
||||
async syncCalendar(externalCalendarId: string): Promise<SyncResult> {
|
||||
const [externalCalendar] = await this.db
|
||||
.select()
|
||||
.from(externalCalendars)
|
||||
.where(eq(externalCalendars.id, externalCalendarId));
|
||||
|
||||
if (!externalCalendar) {
|
||||
throw new NotFoundException('External calendar not found');
|
||||
}
|
||||
|
||||
if (!externalCalendar.syncEnabled) {
|
||||
return { success: true, eventsImported: 0, eventsExported: 0, errors: ['Sync is disabled'] };
|
||||
}
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
eventsImported: 0,
|
||||
eventsExported: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Import events
|
||||
if (
|
||||
externalCalendar.syncDirection === 'import' ||
|
||||
externalCalendar.syncDirection === 'both'
|
||||
) {
|
||||
const imported = await this.importEvents(externalCalendar);
|
||||
result.eventsImported = imported;
|
||||
}
|
||||
|
||||
// Export events
|
||||
if (
|
||||
externalCalendar.syncDirection === 'export' ||
|
||||
externalCalendar.syncDirection === 'both'
|
||||
) {
|
||||
const exported = await this.exportEvents(externalCalendar);
|
||||
result.eventsExported = exported;
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
await this.db
|
||||
.update(externalCalendars)
|
||||
.set({
|
||||
lastSyncAt: new Date(),
|
||||
lastSyncError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(externalCalendars.id, externalCalendarId));
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
|
||||
|
||||
// Update error status
|
||||
await this.db
|
||||
.update(externalCalendars)
|
||||
.set({
|
||||
lastSyncError: error instanceof Error ? error.message : 'Unknown error',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(externalCalendars.id, externalCalendarId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import events from external calendar
|
||||
*/
|
||||
private async importEvents(externalCalendar: ExternalCalendar): Promise<number> {
|
||||
let parsedEvents: ParsedEvent[] = [];
|
||||
|
||||
// Calculate time range (sync last 30 days to next 365 days)
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
|
||||
switch (externalCalendar.provider) {
|
||||
case 'ical_url':
|
||||
parsedEvents = await this.icalService.fetchAndParseICalUrl(externalCalendar.calendarUrl);
|
||||
break;
|
||||
|
||||
case 'caldav':
|
||||
case 'apple': {
|
||||
const serverUrl =
|
||||
externalCalendar.provider === 'apple'
|
||||
? this.caldavService.getAppleCalDavUrl()
|
||||
: new URL(externalCalendar.calendarUrl).origin;
|
||||
|
||||
const result = await this.caldavService.fetchEvents(
|
||||
serverUrl,
|
||||
externalCalendar.calendarUrl,
|
||||
externalCalendar.username || '',
|
||||
externalCalendar.encryptedPassword || '', // TODO: Decrypt
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
parsedEvents = result.events;
|
||||
|
||||
// Store ctag for change detection
|
||||
if (result.ctag) {
|
||||
await this.db
|
||||
.update(externalCalendars)
|
||||
.set({
|
||||
providerData: {
|
||||
...externalCalendar.providerData,
|
||||
caldavCtag: result.ctag,
|
||||
},
|
||||
})
|
||||
.where(eq(externalCalendars.id, externalCalendar.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
// Refresh token if needed
|
||||
let accessToken = externalCalendar.accessToken;
|
||||
if (
|
||||
externalCalendar.tokenExpiresAt &&
|
||||
new Date(externalCalendar.tokenExpiresAt) < new Date()
|
||||
) {
|
||||
if (!externalCalendar.refreshToken) {
|
||||
throw new Error('Token expired and no refresh token available');
|
||||
}
|
||||
const tokens = await this.googleCalendarService.refreshAccessToken(
|
||||
externalCalendar.refreshToken
|
||||
);
|
||||
accessToken = tokens.access_token;
|
||||
|
||||
await this.db
|
||||
.update(externalCalendars)
|
||||
.set({
|
||||
accessToken: tokens.access_token,
|
||||
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
})
|
||||
.where(eq(externalCalendars.id, externalCalendar.id));
|
||||
}
|
||||
|
||||
const googleCalendarId = externalCalendar.providerData?.googleCalendarId || 'primary';
|
||||
parsedEvents = await this.googleCalendarService.fetchEvents(
|
||||
accessToken!,
|
||||
googleCalendarId,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create a local calendar for imported events
|
||||
let [localCalendar] = await this.db
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(
|
||||
and(
|
||||
eq(calendars.userId, externalCalendar.userId),
|
||||
eq(calendars.name, `${externalCalendar.name} (Sync)`)
|
||||
)
|
||||
);
|
||||
|
||||
if (!localCalendar) {
|
||||
[localCalendar] = await this.db
|
||||
.insert(calendars)
|
||||
.values({
|
||||
userId: externalCalendar.userId,
|
||||
name: `${externalCalendar.name} (Sync)`,
|
||||
color: externalCalendar.color || '#6B7280',
|
||||
isDefault: false,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Upsert events
|
||||
let importedCount = 0;
|
||||
for (const parsedEvent of parsedEvents) {
|
||||
try {
|
||||
// Check if event already exists
|
||||
const [existingEvent] = await this.db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.externalId, parsedEvent.uid),
|
||||
eq(events.externalCalendarId, externalCalendar.id)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingEvent) {
|
||||
// Update existing event
|
||||
await this.db
|
||||
.update(events)
|
||||
.set({
|
||||
title: parsedEvent.summary,
|
||||
description: parsedEvent.description,
|
||||
location: parsedEvent.location,
|
||||
startTime: parsedEvent.dtstart,
|
||||
endTime: parsedEvent.dtend,
|
||||
isAllDay: parsedEvent.isAllDay,
|
||||
recurrenceRule: parsedEvent.rrule,
|
||||
status: parsedEvent.status,
|
||||
lastSyncedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(events.id, existingEvent.id));
|
||||
} else {
|
||||
// Create new event
|
||||
await this.db.insert(events).values({
|
||||
calendarId: localCalendar.id,
|
||||
userId: externalCalendar.userId,
|
||||
title: parsedEvent.summary,
|
||||
description: parsedEvent.description,
|
||||
location: parsedEvent.location,
|
||||
startTime: parsedEvent.dtstart,
|
||||
endTime: parsedEvent.dtend,
|
||||
isAllDay: parsedEvent.isAllDay,
|
||||
recurrenceRule: parsedEvent.rrule,
|
||||
status: parsedEvent.status,
|
||||
externalId: parsedEvent.uid,
|
||||
externalCalendarId: externalCalendar.id,
|
||||
lastSyncedAt: new Date(),
|
||||
metadata: parsedEvent.attendees
|
||||
? {
|
||||
attendees: parsedEvent.attendees.map((a) => ({
|
||||
email: a.email,
|
||||
name: a.name,
|
||||
status:
|
||||
(a.status as 'accepted' | 'declined' | 'tentative' | 'pending') || undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
importedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to import event ${parsedEvent.uid}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return importedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export events to external calendar
|
||||
*/
|
||||
private async exportEvents(externalCalendar: ExternalCalendar): Promise<number> {
|
||||
// Only CalDAV and Google support export
|
||||
if (externalCalendar.provider === 'ical_url') {
|
||||
return 0; // iCal URLs are read-only
|
||||
}
|
||||
|
||||
// Get local events that haven't been synced yet
|
||||
const [localCalendar] = await this.db
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.userId, externalCalendar.userId), eq(calendars.isDefault, true)));
|
||||
|
||||
if (!localCalendar) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get events from default calendar that need to be exported
|
||||
const localEvents = await this.db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.calendarId, localCalendar.id),
|
||||
or(isNull(events.lastSyncedAt), lte(events.lastSyncedAt, events.updatedAt))
|
||||
)
|
||||
);
|
||||
|
||||
let exportedCount = 0;
|
||||
|
||||
for (const event of localEvents) {
|
||||
try {
|
||||
switch (externalCalendar.provider) {
|
||||
case 'caldav':
|
||||
case 'apple': {
|
||||
const serverUrl =
|
||||
externalCalendar.provider === 'apple'
|
||||
? this.caldavService.getAppleCalDavUrl()
|
||||
: new URL(externalCalendar.calendarUrl).origin;
|
||||
|
||||
await this.caldavService.upsertEvent(
|
||||
serverUrl,
|
||||
externalCalendar.calendarUrl,
|
||||
externalCalendar.username || '',
|
||||
externalCalendar.encryptedPassword || '',
|
||||
{
|
||||
uid: event.externalId || event.id,
|
||||
title: event.title,
|
||||
description: event.description ?? undefined,
|
||||
location: event.location ?? undefined,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay ?? false,
|
||||
recurrenceRule: event.recurrenceRule ?? undefined,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
let accessToken = externalCalendar.accessToken;
|
||||
if (
|
||||
externalCalendar.tokenExpiresAt &&
|
||||
new Date(externalCalendar.tokenExpiresAt) < new Date()
|
||||
) {
|
||||
if (!externalCalendar.refreshToken) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
const tokens = await this.googleCalendarService.refreshAccessToken(
|
||||
externalCalendar.refreshToken
|
||||
);
|
||||
accessToken = tokens.access_token;
|
||||
}
|
||||
|
||||
const googleCalendarId = externalCalendar.providerData?.googleCalendarId || 'primary';
|
||||
|
||||
if (event.externalId) {
|
||||
await this.googleCalendarService.updateEvent(
|
||||
accessToken!,
|
||||
googleCalendarId,
|
||||
event.externalId,
|
||||
{
|
||||
title: event.title,
|
||||
description: event.description ?? undefined,
|
||||
location: event.location ?? undefined,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay ?? false,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const result = await this.googleCalendarService.createEvent(
|
||||
accessToken!,
|
||||
googleCalendarId,
|
||||
{
|
||||
title: event.title,
|
||||
description: event.description ?? undefined,
|
||||
location: event.location ?? undefined,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay ?? false,
|
||||
recurrenceRule: event.recurrenceRule ?? undefined,
|
||||
}
|
||||
);
|
||||
|
||||
// Update the local event with the external ID
|
||||
await this.db
|
||||
.update(events)
|
||||
.set({ externalId: result.id })
|
||||
.where(eq(events.id, event.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as synced
|
||||
await this.db
|
||||
.update(events)
|
||||
.set({ lastSyncedAt: new Date() })
|
||||
.where(eq(events.id, event.id));
|
||||
|
||||
exportedCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to export event ${event.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return exportedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a local calendar to iCal format
|
||||
*/
|
||||
async exportCalendarAsIcal(calendarId: string, userId: string): Promise<string> {
|
||||
const [calendar] = await this.db
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.id, calendarId), eq(calendars.userId, userId)));
|
||||
|
||||
if (!calendar) {
|
||||
throw new NotFoundException('Calendar not found');
|
||||
}
|
||||
|
||||
const calendarEvents = await this.db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.calendarId, calendarId));
|
||||
|
||||
return this.icalService.generateICalData(calendar.name, calendarEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled sync job - runs every 5 minutes
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async scheduledSync() {
|
||||
this.logger.log('Running scheduled calendar sync...');
|
||||
|
||||
// Get calendars that need syncing
|
||||
const now = new Date();
|
||||
const calendarsToSync = await this.db
|
||||
.select()
|
||||
.from(externalCalendars)
|
||||
.where(eq(externalCalendars.syncEnabled, true));
|
||||
|
||||
for (const calendar of calendarsToSync) {
|
||||
// Check if enough time has passed since last sync
|
||||
const lastSync = calendar.lastSyncAt ? new Date(calendar.lastSyncAt) : null;
|
||||
const intervalMs = (calendar.syncInterval || 15) * 60 * 1000;
|
||||
|
||||
if (!lastSync || now.getTime() - lastSync.getTime() >= intervalMs) {
|
||||
try {
|
||||
await this.syncCalendar(calendar.id);
|
||||
this.logger.log(`Synced calendar: ${calendar.name} (${calendar.id})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sync calendar ${calendar.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue