mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
- 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>
413 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|