diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 1365b05..280c3c1 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -34,6 +34,12 @@ model User { postalCode String? @map("postal_code") @db.VarChar(10) city String? @map("city") @db.VarChar(100) + // Organisation (analog Microsoft 365 /me Profil) + jobTitle String? @map("job_title") @db.VarChar(100) + department String? @map("department") @db.VarChar(100) + companyName String? @map("company_name") @db.VarChar(200) + officeLocation String? @map("office_location") @db.VarChar(200) + role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER isActive Boolean @default(true) @map("is_active") diff --git a/packages/core-service/prisma/migrations/20260313_user_profile_extra_fields/migration.sql b/packages/core-service/prisma/migrations/20260313_user_profile_extra_fields/migration.sql new file mode 100644 index 0000000..0fd75c6 --- /dev/null +++ b/packages/core-service/prisma/migrations/20260313_user_profile_extra_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable: Organisations-Felder für Benutzerprofil (analog Microsoft 365 /me) +ALTER TABLE "users" ADD COLUMN "job_title" VARCHAR(100); +ALTER TABLE "users" ADD COLUMN "department" VARCHAR(100); +ALTER TABLE "users" ADD COLUMN "company_name" VARCHAR(200); +ALTER TABLE "users" ADD COLUMN "office_location" VARCHAR(200); diff --git a/packages/core-service/src/core/users/dto/update-user.dto.ts b/packages/core-service/src/core/users/dto/update-user.dto.ts index 998818b..2dd9d5b 100644 --- a/packages/core-service/src/core/users/dto/update-user.dto.ts +++ b/packages/core-service/src/core/users/dto/update-user.dto.ts @@ -77,4 +77,33 @@ export class UpdateUserDto { @IsString() @MaxLength(100) city?: string | null; + + // --- Organisation (analog Microsoft 365 /me) --- + @ApiProperty({ example: 'Senior Developer', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.jobTitle !== null) + @IsString() + @MaxLength(100) + jobTitle?: string | null; + + @ApiProperty({ example: 'Engineering', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.department !== null) + @IsString() + @MaxLength(100) + department?: string | null; + + @ApiProperty({ example: 'Acme GmbH', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.companyName !== null) + @IsString() + @MaxLength(200) + companyName?: string | null; + + @ApiProperty({ example: 'Berlin Office', required: false, nullable: true }) + @IsOptional() + @ValidateIf((o: UpdateUserDto) => o.officeLocation !== null) + @IsString() + @MaxLength(200) + officeLocation?: string | null; } diff --git a/packages/core-service/src/core/users/users.service.ts b/packages/core-service/src/core/users/users.service.ts index e44f3e6..b4d2585 100644 --- a/packages/core-service/src/core/users/users.service.ts +++ b/packages/core-service/src/core/users/users.service.ts @@ -101,6 +101,10 @@ export class UsersService { street: user.street, postalCode: user.postalCode, city: user.city, + jobTitle: user.jobTitle, + department: user.department, + companyName: user.companyName, + officeLocation: user.officeLocation, role: user.role, isActive: user.isActive, twoFactorEnabled: user.twoFactorEnabled, @@ -136,6 +140,10 @@ export class UsersService { ...(dto.street !== undefined && { street: dto.street }), ...(dto.postalCode !== undefined && { postalCode: dto.postalCode }), ...(dto.city !== undefined && { city: dto.city }), + ...(dto.jobTitle !== undefined && { jobTitle: dto.jobTitle }), + ...(dto.department !== undefined && { department: dto.department }), + ...(dto.companyName !== undefined && { companyName: dto.companyName }), + ...(dto.officeLocation !== undefined && { officeLocation: dto.officeLocation }), }, }); @@ -150,6 +158,10 @@ export class UsersService { street: updated.street, postalCode: updated.postalCode, city: updated.city, + jobTitle: updated.jobTitle, + department: updated.department, + companyName: updated.companyName, + officeLocation: updated.officeLocation, role: updated.role, isActive: updated.isActive, }; @@ -175,6 +187,10 @@ export class UsersService { ...(dto.street !== undefined && { street: dto.street }), ...(dto.postalCode !== undefined && { postalCode: dto.postalCode }), ...(dto.city !== undefined && { city: dto.city }), + ...(dto.jobTitle !== undefined && { jobTitle: dto.jobTitle }), + ...(dto.department !== undefined && { department: dto.department }), + ...(dto.companyName !== undefined && { companyName: dto.companyName }), + ...(dto.officeLocation !== undefined && { officeLocation: dto.officeLocation }), }, }); @@ -189,6 +205,10 @@ export class UsersService { street: updated.street, postalCode: updated.postalCode, city: updated.city, + jobTitle: updated.jobTitle, + department: updated.department, + companyName: updated.companyName, + officeLocation: updated.officeLocation, role: updated.role, isActive: updated.isActive, twoFactorEnabled: updated.twoFactorEnabled, diff --git a/packages/crm-service/src/graph/graph.service.ts b/packages/crm-service/src/graph/graph.service.ts index a5d1eca..96df40b 100644 --- a/packages/crm-service/src/graph/graph.service.ts +++ b/packages/crm-service/src/graph/graph.service.ts @@ -80,6 +80,9 @@ export interface M365UserProfile { streetAddress: string | null; postalCode: string | null; jobTitle: string | null; + department: string | null; + companyName: string | null; + officeLocation: string | null; } export interface M365MailFolder { @@ -623,9 +626,12 @@ export class GraphService { 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', + 'givenName,surname,displayName,mobilePhone,businessPhones,city,streetAddress,postalCode,jobTitle,department,companyName,officeLocation', }); return { @@ -638,9 +644,48 @@ export class GraphService { streetAddress: data.streetAddress ?? null, postalCode: data.postalCode ?? null, jobTitle: data.jobTitle ?? null, + department: data.department ?? null, + companyName: data.companyName ?? null, + officeLocation: data.officeLocation ?? null, }; } + /** + * Microsoft-365-Profilbild laden (96x96 JPEG). + * Gibt Base64 Data-URL zurück, oder null wenn kein Foto vorhanden (404). + */ + async getM365Photo(userJwt: string): Promise { + 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, diff --git a/packages/crm-service/src/graph/office365.controller.ts b/packages/crm-service/src/graph/office365.controller.ts index 562c834..2039587 100644 --- a/packages/crm-service/src/graph/office365.controller.ts +++ b/packages/crm-service/src/graph/office365.controller.ts @@ -120,6 +120,14 @@ export class Office365Controller { return { success: true, data: profile }; } + /** Microsoft-365-Profilbild abrufen (96x96 JPEG als Base64 Data-URL) */ + @Get('photo') + async getPhoto(@Req() req: Request & { user: JwtUser }) { + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const photoBase64 = await this.graphService.getM365Photo(jwt); + return { success: true, data: { photoBase64 } }; + } + @Get('folders') async getMailFolders(@Req() req: Request & { user: JwtUser }) { const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); diff --git a/packages/frontend/src/auth/AuthContext.tsx b/packages/frontend/src/auth/AuthContext.tsx index 144cd32..a48e233 100644 --- a/packages/frontend/src/auth/AuthContext.tsx +++ b/packages/frontend/src/auth/AuthContext.tsx @@ -19,6 +19,11 @@ interface User { street?: string | null; postalCode?: string | null; city?: string | null; + // Organisation (analog Microsoft 365 /me) + jobTitle?: string | null; + department?: string | null; + companyName?: string | null; + officeLocation?: string | null; role: string; twoFactorEnabled: boolean; } diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 34b7399..e7c4bc1 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -906,4 +906,9 @@ export const office365Api = { api .get<{ success: boolean; data: M365UserProfile }>('/crm/office365/profile') .then((r) => r.data), + + getM365Photo: () => + api + .get<{ success: boolean; data: { photoBase64: string | null } }>('/crm/office365/photo') + .then((r) => r.data), }; diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index ee91711..06e833a 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -1065,6 +1065,9 @@ export interface M365UserProfile { streetAddress: string | null; postalCode: string | null; jobTitle: string | null; + department: string | null; + companyName: string | null; + officeLocation: string | null; } /** Minimaler CRM-Kontakt für E-Mail-Lookup */ diff --git a/packages/frontend/src/hooks/useO365ProfileSync.ts b/packages/frontend/src/hooks/useO365ProfileSync.ts index 5cc4877..22959fd 100644 --- a/packages/frontend/src/hooks/useO365ProfileSync.ts +++ b/packages/frontend/src/hooks/useO365ProfileSync.ts @@ -4,6 +4,7 @@ import { useIntegrations } from '../crm/hooks'; import { office365Api } from '../crm/api'; import api from '../api/client'; + const SESSION_KEY = 'o365_profile_synced'; /** @@ -33,11 +34,17 @@ export function useO365ProfileSync(): void { void (async () => { try { - const result = await office365Api.getM365Profile(); - const p = result.data; + // Fetch profile + photo in parallel + const [profileResult, photoResult] = await Promise.all([ + office365Api.getM365Profile(), + office365Api.getM365Photo(), + ]); + const p = profileResult.data; - // Overwrite all fields where O365 has a non-null value + // Build patch with all fields where O365 has a non-null value const patch: Record = {}; + + // Kontaktfelder if (p.givenName) patch.firstName = p.givenName; if (p.surname) patch.lastName = p.surname; if (p.businessPhones?.[0]) patch.phone = p.businessPhones[0]; @@ -46,6 +53,17 @@ export function useO365ProfileSync(): void { if (p.streetAddress) patch.street = p.streetAddress; if (p.postalCode) patch.postalCode = p.postalCode; + // Organisationsfelder + if (p.jobTitle) patch.jobTitle = p.jobTitle; + if (p.department) patch.department = p.department; + if (p.companyName) patch.companyName = p.companyName; + if (p.officeLocation) patch.officeLocation = p.officeLocation; + + // Profilbild — nur wenn noch kein INSIGHT-Avatar gesetzt + if (!user.avatar && photoResult.data.photoBase64) { + patch.avatar = photoResult.data.photoBase64; + } + if (Object.keys(patch).length > 0) { await api.patch('/users/me', patch); await refreshUser(); diff --git a/packages/frontend/src/profile/ProfilePage.tsx b/packages/frontend/src/profile/ProfilePage.tsx index 5d67f83..9b67b38 100644 --- a/packages/frontend/src/profile/ProfilePage.tsx +++ b/packages/frontend/src/profile/ProfilePage.tsx @@ -22,6 +22,12 @@ export function ProfilePage() { const [street, setStreet] = useState(user?.street ?? ''); const [postalCode, setPostalCode] = useState(user?.postalCode ?? ''); const [city, setCity] = useState(user?.city ?? ''); + + // --- Organisation --- + const [jobTitle, setJobTitle] = useState(user?.jobTitle ?? ''); + const [department, setDepartment] = useState(user?.department ?? ''); + const [companyName, setCompanyName] = useState(user?.companyName ?? ''); + const [officeLocation, setOfficeLocation] = useState(user?.officeLocation ?? ''); const [profileMsg, setProfileMsg] = useState(''); const [profileError, setProfileError] = useState(''); const [profileLoading, setProfileLoading] = useState(false); @@ -86,6 +92,16 @@ export function ProfilePage() { } }, [user?.phone, user?.mobile, user?.street, user?.postalCode, user?.city]); + // Organisationsdaten mit Context-User synchronisieren + useEffect(() => { + if (user) { + setJobTitle(user.jobTitle ?? ''); + setDepartment(user.department ?? ''); + setCompanyName(user.companyName ?? ''); + setOfficeLocation(user.officeLocation ?? ''); + } + }, [user?.jobTitle, user?.department, user?.companyName, user?.officeLocation]); + // === Handler: Profilbild hochladen === const handleAvatarChange = async (e: ChangeEvent) => { const file = e.target.files?.[0]; @@ -161,6 +177,10 @@ export function ProfilePage() { street: street || null, postalCode: postalCode || null, city: city || null, + jobTitle: jobTitle || null, + department: department || null, + companyName: companyName || null, + officeLocation: officeLocation || null, }); await refreshUser(); setProfileMsg('Profil erfolgreich aktualisiert'); @@ -181,39 +201,36 @@ export function ProfilePage() { setEnrichLoading(true); try { - const result = await office365Api.getM365Profile(); - const p = result.data; + // Profil + Foto parallel laden + const [profileResult, photoResult] = await Promise.all([ + office365Api.getM365Profile(), + office365Api.getM365Photo(), + ]); + const p = profileResult.data; const updated: string[] = []; - // Always overwrite with O365 values where O365 has data - if (p.givenName) { - setFirstName(p.givenName); - updated.push('Vorname'); - } - if (p.surname) { - setLastName(p.surname); - updated.push('Nachname'); - } - if (p.businessPhones?.[0]) { - setPhone(p.businessPhones[0]); - updated.push('Telefon'); - } - if (p.mobilePhone) { - setMobile(p.mobilePhone); - updated.push('Mobil'); - } - if (p.city) { - setCity(p.city); - updated.push('Ort'); - } - if (p.streetAddress) { - setStreet(p.streetAddress); - updated.push('Straße'); - } - if (p.postalCode) { - setPostalCode(p.postalCode); - updated.push('PLZ'); + // Kontaktfelder — immer überschreiben wo O365 Daten hat + if (p.givenName) { setFirstName(p.givenName); updated.push('Vorname'); } + if (p.surname) { setLastName(p.surname); updated.push('Nachname'); } + if (p.businessPhones?.[0]) { setPhone(p.businessPhones[0]); updated.push('Telefon'); } + if (p.mobilePhone) { setMobile(p.mobilePhone); updated.push('Mobil'); } + if (p.city) { setCity(p.city); updated.push('Ort'); } + if (p.streetAddress) { setStreet(p.streetAddress); updated.push('Straße'); } + if (p.postalCode) { setPostalCode(p.postalCode); updated.push('PLZ'); } + + // Organisationsfelder + if (p.jobTitle) { setJobTitle(p.jobTitle); updated.push('Position'); } + if (p.department) { setDepartment(p.department); updated.push('Abteilung'); } + if (p.companyName) { setCompanyName(p.companyName); updated.push('Firma'); } + if (p.officeLocation) { setOfficeLocation(p.officeLocation); updated.push('Standort'); } + + // Profilbild übernehmen (manuelle Sync überschreibt immer) + if (photoResult.data.photoBase64) { + await api.patch('/users/me', { avatar: photoResult.data.photoBase64 }); + setAvatar(photoResult.data.photoBase64); + await refreshUser(); + updated.push('Profilbild'); } if (updated.length > 0) { @@ -641,6 +658,59 @@ export function ProfilePage() { + {/* Organisation */} +
+ Organisation +
+
+ + setJobTitle(e.target.value)} + maxLength={100} + placeholder="Senior Developer" + /> +
+
+ + setDepartment(e.target.value)} + maxLength={100} + placeholder="Engineering" + /> +
+
+
+
+ + setCompanyName(e.target.value)} + maxLength={200} + placeholder="Acme GmbH" + /> +
+
+ + setOfficeLocation(e.target.value)} + maxLength={200} + placeholder="Berlin Office" + /> +
+
+
+