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:
@@ -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<string, CacheEntry>();
|
||||
|
||||
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<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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user