INSIGHT-MVP/packages/crm-service/src/graph/graph.service.ts
Thomas Reitz 01dc8bb41c feat(dashboard): E-Mail Tab mit Outlook-Postfach, Ordner-Navigation und Aktivitäten-Speicherung
- GraphService: getMailFolders() + getMailsByFolder(folderId, days) Methoden
- Office365Controller: GET /crm/office365/folders und /folders/:id/messages?days=X Endpoints
- ContactsController/Service: GET /crm/contacts/lookup?email=xxx für CRM-Kontakt-Abgleich
- Frontend types: M365MailFolder + CrmContactLookup Interfaces
- Frontend API: office365Api.getMailFolders/getMailsInFolder + contactsApi.lookupByEmail
- Frontend Hooks: useOffice365MailFolders, useOffice365MailsInFolder, useContactByEmail
- DashboardEmailTab: Ordner-Sidebar, Zeitfilter (1/7/14 Tage/alle), E-Mail-Liste
  mit Outlook-Link, CRM-Badge bei bekannten Absendern, Aktivitäten-Modal mit Kommentar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:16:05 +01:00

413 lines
13 KiB
TypeScript

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<string>('CORE_SERVICE_URL') ?? 'http://core:3000';
}
// ── Token vom Core-Service holen ──────────────────────────────────────
async getM365Token(userJwt: string): Promise<string> {
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<T>(
accessToken: string,
path: string,
params?: Record<string, string>,
): Promise<T> {
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<T>;
}
// ── Kontakt-spezifische Abfragen ──────────────────────────────────────
async getContactEmails(
userJwt: string,
userId: string,
contactEmail: string,
): Promise<M365Email[]> {
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<M365CalendarEvent[]> {
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<M365Email[]> {
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<M365CalendarEvent[]> {
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<M365Contact[]> {
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<M365TaskList[]> {
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<M365MailFolder[]> {
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<M365Email[]> {
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<string, string> = {
$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;
}
}