diff --git a/docker-compose.crm.yml b/docker-compose.crm.yml index 74e6eef..8c171f5 100644 --- a/docker-compose.crm.yml +++ b/docker-compose.crm.yml @@ -24,6 +24,8 @@ services: - JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem - JWT_ISSUER=${JWT_ISSUER:-insight-platform} - CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59} + # Core-Service URL fuer interne Kommunikation (M365 Token) + - CORE_SERVICE_URL=http://core:3000 # Lexware Office Integration (optional) - LEXWARE_API_KEY=${LEXWARE_API_KEY:-} - LEXWARE_API_URL=${LEXWARE_API_URL:-https://api.lexware.io} diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index a2039bc..121f76e 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -25,6 +25,7 @@ import { CustomFieldsModule } from './custom-fields/custom-fields.module'; import { ImportModule } from './import/import.module'; import { EnrichmentModule } from './enrichment/enrichment.module'; import { ContractsModule } from './contracts/contracts.module'; +import { GraphModule } from './graph/graph.module'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { ContractsModule } from './contracts/contracts.module'; ImportModule, EnrichmentModule, ContractsModule, + GraphModule, ], providers: [ { diff --git a/packages/crm-service/src/config/env.validation.ts b/packages/crm-service/src/config/env.validation.ts index 5a93037..28b2fc9 100644 --- a/packages/crm-service/src/config/env.validation.ts +++ b/packages/crm-service/src/config/env.validation.ts @@ -57,6 +57,11 @@ export class EnvironmentVariables { @IsString() @IsOptional() NORTH_DATA_API_URL?: string; + + // Core-Service URL fuer interne Kommunikation (Microsoft 365 Token) + @IsString() + @IsOptional() + CORE_SERVICE_URL?: string; } export function validate( diff --git a/packages/crm-service/src/graph/graph.controller.ts b/packages/crm-service/src/graph/graph.controller.ts new file mode 100644 index 0000000..d96c200 --- /dev/null +++ b/packages/crm-service/src/graph/graph.controller.ts @@ -0,0 +1,118 @@ +import { + Controller, + Get, + Param, + Req, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { GraphService } from './graph.service'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; + +interface JwtUser { + sub: string; + email: string; + tenantId: string; +} + +/** + * GraphController — Microsoft 365 Graph-API Proxy + * + * Routen: + * GET /crm/contacts/:id/emails — E-Mails zu einem CRM-Kontakt + * GET /crm/contacts/:id/calendar — Kalender-Termine mit einem Kontakt + * GET /crm/contacts/:id/tasks — Microsoft To Do Aufgaben + */ +@Controller('contacts') +export class GraphController { + private readonly logger = new Logger(GraphController.name); + + constructor( + private readonly graphService: GraphService, + private readonly prisma: CrmPrismaService, + ) {} + + /** + * GET /api/v1/crm/contacts/:id/emails + * E-Mails die den Kontakt betreffen aus MS Graph laden. + */ + @Get(':id/emails') + async getContactEmails( + @Param('id') contactId: string, + @Req() req: Request & { user: JwtUser }, + ) { + const contact = await this.prisma.contact.findFirst({ + where: { id: contactId, tenantId: req.user.tenantId }, + select: { id: true, email: true }, + }); + + if (!contact) { + throw new NotFoundException('Kontakt nicht gefunden'); + } + + if (!contact.email) { + return { success: true, data: [], meta: { reason: 'Kein E-Mail beim Kontakt hinterlegt' } }; + } + + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const emails = await this.graphService.getContactEmails( + jwt, + req.user.sub, + contact.email, + ); + + return { success: true, data: emails, meta: { count: emails.length } }; + } + + /** + * GET /api/v1/crm/contacts/:id/calendar + * Kalender-Termine mit dem Kontakt aus MS Graph laden. + */ + @Get(':id/calendar') + async getContactCalendar( + @Param('id') contactId: string, + @Req() req: Request & { user: JwtUser }, + ) { + const contact = await this.prisma.contact.findFirst({ + where: { id: contactId, tenantId: req.user.tenantId }, + select: { id: true, email: true }, + }); + + if (!contact) { + throw new NotFoundException('Kontakt nicht gefunden'); + } + + if (!contact.email) { + return { success: true, data: [], meta: { reason: 'Kein E-Mail beim Kontakt hinterlegt' } }; + } + + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const events = await this.graphService.getContactCalendar( + jwt, + req.user.sub, + contact.email, + ); + + return { success: true, data: events, meta: { count: events.length } }; + } + + /** + * GET /api/v1/crm/contacts/:id/tasks + * Microsoft To Do Aufgaben (nicht kontakt-spezifisch, alle Listen). + */ + @Get(':id/tasks') + async getContactTasks( + @Param('id') _contactId: string, + @Req() req: Request & { user: JwtUser }, + ) { + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const taskLists = await this.graphService.getTasks(jwt, req.user.sub); + + return { + success: true, + data: taskLists, + meta: { listCount: taskLists.length }, + }; + } +} diff --git a/packages/crm-service/src/graph/graph.module.ts b/packages/crm-service/src/graph/graph.module.ts new file mode 100644 index 0000000..a5767a1 --- /dev/null +++ b/packages/crm-service/src/graph/graph.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GraphController } from './graph.controller'; +import { GraphService } from './graph.service'; +import { CrmPrismaModule } from '../prisma/crm-prisma.module'; +import { RedisModule } from '../redis/redis.module'; + +@Module({ + imports: [CrmPrismaModule, RedisModule], + controllers: [GraphController], + providers: [GraphService], +}) +export class GraphModule {} diff --git a/packages/crm-service/src/graph/graph.service.ts b/packages/crm-service/src/graph/graph.service.ts new file mode 100644 index 0000000..006b639 --- /dev/null +++ b/packages/crm-service/src/graph/graph.service.ts @@ -0,0 +1,273 @@ +import { + Injectable, + Logger, + UnauthorizedException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../redis/redis.service'; + +export interface M365Email { + id: string; + subject: string; + bodyPreview: string; + receivedDateTime: string; + from: { emailAddress: { name: string; address: string } }; + hasAttachments: boolean; + isRead: boolean; + webLink: string; +} + +export interface M365CalendarEvent { + id: string; + subject: string; + start: { dateTime: string; timeZone: string }; + end: { dateTime: string; timeZone: string }; + location: { displayName: string }; + organizer: { emailAddress: { name: string; address: string } }; + isOnlineMeeting: boolean; + onlineMeetingUrl: string | null; + webLink: string; +} + +export interface M365Task { + id: string; + title: string; + status: string; + importance: string; + dueDateTime: { dateTime: string; timeZone: string } | null; + completedDateTime: { dateTime: string; timeZone: string } | null; + createdDateTime: string; +} + +export interface M365TaskList { + id: string; + displayName: string; + tasks: M365Task[]; +} + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; +const CACHE_TTL = 300; // 5 Minuten + +@Injectable() +export class GraphService { + private readonly logger = new Logger(GraphService.name); + private readonly coreServiceUrl: string; + + constructor( + private readonly config: ConfigService, + private readonly redis: RedisService, + ) { + this.coreServiceUrl = + this.config.get('CORE_SERVICE_URL') ?? 'http://core:3000'; + } + + // ── Token vom Core-Service holen ────────────────────────────────────── + + /** + * M365-Access-Token vom Core-Service abrufen. + * Leitet den User-JWT an Core weiter — Core gibt den M365-Token zurueck. + */ + async getM365Token(userJwt: string): Promise { + const url = `${this.coreServiceUrl}/api/v1/users/me/integrations/microsoft-365/token`; + + let resp: Response; + try { + resp = await fetch(url, { + headers: { Authorization: `Bearer ${userJwt}` }, + signal: AbortSignal.timeout(5000), + }); + } catch (err) { + throw new ServiceUnavailableException( + `Core-Service nicht erreichbar: ${(err as Error).message}`, + ); + } + + if (resp.status === 404) { + throw new UnauthorizedException( + 'Keine Microsoft 365 Verbindung vorhanden — bitte zuerst verbinden', + ); + } + + if (!resp.ok) { + throw new ServiceUnavailableException( + `Core-Service Fehler: ${resp.status} ${resp.statusText}`, + ); + } + + const body = (await resp.json()) as { + success: boolean; + data: { accessToken: string }; + }; + + if (!body.success || !body.data?.accessToken) { + throw new ServiceUnavailableException('Kein M365-Token erhalten'); + } + + return body.data.accessToken; + } + + // ── Graph API Helpers ───────────────────────────────────────────────── + + private async graphGet( + accessToken: string, + path: string, + params?: Record, + ): Promise { + const url = new URL(`${GRAPH_BASE}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + + const resp = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(10000), + }); + + if (!resp.ok) { + const errBody = (await resp.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new ServiceUnavailableException( + `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, + ); + } + + return resp.json() as Promise; + } + + // ── E-Mails ─────────────────────────────────────────────────────────── + + /** + * E-Mails zum Kontakt aus MS Graph laden. + * Sucht in gesendeten + empfangenen Nachrichten nach der Kontakt-E-Mail. + */ + async getContactEmails( + userJwt: string, + userId: string, + contactEmail: string, + ): Promise { + const cacheKey = `graph:emails:${userId}:${contactEmail}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as M365Email[]; + } + + const accessToken = await this.getM365Token(userJwt); + + const data = await this.graphGet<{ value: M365Email[] }>( + accessToken, + '/me/messages', + { + $search: `"${contactEmail}"`, + $top: '25', + $orderby: 'receivedDateTime desc', + $select: + 'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink', + }, + ); + + const emails = data.value ?? []; + await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL); + this.logger.debug( + `Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`, + ); + return emails; + } + + // ── Kalender-Ereignisse ─────────────────────────────────────────────── + + /** + * Kalender-Ereignisse mit dem Kontakt aus MS Graph laden. + * Filtert Ereignisse in denen die Kontakt-E-Mail als Teilnehmer vorkommt. + */ + async getContactCalendar( + userJwt: string, + userId: string, + contactEmail: string, + ): Promise { + const cacheKey = `graph:calendar:${userId}:${contactEmail}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as M365CalendarEvent[]; + } + + const accessToken = await this.getM365Token(userJwt); + + // Naechste 3 Monate + const now = new Date().toISOString(); + const future = new Date( + Date.now() + 90 * 24 * 60 * 60 * 1000, + ).toISOString(); + + const data = await this.graphGet<{ value: M365CalendarEvent[] }>( + accessToken, + '/me/calendarView', + { + startDateTime: now, + endDateTime: future, + $top: '20', + $filter: `attendees/any(a:a/emailAddress/address eq '${contactEmail}')`, + $select: + 'id,subject,start,end,location,organizer,isOnlineMeeting,onlineMeetingUrl,webLink', + $orderby: 'start/dateTime asc', + }, + ); + + const events = data.value ?? []; + await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL); + return events; + } + + // ── Aufgaben (Tasks) ────────────────────────────────────────────────── + + /** + * Microsoft To Do Aufgaben laden. + * Gibt alle Task-Listen mit ihren Aufgaben zurueck. + */ + async getTasks( + userJwt: string, + userId: string, + ): Promise { + const cacheKey = `graph:tasks:${userId}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as M365TaskList[]; + } + + const accessToken = await this.getM365Token(userJwt); + + // Listen laden + const listsData = await this.graphGet<{ + value: Array<{ id: string; displayName: string }>; + }>(accessToken, '/me/todo/lists', { $top: '20' }); + + const lists: M365TaskList[] = []; + + for (const list of listsData.value ?? []) { + const tasksData = await this.graphGet<{ value: M365Task[] }>( + accessToken, + `/me/todo/lists/${list.id}/tasks`, + { + $top: '50', + $filter: "status ne 'completed'", + $orderby: 'importance desc', + }, + ); + + lists.push({ + id: list.id, + displayName: list.displayName, + tasks: tasksData.value ?? [], + }); + } + + await this.redis.set(cacheKey, JSON.stringify(lists), CACHE_TTL); + return lists; + } +}