mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
28f6ba84b0
commit
47b1938605
6 changed files with 412 additions and 0 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
118
packages/crm-service/src/graph/graph.controller.ts
Normal file
118
packages/crm-service/src/graph/graph.controller.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/crm-service/src/graph/graph.module.ts
Normal file
12
packages/crm-service/src/graph/graph.module.ts
Normal 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 {}
|
||||||
273
packages/crm-service/src/graph/graph.service.ts
Normal file
273
packages/crm-service/src/graph/graph.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue