mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
- max-width 960px auf Kontakt-Detailseite - M365-Sektion umbenannt zu "Outlook Daten", default eingeklappt - Aufgaben-Tab entfernt (nur noch E-Mails + Kalender) - "In Outlook speichern"-Button: pusht/aktualisiert Kontakt in Outlook-Kontakte via MS Graph POST/PATCH /me/contacts - Kontaktdaten: Typ, Status immer sichtbar, Bundesland (state) in Adresse - Backend: GraphService exportiert, pushContactToOutlook-Methode, POST /crm/contacts/:id/push-to-outlook Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
834 lines
26 KiB
TypeScript
834 lines
26 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[];
|
||
}
|
||
|
||
/** 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<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 graphPost<T>(
|
||
accessToken: string,
|
||
path: string,
|
||
body: unknown,
|
||
): Promise<T> {
|
||
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<T>;
|
||
}
|
||
|
||
private async graphPatch<T>(
|
||
accessToken: string,
|
||
path: string,
|
||
body: unknown,
|
||
): Promise<T> {
|
||
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<T>;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ── 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<M365CalendarEvent[]> {
|
||
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<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',
|
||
// 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<M365TaskFlat[]> {
|
||
// 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<string, unknown> = { 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<void> {
|
||
const accessToken = await this.getM365Token(userJwt);
|
||
await this.graphPatch<unknown>(
|
||
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<M365UserProfile> {
|
||
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<string, unknown> = {
|
||
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<unknown>(
|
||
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<string | null> {
|
||
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<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;
|
||
}
|
||
}
|