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_ISSUER=${JWT_ISSUER:-insight-platform}
|
||||
- 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_API_KEY=${LEXWARE_API_KEY:-}
|
||||
- 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 { EnrichmentModule } from './enrichment/enrichment.module';
|
||||
import { ContractsModule } from './contracts/contracts.module';
|
||||
import { GraphModule } from './graph/graph.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -53,6 +54,7 @@ import { ContractsModule } from './contracts/contracts.module';
|
|||
ImportModule,
|
||||
EnrichmentModule,
|
||||
ContractsModule,
|
||||
GraphModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ export class EnvironmentVariables {
|
|||
@IsString()
|
||||
@IsOptional()
|
||||
NORTH_DATA_API_URL?: string;
|
||||
|
||||
// Core-Service URL fuer interne Kommunikation (Microsoft 365 Token)
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
CORE_SERVICE_URL?: string;
|
||||
}
|
||||
|
||||
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