feat(crm): Microsoft 365 Graph-API Proxy (Phase 3.2)

- Neues GraphModule mit GraphService + GraphController
- GET /crm/contacts/:id/emails    → MS Graph Emails (Search nach Kontakt-E-Mail)
- GET /crm/contacts/:id/calendar  → Kalender-Termine (naechste 90 Tage)
- GET /crm/contacts/:id/tasks     → Microsoft To Do Listen + Aufgaben
- GraphService: JWT an Core-Service weiterleiten, M365-Token holen, Graph aufrufen
- Redis-Cache 5 Minuten fuer alle Graph-Responses
- CORE_SERVICE_URL env var + docker-compose.crm.yml Eintrag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 22:40:31 +01:00
parent 28f6ba84b0
commit 47b1938605
6 changed files with 412 additions and 0 deletions

View file

@ -24,6 +24,8 @@ services:
- JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem - JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem
- JWT_ISSUER=${JWT_ISSUER:-insight-platform} - JWT_ISSUER=${JWT_ISSUER:-insight-platform}
- CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59} - CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59}
# Core-Service URL fuer interne Kommunikation (M365 Token)
- CORE_SERVICE_URL=http://core:3000
# Lexware Office Integration (optional) # Lexware Office Integration (optional)
- LEXWARE_API_KEY=${LEXWARE_API_KEY:-} - LEXWARE_API_KEY=${LEXWARE_API_KEY:-}
- LEXWARE_API_URL=${LEXWARE_API_URL:-https://api.lexware.io} - LEXWARE_API_URL=${LEXWARE_API_URL:-https://api.lexware.io}

View file

@ -25,6 +25,7 @@ import { CustomFieldsModule } from './custom-fields/custom-fields.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { EnrichmentModule } from './enrichment/enrichment.module'; import { EnrichmentModule } from './enrichment/enrichment.module';
import { ContractsModule } from './contracts/contracts.module'; import { ContractsModule } from './contracts/contracts.module';
import { GraphModule } from './graph/graph.module';
@Module({ @Module({
imports: [ imports: [
@ -53,6 +54,7 @@ import { ContractsModule } from './contracts/contracts.module';
ImportModule, ImportModule,
EnrichmentModule, EnrichmentModule,
ContractsModule, ContractsModule,
GraphModule,
], ],
providers: [ providers: [
{ {

View file

@ -57,6 +57,11 @@ export class EnvironmentVariables {
@IsString() @IsString()
@IsOptional() @IsOptional()
NORTH_DATA_API_URL?: string; NORTH_DATA_API_URL?: string;
// Core-Service URL fuer interne Kommunikation (Microsoft 365 Token)
@IsString()
@IsOptional()
CORE_SERVICE_URL?: string;
} }
export function validate( export function validate(

View file

@ -0,0 +1,118 @@
import {
Controller,
Get,
Param,
Req,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Request } from 'express';
import { GraphService } from './graph.service';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
interface JwtUser {
sub: string;
email: string;
tenantId: string;
}
/**
* GraphController Microsoft 365 Graph-API Proxy
*
* Routen:
* GET /crm/contacts/:id/emails E-Mails zu einem CRM-Kontakt
* GET /crm/contacts/:id/calendar Kalender-Termine mit einem Kontakt
* GET /crm/contacts/:id/tasks Microsoft To Do Aufgaben
*/
@Controller('contacts')
export class GraphController {
private readonly logger = new Logger(GraphController.name);
constructor(
private readonly graphService: GraphService,
private readonly prisma: CrmPrismaService,
) {}
/**
* GET /api/v1/crm/contacts/:id/emails
* E-Mails die den Kontakt betreffen aus MS Graph laden.
*/
@Get(':id/emails')
async getContactEmails(
@Param('id') contactId: string,
@Req() req: Request & { user: JwtUser },
) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId: req.user.tenantId },
select: { id: true, email: true },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
if (!contact.email) {
return { success: true, data: [], meta: { reason: 'Kein E-Mail beim Kontakt hinterlegt' } };
}
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const emails = await this.graphService.getContactEmails(
jwt,
req.user.sub,
contact.email,
);
return { success: true, data: emails, meta: { count: emails.length } };
}
/**
* GET /api/v1/crm/contacts/:id/calendar
* Kalender-Termine mit dem Kontakt aus MS Graph laden.
*/
@Get(':id/calendar')
async getContactCalendar(
@Param('id') contactId: string,
@Req() req: Request & { user: JwtUser },
) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId: req.user.tenantId },
select: { id: true, email: true },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
if (!contact.email) {
return { success: true, data: [], meta: { reason: 'Kein E-Mail beim Kontakt hinterlegt' } };
}
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const events = await this.graphService.getContactCalendar(
jwt,
req.user.sub,
contact.email,
);
return { success: true, data: events, meta: { count: events.length } };
}
/**
* GET /api/v1/crm/contacts/:id/tasks
* Microsoft To Do Aufgaben (nicht kontakt-spezifisch, alle Listen).
*/
@Get(':id/tasks')
async getContactTasks(
@Param('id') _contactId: string,
@Req() req: Request & { user: JwtUser },
) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const taskLists = await this.graphService.getTasks(jwt, req.user.sub);
return {
success: true,
data: taskLists,
meta: { listCount: taskLists.length },
};
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { GraphController } from './graph.controller';
import { GraphService } from './graph.service';
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
import { RedisModule } from '../redis/redis.module';
@Module({
imports: [CrmPrismaModule, RedisModule],
controllers: [GraphController],
providers: [GraphService],
})
export class GraphModule {}

View file

@ -0,0 +1,273 @@
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 } };
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[];
}
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 ──────────────────────────────────────
/**
* M365-Access-Token vom Core-Service abrufen.
* Leitet den User-JWT an Core weiter Core gibt den M365-Token zurueck.
*/
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 };
};
throw new ServiceUnavailableException(
`Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`,
);
}
return resp.json() as Promise<T>;
}
// ── E-Mails ───────────────────────────────────────────────────────────
/**
* E-Mails zum Kontakt aus MS Graph laden.
* Sucht in gesendeten + empfangenen Nachrichten nach der Kontakt-E-Mail.
*/
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);
const data = await this.graphGet<{ value: M365Email[] }>(
accessToken,
'/me/messages',
{
$search: `"${contactEmail}"`,
$top: '25',
$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 fuer ${contactEmail} geladen`,
);
return emails;
}
// ── Kalender-Ereignisse ───────────────────────────────────────────────
/**
* Kalender-Ereignisse mit dem Kontakt aus MS Graph laden.
* Filtert Ereignisse in denen die Kontakt-E-Mail als Teilnehmer vorkommt.
*/
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);
// Naechste 3 Monate
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,isOnlineMeeting,onlineMeetingUrl,webLink',
$orderby: 'start/dateTime asc',
},
);
const events = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL);
return events;
}
// ── Aufgaben (Tasks) ──────────────────────────────────────────────────
/**
* Microsoft To Do Aufgaben laden.
* Gibt alle Task-Listen mit ihren Aufgaben zurueck.
*/
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);
// Listen laden
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;
}
}