import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { normalizeAccount, normalizeCacheMinutes, normalizeMaxDepth, normalizeMaxPeople, normalizeProfileUrl, profileProperty, uniqueAccounts } from './OrganizationChartCore'; import { IOrganizationChartData, IOrganizationChartPersonSuggestion, IOrganizationNode, IUserProfile, IUserProfileProperty } from './OrganizationChartTypes'; interface IProfileCacheEntry { expires: number; profile: IUserProfile; } interface ITreeLoadState { count: number; maximum: number; maximumDepth: number; truncated: boolean; visited: { [account: string]: boolean }; } interface IPeoplePickerEntityData { AccountName?: string; Email?: string; } interface IPeoplePickerEntity { DisplayText?: string; EntityData?: IPeoplePickerEntityData; IsResolved?: boolean; Key?: string; } export class OrganizationChartDataService { private static cache: { [account: string]: IProfileCacheEntry } = {}; public constructor(private context: WebPartContext) { } public getOrganization( accountName: string, maxDepth: number, maxPeople: number, cacheMinutes: number, alternateAccountName?: string ): Promise { const account: string = normalizeAccount(accountName); const state: ITreeLoadState = { count: 0, maximum: normalizeMaxPeople(maxPeople), maximumDepth: normalizeMaxDepth(maxDepth), truncated: false, visited: {} }; const rootRequest: Promise = account ? this.getConfiguredProfile(account, cacheMinutes, alternateAccountName) : this.getMyProfile(cacheMinutes); return rootRequest .then((profile: IUserProfile): Promise => this.buildProfileNode(profile, 0, state, cacheMinutes)) .then((root: IOrganizationNode): IOrganizationChartData => ({ root: root, peopleCount: state.count, truncated: state.truncated })); } public getMyProfile(cacheMinutes: number): Promise { const cacheKey: string = '__current_user__'; const cached: IProfileCacheEntry = OrganizationChartDataService.cache[cacheKey]; if (cached && cached.expires > new Date().getTime()) { return Promise.resolve(cached.profile); } return this.requestProfile( this.webUrl + '/_api/SP.UserProfiles.PeopleManager/GetMyProperties', cacheKey, cacheMinutes ); } public getProfile(accountName: string, cacheMinutes: number): Promise { const account: string = normalizeAccount(accountName); const key: string = account.toLowerCase(); const cached: IProfileCacheEntry = OrganizationChartDataService.cache[key]; if (cached && cached.expires > new Date().getTime()) { return Promise.resolve(cached.profile); } const escaped: string = account.replace(/'/g, "''"); const url: string = this.webUrl + "/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)?@v='" + encodeURIComponent(escaped) + "'"; return this.requestProfile(url, key, cacheMinutes); } public searchPeople(query: string): Promise { const searchText: string = normalizeAccount(query); if (searchText.length < 2) { return Promise.resolve([]); } return this.requestPeoplePicker(searchText).then( (entities: IPeoplePickerEntity[]): IOrganizationChartPersonSuggestion[] => entities.filter((entity: IPeoplePickerEntity): boolean => entity.IsResolved !== false && !!normalizeAccount(entity.Key)) .map((entity: IPeoplePickerEntity): IOrganizationChartPersonSuggestion => ({ accountName: normalizeAccount(entity.EntityData && entity.EntityData.AccountName), displayName: normalizeAccount(entity.DisplayText) || normalizeAccount(entity.Key), email: normalizeAccount(entity.EntityData && entity.EntityData.Email), loginName: normalizeAccount(entity.Key) })) ); } public validatePerson(loginName: string, accountName: string, cacheMinutes: number): Promise { const candidates: string[] = uniqueAccounts([loginName, accountName], '', 2); return this.getFirstAvailableProfile(candidates, cacheMinutes, 0) .then((): boolean => true, (): boolean => false); } private getConfiguredProfile( accountName: string, cacheMinutes: number, alternateAccountName?: string ): Promise { return this.resolveAccountNames(accountName) .catch((): string[] => [accountName]) .then((accounts: string[]): Promise => { const candidates: string[] = uniqueAccounts(accounts.concat([alternateAccountName]), '', accounts.length + 1); return this.getFirstAvailableProfile(candidates, cacheMinutes, 0); }); } private getFirstAvailableProfile( accounts: string[], cacheMinutes: number, index: number, previousError?: Error ): Promise { if (index >= accounts.length) { return Promise.reject(previousError || new Error('The configured user could not be resolved.')); } return this.getProfile(accounts[index], cacheMinutes) .catch((error: Error): Promise => this.getFirstAvailableProfile(accounts, cacheMinutes, index + 1, error)); } private resolveAccountNames(accountName: string): Promise { return this.searchPeople(accountName).then( (suggestions: IOrganizationChartPersonSuggestion[]): string[] => { const input: string = normalizeAccount(accountName).toLowerCase(); const matching: IOrganizationChartPersonSuggestion[] = suggestions.filter( (suggestion: IOrganizationChartPersonSuggestion): boolean => suggestion.accountName.toLowerCase() === input || suggestion.email.toLowerCase() === input || suggestion.loginName.toLowerCase() === input ); const selected: IOrganizationChartPersonSuggestion[] = matching.length ? matching : (suggestions.length === 1 ? suggestions : []); const candidates: string[] = []; selected.forEach((suggestion: IOrganizationChartPersonSuggestion): void => { candidates.push(suggestion.loginName); candidates.push(suggestion.accountName); }); candidates.push(normalizeAccount(accountName)); return uniqueAccounts(candidates, '', candidates.length || 1); }); } private requestPeoplePicker(accountName: string): Promise { const url: string = this.webUrl + '/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser'; const body: string = JSON.stringify({ queryParams: { __metadata: { type: 'SP.UI.ApplicationPages.ClientPeoplePickerQueryParameters' }, AllowEmailAddresses: true, AllowMultipleEntities: false, AllUrlZones: false, MaximumEntitySuggestions: 10, PrincipalSource: 15, PrincipalType: 1, QueryString: accountName } }); const headers: any = { // tslint:disable-line:no-any 'Accept': 'application/json;odata=verbose', 'Content-Type': 'application/json;odata=verbose', 'OData-Version': '3.0' }; return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, { headers: headers, body: body }) .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any if (response.ok) { return response.json(); } return response.text().then((text: string): Promise => // tslint:disable-line:no-any Promise.reject(new Error('People Picker request failed (' + response.status + '): ' + text))); }).then((payload: any): IPeoplePickerEntity[] => { // tslint:disable-line:no-any const data: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any const serialized: string = String( data && data.ClientPeoplePickerSearchUser !== undefined ? data.ClientPeoplePickerSearchUser : (data && data.value !== undefined ? data.value : '[]') ); try { return JSON.parse(serialized) as IPeoplePickerEntity[]; } catch (error) { return []; } }); } private requestProfile(url: string, cacheKey: string, cacheMinutes: number): Promise { return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.headers }) .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any if (response.ok) { return response.json(); } return response.text().then((text: string): Promise => // tslint:disable-line:no-any Promise.reject(new Error('User profile request failed (' + response.status + '): ' + text))); }).then((payload: any): IUserProfile => { // tslint:disable-line:no-any const raw: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any const profile: IUserProfile = this.mapProfile(raw || {}); const minutes: number = normalizeCacheMinutes(cacheMinutes); if (minutes > 0) { OrganizationChartDataService.cache[cacheKey] = { profile: profile, expires: new Date().getTime() + minutes * 60000 }; if (profile.accountName) { OrganizationChartDataService.cache[profile.accountName.toLowerCase()] = OrganizationChartDataService.cache[cacheKey]; } } return profile; }); } private buildNode( accountName: string, depth: number, state: ITreeLoadState, cacheMinutes: number ): Promise { const account: string = normalizeAccount(accountName); const key: string = account.toLowerCase(); if (!account || state.visited[key] || state.count >= state.maximum) { if (state.count >= state.maximum) { state.truncated = true; } return Promise.resolve(undefined as IOrganizationNode); } return this.getProfile(account, cacheMinutes).then((profile: IUserProfile): Promise => this.buildProfileNode(profile, depth, state, cacheMinutes)); } private buildProfileNode( profile: IUserProfile, depth: number, state: ITreeLoadState, cacheMinutes: number ): Promise { const key: string = normalizeAccount(profile.accountName).toLowerCase(); if (!key || state.visited[key] || state.count >= state.maximum) { if (state.count >= state.maximum) { state.truncated = true; } return Promise.resolve(undefined as IOrganizationNode); } state.visited[key] = true; state.count++; const node: IOrganizationNode = { profile: profile, children: [] }; if (depth >= state.maximumDepth) { if (profile.directReports.length) { state.truncated = true; } return Promise.resolve(node); } const accounts: string[] = uniqueAccounts(profile.directReports, profile.accountName, state.maximum); const requests: Array> = accounts.map( (childAccount: string): Promise => this.buildNode(childAccount, depth + 1, state, cacheMinutes) .catch((): IOrganizationNode => undefined as IOrganizationNode) ); return Promise.all(requests).then((children: IOrganizationNode[]): IOrganizationNode => { node.children = children.filter((child: IOrganizationNode): boolean => !!child) .sort((left: IOrganizationNode, right: IOrganizationNode): number => left.profile.displayName.localeCompare(right.profile.displayName)); return node; }); } private mapProfile(raw: any): IUserProfile { // tslint:disable-line:no-any const properties: IUserProfileProperty[] = this.results(raw.UserProfileProperties) .map((entry: any): IUserProfileProperty => ({ // tslint:disable-line:no-any Key: String(entry.Key || ''), Value: String(entry.Value || '') })); const account: string = String(raw.AccountName || profileProperty(properties, 'AccountName') || ''); let pictureUrl: string = normalizeProfileUrl(String(raw.PictureUrl || ''), this.webUrl); if (!pictureUrl && account) { pictureUrl = this.webUrl + '/_layouts/15/userphoto.aspx?size=L&accountname=' + encodeURIComponent(account); } return { accountName: account, displayName: String(raw.DisplayName || profileProperty(properties, 'PreferredName') || account), email: String(raw.Email || profileProperty(properties, 'WorkEmail') || ''), title: String(raw.Title || profileProperty(properties, 'SPS-JobTitle') || ''), department: profileProperty(properties, 'Department'), office: profileProperty(properties, 'Office'), workPhone: profileProperty(properties, 'WorkPhone'), cellPhone: profileProperty(properties, 'CellPhone'), location: profileProperty(properties, 'SPS-Location'), managerAccount: profileProperty(properties, 'Manager'), pictureUrl: pictureUrl, profileUrl: normalizeProfileUrl(String(raw.UserUrl || raw.PersonalUrl || ''), this.webUrl), directReports: this.stringResults(raw.DirectReports), extendedManagers: this.stringResults(raw.ExtendedManagers) }; } private stringResults(value: any): string[] { // tslint:disable-line:no-any return this.results(value).map((entry: any): string => String(entry || '')); // tslint:disable-line:no-any } private results(value: any): any[] { // tslint:disable-line:no-any if (!value) { return []; } if (Array.isArray(value.results)) { return value.results; } return Array.isArray(value) ? value : []; } private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); } private get headers(): any { // tslint:disable-line:no-any return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } }