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 } }; attendees: Array<{ emailAddress: { name: string; address: string }; type: 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[]; } export interface M365Contact { id: string; displayName: string; emailAddresses: Array<{ name: string; address: string }>; mobilePhone: string | null; businessPhones: string[]; jobTitle: string | null; companyName: string | null; } export interface M365MailFolder { id: string; displayName: string; totalItemCount: number; unreadItemCount: number; childFolderCount: number; wellKnownName: string | null; } 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 ────────────────────────────────────── 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 }; }; this.logger.error( `Graph API Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`, ); throw new ServiceUnavailableException( `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, ); } return resp.json() as Promise; } // ── Kontakt-spezifische Abfragen ────────────────────────────────────── 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); // Hinweis: $search und $orderby können NICHT kombiniert werden (Graph API Limitation) const data = await this.graphGet<{ value: M365Email[] }>( accessToken, '/me/messages', { $search: `"${contactEmail}"`, $top: '25', $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; } 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); 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,attendees,isOnlineMeeting,onlineMeetingUrl,webLink', $orderby: 'start/dateTime asc', }, ); const events = data.value ?? []; await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL); return events; } // ── Globale Office365-Übersicht ─────────────────────────────────────── /** Alle aktuellen E-Mails (Posteingang) */ async getAllEmails(userJwt: string, userId: string): Promise { const cacheKey = `graph:all-emails:${userId}`; 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', { $top: '50', $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 (global) geladen`); return emails; } /** Alle Kalender-Ereignisse (nächste 30 Tage) */ async getAllCalendarEvents( userJwt: string, userId: string, ): Promise { const cacheKey = `graph:all-calendar:${userId}`; const cached = await this.redis.get(cacheKey); if (cached) return JSON.parse(cached) as M365CalendarEvent[]; const accessToken = await this.getM365Token(userJwt); const now = new Date().toISOString(); const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); const data = await this.graphGet<{ value: M365CalendarEvent[] }>( accessToken, '/me/calendarView', { startDateTime: now, endDateTime: future, $top: '50', $select: 'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink', $orderby: 'start/dateTime asc', }, ); const events = data.value ?? []; await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL); this.logger.debug(`Graph: ${events.length} Kalender-Ereignisse (global) geladen`); return events; } /** Outlook-Kontakte des Benutzers */ async getAllOutlookContacts( userJwt: string, userId: string, ): Promise { const cacheKey = `graph:all-contacts:${userId}`; const cached = await this.redis.get(cacheKey); if (cached) return JSON.parse(cached) as M365Contact[]; const accessToken = await this.getM365Token(userJwt); const data = await this.graphGet<{ value: M365Contact[] }>( accessToken, '/me/contacts', { $top: '100', $orderby: 'displayName asc', $select: 'id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName', }, ); const contacts = data.value ?? []; await this.redis.set(cacheKey, JSON.stringify(contacts), CACHE_TTL); this.logger.debug(`Graph: ${contacts.length} Outlook-Kontakte geladen`); return contacts; } /** Alle Task-Listen mit Aufgaben */ 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); 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; } // ── Mail-Ordner ─────────────────────────────────────────────────────── /** Alle Mail-Ordner des Benutzers (Inbox, Gesendet, Entwürfe, …) */ async getMailFolders(userJwt: string, userId: string): Promise { const cacheKey = `graph:mail-folders:${userId}`; const cached = await this.redis.get(cacheKey); if (cached) return JSON.parse(cached) as M365MailFolder[]; const accessToken = await this.getM365Token(userJwt); const data = await this.graphGet<{ value: M365MailFolder[] }>( accessToken, '/me/mailFolders', { $top: '50', $select: 'id,displayName,totalItemCount,unreadItemCount,childFolderCount,wellKnownName', }, ); const folders = data.value ?? []; await this.redis.set(cacheKey, JSON.stringify(folders), CACHE_TTL); this.logger.debug(`Graph: ${folders.length} Mail-Ordner geladen`); return folders; } /** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */ async getMailsByFolder( userJwt: string, userId: string, folderId: string, days: number, ): Promise { const cacheKey = `graph:folder-mails:${userId}:${folderId}:${days}`; const cached = await this.redis.get(cacheKey); if (cached) return JSON.parse(cached) as M365Email[]; const accessToken = await this.getM365Token(userJwt); const params: Record = { $top: '50', $orderby: 'receivedDateTime desc', $select: 'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink', }; if (days > 0) { const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); params['$filter'] = `receivedDateTime ge ${since}`; } const data = await this.graphGet<{ value: M365Email[] }>( accessToken, `/me/mailFolders/${folderId}/messages`, params, ); const emails = data.value ?? []; await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL); this.logger.debug( `Graph: ${emails.length} E-Mails in Ordner ${folderId} (days=${days}) geladen`, ); return emails; } }