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 { 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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user