diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index 9472941..4ed0110 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -8,6 +8,9 @@ import { PrismaService } from '../prisma/prisma.service'; import { CalendarCryptoService } from './crypto.service'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto'; +import { ICSProvider } from './providers/ics.provider'; +import { CalDAVProvider } from './providers/caldav.provider'; +import { ExchangeProvider } from './providers/exchange.provider'; /** * Common interface for normalized calendar events across all provider types. @@ -79,6 +82,17 @@ const PRIVATE_IP_PATTERNS = [ /^\[fe80:/, ]; +/** + * In-memory event cache entry with TTL (Pitfall 4). + */ +interface CacheEntry { + events: CalendarEvent[]; + expiresAt: number; +} + +/** Cache TTL in milliseconds (5 minutes). */ +const CACHE_TTL_MS = 5 * 60 * 1000; + /** * Service for calendar source CRUD and event aggregation. * @@ -89,9 +103,15 @@ const PRIVATE_IP_PATTERNS = [ export class CalendarService { private readonly logger = new Logger(CalendarService.name); + /** Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. */ + private readonly eventCache = new Map(); + constructor( private readonly prisma: PrismaService, private readonly crypto: CalendarCryptoService, + private readonly icsProvider: ICSProvider, + private readonly caldavProvider: CalDAVProvider, + private readonly exchangeProvider: ExchangeProvider, ) {} /** @@ -215,25 +235,182 @@ export class CalendarService { /** * Test connection to a calendar source via its provider. - * Stub — full implementation in Task 3. + * Updates lastSyncAt/lastSyncError on the source record. */ async testConnection(id: string, userId: string): Promise<{ success: boolean; error?: string }> { const source = await this.prisma.calendarSource.findUnique({ where: { id } }); if (!source) throw new NotFoundException('Calendar source not found'); if (source.userId !== userId) throw new ForbiddenException('Not your calendar source'); - return { success: false, error: 'Not yet implemented' }; + + const provider = this.getProvider(source.type); + const decryptedSource = { + url: source.url, + username: source.username ?? undefined, + password: source.encryptedPassword + ? this.crypto.decrypt(source.encryptedPassword) + : undefined, + exchangeMode: source.exchangeMode, + id: source.id, + }; + + try { + const success = await provider.testConnection(decryptedSource); + + await this.prisma.calendarSource.update({ + where: { id }, + data: { + lastSyncAt: success ? new Date() : undefined, + lastSyncError: success ? null : 'Connection test failed', + }, + }); + + return { success }; + } catch (error) { + const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials + await this.prisma.calendarSource.update({ + where: { id }, + data: { lastSyncError: errorMsg }, + }); + return { success: false, error: errorMsg }; + } } /** - * Aggregate events from all visible sources for a user. - * Stub — full implementation in Task 3. + * Aggregate events from all visible sources for a user (CAL-02/CAL-03). + * + * - Loads only isVisible: true sources + * - Decrypts credentials per source + * - Fetches in parallel with Promise.allSettled (one failing source doesn't break others) + * - Merges + sorts by start ascending + * - Caches per user with 5-minute TTL (Pitfall 4) */ async aggregateEvents( userId: string, from?: string, to?: string, ): Promise { - return []; + const now = new Date(); + const fromDate = from ? new Date(from) : now; + const toDate = to ? new Date(to) : new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default: +30 days + + // Check cache first + const cacheKey = `${userId}:${fromDate.toISOString()}:${toDate.toISOString()}`; + const cached = this.eventCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + // Serve cached immediately, trigger background refresh if close to expiry + if (cached.expiresAt - Date.now() < CACHE_TTL_MS / 2) { + this.refreshCacheInBackground(userId, fromDate, toDate, cacheKey); + } + return cached.events; + } + + // Fetch fresh + const events = await this.fetchAndCacheEvents(userId, fromDate, toDate, cacheKey); + return events; + } + + /** + * Fetches events from all visible sources, caches them, and returns. + */ + private async fetchAndCacheEvents( + userId: string, + from: Date, + to: Date, + cacheKey: string, + ): Promise { + const sources = await this.prisma.calendarSource.findMany({ + where: { userId, isVisible: true }, + }); + + if (sources.length === 0) return []; + + // Fetch from all sources in parallel — Promise.allSettled so one failure doesn't break others + const results = await Promise.allSettled( + sources.map(async (source) => { + const provider = this.getProvider(source.type); + const decryptedSource = { + url: source.url, + username: source.username ?? undefined, + password: source.encryptedPassword + ? this.crypto.decrypt(source.encryptedPassword) + : undefined, + exchangeMode: source.exchangeMode, + id: source.id, + color: source.color, + }; + + try { + const events = await provider.fetchEvents(decryptedSource, from, to); + + // Update sync status on success + await this.prisma.calendarSource.update({ + where: { id: source.id }, + data: { lastSyncAt: new Date(), lastSyncError: null }, + }); + + return events; + } catch (error) { + // Update sync error — generic message (T-05-13) + this.logger.warn( + `Failed to fetch events from source ${source.id} (${source.type}): ${(error as Error).message}`, + ); + await this.prisma.calendarSource.update({ + where: { id: source.id }, + data: { lastSyncError: 'Event fetch failed' }, + }); + return [] as CalendarEvent[]; + } + }), + ); + + // Merge all successful results + const allEvents: CalendarEvent[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + allEvents.push(...result.value); + } + } + + // Sort by start ascending + allEvents.sort((a, b) => a.start.getTime() - b.start.getTime()); + + // Cache result + this.eventCache.set(cacheKey, { + events: allEvents, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + + return allEvents; + } + + /** + * Refreshes cache in the background without blocking the response. + */ + private refreshCacheInBackground( + userId: string, + from: Date, + to: Date, + cacheKey: string, + ): void { + this.fetchAndCacheEvents(userId, from, to, cacheKey).catch((error) => { + this.logger.warn(`Background cache refresh failed: ${(error as Error).message}`); + }); + } + + /** + * Returns the provider instance for a given source type. + */ + private getProvider(type: string): CalendarProvider { + switch (type) { + case 'ics': + return this.icsProvider; + case 'caldav': + return this.caldavProvider; + case 'exchange': + return this.exchangeProvider; + default: + throw new Error(`Unknown calendar source type: ${type}`); + } } /**