feat(05-03): event aggregation + caching backend

- Implement aggregateEvents with Promise.allSettled across visible sources
- Dispatch to ICS/CalDAV/Exchange providers by source.type with credential decryption
- In-memory per-user event cache with 5-minute TTL (Pitfall 4)
- Background cache refresh when close to expiry
- Implement testConnection with lastSyncAt/lastSyncError updates
- Default window: now to now+30 days
- Events sorted by start ascending with source color included
This commit is contained in:
2026-06-24 15:14:44 +02:00
parent 389ac9b692
commit 0cd8efe157
+182 -5
View File
@@ -8,6 +8,9 @@ import { PrismaService } from '../prisma/prisma.service';
import { CalendarCryptoService } from './crypto.service'; import { CalendarCryptoService } from './crypto.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-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. * Common interface for normalized calendar events across all provider types.
@@ -79,6 +82,17 @@ const PRIVATE_IP_PATTERNS = [
/^\[fe80:/, /^\[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. * Service for calendar source CRUD and event aggregation.
* *
@@ -89,9 +103,15 @@ const PRIVATE_IP_PATTERNS = [
export class CalendarService { export class CalendarService {
private readonly logger = new Logger(CalendarService.name); private readonly logger = new Logger(CalendarService.name);
/** Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. */
private readonly eventCache = new Map<string, CacheEntry>();
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly crypto: CalendarCryptoService, 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. * 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 }> { async testConnection(id: string, userId: string): Promise<{ success: boolean; error?: string }> {
const source = await this.prisma.calendarSource.findUnique({ where: { id } }); const source = await this.prisma.calendarSource.findUnique({ where: { id } });
if (!source) throw new NotFoundException('Calendar source not found'); if (!source) throw new NotFoundException('Calendar source not found');
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source'); 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. * Aggregate events from all visible sources for a user (CAL-02/CAL-03).
* Stub — full implementation in Task 3. *
* - 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( async aggregateEvents(
userId: string, userId: string,
from?: string, from?: string,
to?: string, to?: string,
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
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<CalendarEvent[]> {
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}`);
}
} }
/** /**