feat(05-03): calendar backend — model, crypto, source CRUD module
- Add CalendarSource Prisma model with encrypted credentials (AES-256-GCM) - Create CalendarCryptoService with encrypt/decrypt using CALENDAR_ENCRYPTION_KEY - Create CalendarController with source CRUD endpoints (GET/POST/PATCH/DELETE) - Create CalendarService with ownership checks and SSRF URL validation - Add DTOs with https-only URL validation and class-validator decorators - Register CalendarModule in AppModule - Install tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-client - Stub provider files for Task 2 compilation
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
||||
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
||||
import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
|
||||
|
||||
/**
|
||||
* REST controller for calendar source management and event aggregation.
|
||||
*
|
||||
* All endpoints require JWT auth (global JwtAuthGuard).
|
||||
* Every handler extracts userId and tenantId from the request
|
||||
* and scopes all operations to the calling user (T-05-12).
|
||||
*
|
||||
* Routes:
|
||||
* - GET /calendar/sources — list user's calendar sources (no passwords)
|
||||
* - POST /calendar/sources — add a new calendar source
|
||||
* - PATCH /calendar/sources/:id — update a calendar source
|
||||
* - DELETE /calendar/sources/:id — remove a calendar source
|
||||
* - POST /calendar/sources/:id/test — test connection to a source
|
||||
* - GET /calendar/events — aggregated events from visible sources
|
||||
*/
|
||||
@Controller('calendar')
|
||||
export class CalendarController {
|
||||
constructor(private readonly calendarService: CalendarService) {}
|
||||
|
||||
private extractContext(req: Request) {
|
||||
const userId = (req as any).user?.id;
|
||||
const tenantId =
|
||||
(req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('No user context');
|
||||
}
|
||||
|
||||
return { userId, tenantId };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /calendar/sources
|
||||
* Returns all calendar sources for the user WITHOUT encryptedPassword.
|
||||
* T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean.
|
||||
*/
|
||||
@Get('sources')
|
||||
async getSources(@Req() req: Request) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.getSources(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /calendar/sources
|
||||
* Creates a new calendar source. Password is encrypted before storage.
|
||||
* T-05-11: URL validated as https-only + SSRF private IP check.
|
||||
*/
|
||||
@Post('sources')
|
||||
async addSource(
|
||||
@Req() req: Request,
|
||||
@Body() dto: CreateCalendarSourceDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.addSource(userId, tenantId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /calendar/sources/:id
|
||||
* Updates an existing calendar source. Ownership verified.
|
||||
* CAL-02: isVisible toggle is part of this DTO.
|
||||
*/
|
||||
@Patch('sources/:id')
|
||||
async updateSource(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Body() dto: UpdateCalendarSourceDto,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.updateSource(id, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /calendar/sources/:id
|
||||
* Removes a calendar source. Ownership verified (T-05-12).
|
||||
*/
|
||||
@Delete('sources/:id')
|
||||
async deleteSource(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.deleteSource(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /calendar/sources/:id/test
|
||||
* Tests connection to a calendar source. Implemented in Task 3.
|
||||
*/
|
||||
@Post('sources/:id/test')
|
||||
async testSource(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.testConnection(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /calendar/events
|
||||
* Returns aggregated events from all visible sources. Implemented in Task 3.
|
||||
*/
|
||||
@Get('events')
|
||||
async getEvents(
|
||||
@Req() req: Request,
|
||||
@Query() query: CalendarEventsQueryDto,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.aggregateEvents(userId, query.from, query.to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CalendarController } from './calendar.controller';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CalendarCryptoService } from './crypto.service';
|
||||
import { CalDAVProvider } from './providers/caldav.provider';
|
||||
import { ICSProvider } from './providers/ics.provider';
|
||||
import { ExchangeProvider } from './providers/exchange.provider';
|
||||
|
||||
/**
|
||||
* NestJS module for calendar source management and event aggregation.
|
||||
*
|
||||
* Provides:
|
||||
* - CalendarCryptoService: AES-256-GCM encryption for calendar credentials
|
||||
* - CalendarService: Source CRUD + event aggregation across providers
|
||||
* - CalDAVProvider: CalDAV protocol integration via tsdav
|
||||
* - ICSProvider: ICS file fetch + parse via node-ical
|
||||
* - ExchangeProvider: Exchange Online (Graph) + Exchange Server (EWS) integration
|
||||
* - CalendarController: REST API for sources and events
|
||||
*
|
||||
* Exports CalendarService for potential use by other modules.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [CalendarController],
|
||||
providers: [
|
||||
CalendarService,
|
||||
CalendarCryptoService,
|
||||
CalDAVProvider,
|
||||
ICSProvider,
|
||||
ExchangeProvider,
|
||||
],
|
||||
exports: [CalendarService],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Common interface for normalized calendar events across all provider types.
|
||||
*/
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
location?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface for calendar source integrations.
|
||||
* Each provider (ICS, CalDAV, Exchange) implements this contract.
|
||||
*/
|
||||
export interface CalendarProvider {
|
||||
fetchEvents(
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string; color?: string | null },
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<CalendarEvent[]>;
|
||||
|
||||
testConnection(
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma `select` for safe source responses — NEVER includes encryptedPassword.
|
||||
* T-05-09: Return hasCredentials boolean instead.
|
||||
*/
|
||||
const SOURCE_SAFE_SELECT = {
|
||||
id: true,
|
||||
userId: true,
|
||||
tenantId: true,
|
||||
name: true,
|
||||
type: true,
|
||||
exchangeMode: true,
|
||||
url: true,
|
||||
username: true,
|
||||
// encryptedPassword: NEVER included — T-05-09
|
||||
color: true,
|
||||
isVisible: true,
|
||||
syncIntervalMin: true,
|
||||
lastSyncAt: true,
|
||||
lastSyncError: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Private IP ranges for SSRF protection (T-05-11).
|
||||
*/
|
||||
const PRIVATE_IP_PATTERNS = [
|
||||
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
||||
/^192\.168\.\d{1,3}\.\d{1,3}$/,
|
||||
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
||||
/^169\.254\.\d{1,3}\.\d{1,3}$/,
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/,
|
||||
/^0\.0\.0\.0$/,
|
||||
/^\[::1\]$/,
|
||||
/^\[fc00:/,
|
||||
/^\[fd00:/,
|
||||
/^\[fe80:/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Service for calendar source CRUD and event aggregation.
|
||||
*
|
||||
* Source config is per-user (D-09), not per-tenant.
|
||||
* Credentials encrypted at rest via CalendarCryptoService (T-05-10).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
private readonly logger = new Logger(CalendarService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns all calendar sources for a user WITHOUT encryptedPassword.
|
||||
* Adds a `hasCredentials` boolean so the UI knows if credentials are set.
|
||||
*/
|
||||
async getSources(userId: string) {
|
||||
const sources = await this.prisma.calendarSource.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
...SOURCE_SAFE_SELECT,
|
||||
encryptedPassword: true, // Need it only to derive hasCredentials
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return sources.map(({ encryptedPassword, ...source }) => ({
|
||||
...source,
|
||||
hasCredentials: !!encryptedPassword,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new calendar source. Encrypts password before storage.
|
||||
* T-05-11: Validates URL against private IP ranges (SSRF).
|
||||
*/
|
||||
async addSource(userId: string, tenantId: string, dto: CreateCalendarSourceDto) {
|
||||
await this.validateUrlNotPrivate(dto.url);
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
userId,
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
url: dto.url,
|
||||
username: dto.username ?? null,
|
||||
exchangeMode: dto.exchangeMode ?? null,
|
||||
color: dto.color ?? '#3B82F6',
|
||||
};
|
||||
|
||||
if (dto.password) {
|
||||
data.encryptedPassword = this.crypto.encrypt(dto.password);
|
||||
}
|
||||
|
||||
const created = await this.prisma.calendarSource.create({
|
||||
data: data as any,
|
||||
select: SOURCE_SAFE_SELECT,
|
||||
});
|
||||
|
||||
return { ...created, hasCredentials: !!dto.password };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a calendar source. Ownership check ensures user can only modify their own sources.
|
||||
* Re-encrypts password if provided; T-05-12 ownership enforcement.
|
||||
*/
|
||||
async updateSource(id: string, userId: string, dto: UpdateCalendarSourceDto) {
|
||||
const existing = await this.prisma.calendarSource.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Calendar source not found');
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
throw new ForbiddenException('Not your calendar source');
|
||||
}
|
||||
|
||||
if (dto.url) {
|
||||
await this.validateUrlNotPrivate(dto.url);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
if (dto.url !== undefined) data.url = dto.url;
|
||||
if (dto.username !== undefined) data.username = dto.username;
|
||||
if (dto.exchangeMode !== undefined) data.exchangeMode = dto.exchangeMode;
|
||||
if (dto.color !== undefined) data.color = dto.color;
|
||||
if (dto.isVisible !== undefined) data.isVisible = dto.isVisible;
|
||||
|
||||
if (dto.password !== undefined) {
|
||||
data.encryptedPassword = dto.password
|
||||
? this.crypto.encrypt(dto.password)
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await this.prisma.calendarSource.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
select: {
|
||||
...SOURCE_SAFE_SELECT,
|
||||
encryptedPassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { encryptedPassword, ...safe } = updated;
|
||||
return { ...safe, hasCredentials: !!encryptedPassword };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a calendar source. Ownership check enforced (T-05-12).
|
||||
*/
|
||||
async deleteSource(id: string, userId: string) {
|
||||
const existing = await this.prisma.calendarSource.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Calendar source not found');
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
throw new ForbiddenException('Not your calendar source');
|
||||
}
|
||||
|
||||
await this.prisma.calendarSource.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to a calendar source via its provider.
|
||||
* Stub — full implementation in Task 3.
|
||||
*/
|
||||
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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate events from all visible sources for a user.
|
||||
* Stub — full implementation in Task 3.
|
||||
*/
|
||||
async aggregateEvents(
|
||||
userId: string,
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts stored credentials for a source (used internally by providers).
|
||||
* NEVER expose this in API responses.
|
||||
*/
|
||||
decryptSourcePassword(encryptedPassword: string): string {
|
||||
return this.crypto.decrypt(encryptedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF protection: reject URLs that resolve to private IP ranges.
|
||||
* T-05-11: Combined with https-only DTO validation.
|
||||
*/
|
||||
private async validateUrlNotPrivate(url: string): Promise<void> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// Check against private IP patterns
|
||||
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||
if (pattern.test(hostname)) {
|
||||
throw new ForbiddenException(
|
||||
'Calendar source URL must not point to private/internal networks',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also block localhost variants
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === 'ip6-localhost' ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal')
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Calendar source URL must not point to localhost or local networks',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException) throw error;
|
||||
throw new ForbiddenException('Invalid calendar source URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* Encryption service for calendar source credentials.
|
||||
*
|
||||
* Uses AES-256-GCM with a 32-byte hex key from CALENDAR_ENCRYPTION_KEY env var.
|
||||
* Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined).
|
||||
*
|
||||
* Security: T-05-10 — credentials encrypted at rest, never returned in GET responses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalendarCryptoService implements OnModuleInit {
|
||||
private key!: Buffer;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const hexKey = this.configService.get<string>('CALENDAR_ENCRYPTION_KEY');
|
||||
|
||||
if (!hexKey) {
|
||||
throw new Error(
|
||||
'CALENDAR_ENCRYPTION_KEY is not set. Generate one with: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
|
||||
if (hexKey.length !== 64) {
|
||||
throw new Error(
|
||||
`CALENDAR_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Got ${hexKey.length} characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.key = Buffer.from(hexKey, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts plaintext using AES-256-GCM.
|
||||
* @returns `iv:authTag:ciphertext` (all hex-encoded, colon-separated)
|
||||
*/
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12); // 96-bit IV for GCM
|
||||
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag().toString('hex');
|
||||
|
||||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a stored `iv:authTag:ciphertext` value.
|
||||
* @returns The original plaintext
|
||||
*/
|
||||
decrypt(stored: string): string {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted value format. Expected iv:authTag:ciphertext');
|
||||
}
|
||||
|
||||
const [ivHex, authTagHex, ciphertext] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsDateString, IsOptional } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for querying aggregated calendar events.
|
||||
*
|
||||
* Default window: now to now + 30 days (applied in CalendarService).
|
||||
*/
|
||||
export class CalendarEventsQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsHexColor,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for creating a new calendar source.
|
||||
*
|
||||
* Security:
|
||||
* - T-05-11: URL restricted to https only (SSRF mitigation)
|
||||
* - T-05-10: password is plaintext in transit (over TLS), encrypted at rest by CryptoService
|
||||
*/
|
||||
export class CreateCalendarSourceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsIn(['caldav', 'ics', 'exchange'])
|
||||
type!: string;
|
||||
|
||||
@IsUrl(
|
||||
{ protocols: ['https'], require_protocol: true },
|
||||
{ message: 'URL must use HTTPS protocol' },
|
||||
)
|
||||
url!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ews', 'graph'])
|
||||
exchangeMode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
color?: string;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsHexColor,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for updating an existing calendar source.
|
||||
* All fields are optional — only provided fields are updated.
|
||||
*
|
||||
* Security:
|
||||
* - T-05-11: URL restricted to https only if provided
|
||||
* - CAL-02: isVisible toggle for widget source visibility
|
||||
*/
|
||||
export class UpdateCalendarSourceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['caldav', 'ics', 'exchange'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl(
|
||||
{ protocols: ['https'], require_protocol: true },
|
||||
{ message: 'URL must use HTTPS protocol' },
|
||||
)
|
||||
url?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ews', 'graph'])
|
||||
exchangeMode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isVisible?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||
|
||||
/**
|
||||
* CalDAV calendar provider — uses tsdav for protocol handling.
|
||||
* Full implementation in Task 2.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalDAVProvider implements CalendarProvider {
|
||||
private readonly logger = new Logger(CalDAVProvider.name);
|
||||
|
||||
async fetchEvents(
|
||||
source: { url: string; username?: string; password?: string; id: string; color?: string | null },
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<CalendarEvent[]> {
|
||||
// Stub — implemented in Task 2
|
||||
return [];
|
||||
}
|
||||
|
||||
async testConnection(
|
||||
source: { url: string; username?: string; password?: string; id: string },
|
||||
): Promise<boolean> {
|
||||
// Stub — implemented in Task 2
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||
|
||||
/**
|
||||
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
|
||||
* Uses @microsoft/microsoft-graph-client for Graph and ews-javascript-api for EWS.
|
||||
* Full implementation in Task 2.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExchangeProvider implements CalendarProvider {
|
||||
private readonly logger = new Logger(ExchangeProvider.name);
|
||||
|
||||
async fetchEvents(
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string; color?: string | null },
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<CalendarEvent[]> {
|
||||
// Stub — implemented in Task 2
|
||||
return [];
|
||||
}
|
||||
|
||||
async testConnection(
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string },
|
||||
): Promise<boolean> {
|
||||
// Stub — implemented in Task 2
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||
|
||||
/**
|
||||
* ICS calendar provider — fetches and parses .ics files via HTTPS.
|
||||
* Uses node-ical for parsing. Full implementation in Task 2.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ICSProvider implements CalendarProvider {
|
||||
private readonly logger = new Logger(ICSProvider.name);
|
||||
|
||||
async fetchEvents(
|
||||
source: { url: string; id: string; color?: string | null },
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<CalendarEvent[]> {
|
||||
// Stub — implemented in Task 2
|
||||
return [];
|
||||
}
|
||||
|
||||
async testConnection(
|
||||
source: { url: string; id: string },
|
||||
): Promise<boolean> {
|
||||
// Stub — implemented in Task 2
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user