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:
Till-JS 2026-01-28 12:41:32 +01:00
parent b50376dfdb
commit 2e71b5f1d9
10 changed files with 1858 additions and 3 deletions

View file

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

View file

@ -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,
],
})

View 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;
}
}

View 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;
}

View 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';
}
}

View 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;
}
}
}

View 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';

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

View 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 {}

View 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}`);
}
}
}
}
}