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[]; } /** Flache Aufgabe inkl. Body-Inhalt (für Dashboard Aufgaben-Tab) */ export interface M365TaskFlat { id: string; listId: string; listName: string; title: string; status: string; importance: string; dueDateTime: { dateTime: string; timeZone: string } | null; bodyContent: string | null; // Enthält "[INSIGHT_CRM:{activityId}]" wenn synchronisiert createdDateTime: string; } 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 M365UserProfile { givenName: string | null; surname: string | null; displayName: string | null; mobilePhone: string | null; businessPhones: string[]; city: string | null; streetAddress: string | null; postalCode: string | null; jobTitle: string | null; department: string | null; companyName: string | null; officeLocation: 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 graphPost( accessToken: string, path: string, body: unknown, ): Promise { const resp = await fetch(`${GRAPH_BASE}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), }); if (!resp.ok) { const errBody = (await resp.json().catch(() => ({}))) as { error?: { message?: string }; }; this.logger.error( `Graph API POST 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; } private async graphPatch( accessToken: string, path: string, body: unknown, ): Promise { const resp = await fetch(`${GRAPH_BASE}${path}`, { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), }); if (!resp.ok) { const errBody = (await resp.json().catch(() => ({}))) as { error?: { message?: string }; }; this.logger.error( `Graph API PATCH Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`, ); throw new ServiceUnavailableException( `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, ); } // PATCH kann 204 No Content zurückgeben if (resp.status === 204) return {} as T; return resp.json() as Promise; } 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; } // ── Kalender: beliebiger Datumsbereich ─────────────────────────────── /** Kalender-Ereignisse für einen bestimmten Zeitraum (für Monats-/Wochenansicht) */ async getCalendarEventsForRange( userJwt: string, userId: string, startDate: string, // YYYY-MM-DD (inklusiv) endDate: string, // YYYY-MM-DD (exklusiv) ): Promise { const cacheKey = `graph:calendar-range:${userId}:${startDate}:${endDate}`; const cached = await this.redis.get(cacheKey); if (cached) return JSON.parse(cached) as M365CalendarEvent[]; const accessToken = await this.getM365Token(userJwt); const data = await this.graphGet<{ value: M365CalendarEvent[] }>( accessToken, '/me/calendarView', { startDateTime: `${startDate}T00:00:00Z`, endDateTime: `${endDate}T00:00:00Z`, $top: '200', $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 (${startDate} – ${endDate}) geladen`, ); return events; } // ── 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', // wellKnownName wird NICHT in $select aufgenommen — wird von vielen Exchange-Tenants // nicht als selektierbares OData-Property unterstützt (400 Bad Request). // Ordner-Identifikation erfolgt stattdessen über den displayName. $select: 'id,displayName,totalItemCount,unreadItemCount,childFolderCount', }, ); 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; } /** Alle offenen Aufgaben flach (inkl. Body für CRM-Sync-Erkennung) */ async getAllTasksFlat(userJwt: string, userId: string): Promise { // Kein Cache — Aufgaben müssen immer aktuell sein const accessToken = await this.getM365Token(userJwt); const listsData = await this.graphGet<{ value: Array<{ id: string; displayName: string }>; }>(accessToken, '/me/todo/lists', { $top: '20' }); const flatTasks: M365TaskFlat[] = []; for (const list of listsData.value ?? []) { const tasksData = await this.graphGet<{ value: Array<{ id: string; title: string; status: string; importance: string; dueDateTime: { dateTime: string; timeZone: string } | null; body: { content: string; contentType: string } | null; createdDateTime: string; }>; }>(accessToken, `/me/todo/lists/${list.id}/tasks`, { $top: '100', $filter: "status ne 'completed'", $select: 'id,title,status,importance,dueDateTime,body,createdDateTime', $orderby: 'importance desc', }); for (const task of tasksData.value ?? []) { flatTasks.push({ id: task.id, listId: list.id, listName: list.displayName, title: task.title, status: task.status, importance: task.importance, dueDateTime: task.dueDateTime ?? null, bodyContent: task.body?.content ?? null, createdDateTime: task.createdDateTime, }); } } this.logger.debug(`Graph: ${flatTasks.length} Aufgaben (flat) fuer ${userId} geladen`); return flatTasks; } /** Neue Aufgabe in Standard-Aufgabenliste erstellen */ async createM365Task( userJwt: string, title: string, bodyContent?: string, dueDateISO?: string, ): Promise<{ id: string; listId: string }> { const accessToken = await this.getM365Token(userJwt); // Standard-Liste finden ("Tasks" / "Aufgaben" / erste verfügbare Liste) const listsData = await this.graphGet<{ value: Array<{ id: string; displayName: string; wellknownListName?: string }>; }>(accessToken, '/me/todo/lists', { $top: '20' }); const lists = listsData.value ?? []; const targetList = lists.find( (l) => l.displayName.toLowerCase() === 'tasks' || l.displayName.toLowerCase() === 'aufgaben' || (l as { wellknownListName?: string }).wellknownListName === 'defaultList', ) ?? lists[0]; if (!targetList) { throw new ServiceUnavailableException('Keine M365-Aufgabenliste gefunden'); } const taskPayload: Record = { title }; if (bodyContent) { taskPayload['body'] = { content: bodyContent, contentType: 'text' }; } if (dueDateISO) { taskPayload['dueDateTime'] = { dateTime: dueDateISO, timeZone: 'UTC' }; } const created = await this.graphPost<{ id: string }>( accessToken, `/me/todo/lists/${targetList.id}/tasks`, taskPayload, ); this.logger.debug(`Graph: Aufgabe "${title}" in Liste "${targetList.displayName}" erstellt`); return { id: created.id, listId: targetList.id }; } /** Aufgabe als erledigt markieren */ async completeM365Task( userJwt: string, listId: string, taskId: string, ): Promise { const accessToken = await this.getM365Token(userJwt); await this.graphPatch( accessToken, `/me/todo/lists/${listId}/tasks/${taskId}`, { status: 'completed' }, ); this.logger.debug(`Graph: Aufgabe ${taskId} als erledigt markiert`); } /** Microsoft-365-Benutzerprofil laden (für Profilanreicherung) */ async getM365Profile(userJwt: string): Promise { const accessToken = await this.getM365Token(userJwt); const data = await this.graphGet<{ givenName?: string | null; surname?: string | null; displayName?: string | null; mobilePhone?: string | null; businessPhones?: string[]; city?: string | null; streetAddress?: string | null; postalCode?: string | null; jobTitle?: string | null; department?: string | null; companyName?: string | null; officeLocation?: string | null; }>(accessToken, '/me', { $select: 'givenName,surname,displayName,mobilePhone,businessPhones,city,streetAddress,postalCode,jobTitle,department,companyName,officeLocation', }); return { givenName: data.givenName ?? null, surname: data.surname ?? null, displayName: data.displayName ?? null, mobilePhone: data.mobilePhone ?? null, businessPhones: data.businessPhones ?? [], city: data.city ?? null, streetAddress: data.streetAddress ?? null, postalCode: data.postalCode ?? null, jobTitle: data.jobTitle ?? null, department: data.department ?? null, companyName: data.companyName ?? null, officeLocation: data.officeLocation ?? null, }; } /** * CRM-Kontakt in Outlook-Kontakte pushen / synchronisieren. * Sucht anhand der E-Mail-Adresse nach einem vorhandenen Outlook-Kontakt. * Wenn vorhanden → PATCH (Update), sonst → POST (Neu anlegen). */ async pushContactToOutlook( userJwt: string, contact: { firstName: string | null; lastName: string | null; companyName: string | null; email: string | null; phone: string | null; mobile: string | null; position: string | null; department: string | null; street: string | null; zip: string | null; city: string | null; state: string | null; country: string | null; website: string | null; notes: string | null; }, ): Promise<{ created: boolean; outlookContactId: string }> { const accessToken = await this.getM365Token(userJwt); const displayName = [contact.firstName, contact.lastName] .filter(Boolean) .join(' ') || contact.companyName || ''; const outlookPayload: Record = { givenName: contact.firstName ?? '', surname: contact.lastName ?? '', jobTitle: contact.position ?? '', department: contact.department ?? '', companyName: contact.companyName ?? '', businessHomePage: contact.website ?? '', personalNotes: contact.notes ?? '', }; if (contact.email) { outlookPayload['emailAddresses'] = [ { address: contact.email, name: displayName }, ]; } const businessPhones: string[] = []; if (contact.phone) businessPhones.push(contact.phone); if (businessPhones.length > 0) { outlookPayload['businessPhones'] = businessPhones; } if (contact.mobile) { outlookPayload['mobilePhone'] = contact.mobile; } if (contact.street || contact.zip || contact.city) { outlookPayload['businessAddress'] = { street: contact.street ?? '', city: contact.city ?? '', state: contact.state ?? '', postalCode: contact.zip ?? '', countryOrRegion: contact.country ?? '', }; } // Vorhandenen Outlook-Kontakt anhand der E-Mail suchen let existingId: string | null = null; if (contact.email) { try { const search = await this.graphGet<{ value: Array<{ id: string }> }>( accessToken, '/me/contacts', { $filter: `emailAddresses/any(a:a/address eq '${contact.email}')`, $top: '1', $select: 'id', }, ); existingId = search.value?.[0]?.id ?? null; } catch { // Suche schlägt fehl → Neuanlage } } if (existingId) { await this.graphPatch( accessToken, `/me/contacts/${existingId}`, outlookPayload, ); this.logger.debug( `Graph: CRM-Kontakt in Outlook aktualisiert (${existingId})`, ); return { created: false, outlookContactId: existingId }; } const created = await this.graphPost<{ id: string }>( accessToken, '/me/contacts', outlookPayload, ); this.logger.debug( `Graph: CRM-Kontakt in Outlook erstellt (${created.id})`, ); return { created: true, outlookContactId: created.id }; } /** * Microsoft-365-Profilbild laden (96x96 JPEG). * Gibt Base64 Data-URL zurück, oder null wenn kein Foto vorhanden (404). */ async getM365Photo(userJwt: string): Promise { const accessToken = await this.getM365Token(userJwt); try { const resp = await fetch( `${GRAPH_BASE}/me/photos/96x96/$value`, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(10000), }, ); if (resp.status === 404 || resp.status === 400) { this.logger.debug('Graph: Kein M365-Profilbild vorhanden (404/400)'); return null; } if (!resp.ok) { this.logger.warn(`Graph: Profilbild-Fehler ${resp.status} — wird ignoriert`); return null; } const arrayBuffer = await resp.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString('base64'); return `data:image/jpeg;base64,${base64}`; } catch (err) { // Foto ist optional — Fehler niemals an den User propagieren this.logger.warn(`Graph: getM365Photo Fehler: ${(err as Error).message}`); return null; } } /** 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; } }