diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index c4192fbc..e45a638c 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -23,6 +23,7 @@ export type BriefOrganization = { user_role: UserRole; display_decimal_places: number; salesforce_enabled: boolean; + bb_salesforce_enabled: boolean; access_level_names: string[]; audit_template_conditional_import: boolean; property_display_field: string; diff --git a/src/@seed/api/salesforce/bb-salesforce.service.ts b/src/@seed/api/salesforce/bb-salesforce.service.ts new file mode 100644 index 00000000..05c1b0ea --- /dev/null +++ b/src/@seed/api/salesforce/bb-salesforce.service.ts @@ -0,0 +1,132 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { catchError, map, ReplaySubject } from 'rxjs' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { UserService } from '../user' +import type { + BbSalesforceConfig, + BbSalesforceConfigResponse, + BbSalesforceConfigsResponse, + BbSalesforceLoginUrlResponse, + BbSalesforceLogoutResponse, + BbSalesforceTokenResponse, + BbSalesforceVerifyTokenResponse, +} from './salesforce.types' + +@Injectable({ providedIn: 'root' }) +export class BbSalesforceService { + private _httpClient = inject(HttpClient) + private _errorService = inject(ErrorService) + private _snackBar = inject(SnackBarService) + private _userService = inject(UserService) + + private _config = new ReplaySubject(1) + + config$ = this._config.asObservable() + + constructor() { + this._userService.currentOrganizationId$.subscribe((organizationId) => { + this.getConfig(organizationId).subscribe() + }) + } + + getConfig(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/configs/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + map((response) => { + if (response.bb_salesforce_configs.length === 0) { + this._config.next({} as BbSalesforceConfig) + } else { + this._config.next(response.bb_salesforce_configs[0]) + } + return response + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching BB Salesforce config') + }), + ) + } + + create(organizationId: number, config: Partial): Observable { + const url = `/api/v3/bb_salesforce/configs/?organization_id=${organizationId}` + return this._httpClient.post(url, { ...config }).pipe( + map((response) => { + this._config.next(response.bb_salesforce_config) + return response.bb_salesforce_config + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating BB Salesforce config') + }), + ) + } + + update(organizationId: number, config: BbSalesforceConfig): Observable { + const url = `/api/v3/bb_salesforce/configs/update_config/?organization_id=${organizationId}` + return this._httpClient.put(url, { ...config }).pipe( + map((response) => { + this._snackBar.success('BB Salesforce configuration updated') + this._config.next(response.bb_salesforce_config) + return response.bb_salesforce_config + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating BB Salesforce config') + }), + ) + } + + delete(organizationId: number, configId: number): Observable { + const url = `/api/v3/bb_salesforce/configs/${configId}/?organization_id=${organizationId}` + return this._httpClient.delete(url).pipe( + map(() => { + this._config.next({} as BbSalesforceConfig) + return {} + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting BB Salesforce config') + }), + ) + } + + getLoginUrl(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/login_url/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Cannot login to Salesforce. Double check the Salesforce login URL.') + }), + ) + } + + getToken(code: string, organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/get_token/?code=${code}&organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error exchanging Salesforce authorization code') + }), + ) + } + + logout(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/logout/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + map((response) => { + this._snackBar.success('Successfully logged out of Salesforce') + return response + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error logging out of Salesforce') + }), + ) + } + + verifyToken(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/verify_token/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error verifying Salesforce token') + }), + ) + } +} diff --git a/src/@seed/api/salesforce/index.ts b/src/@seed/api/salesforce/index.ts index 396aad80..74e6e7ba 100644 --- a/src/@seed/api/salesforce/index.ts +++ b/src/@seed/api/salesforce/index.ts @@ -1,2 +1,3 @@ +export * from './bb-salesforce.service' export * from './salesforce.service' export * from './salesforce.types' diff --git a/src/@seed/api/salesforce/salesforce.types.ts b/src/@seed/api/salesforce/salesforce.types.ts index d1e92ed9..4bf717d0 100644 --- a/src/@seed/api/salesforce/salesforce.types.ts +++ b/src/@seed/api/salesforce/salesforce.types.ts @@ -49,6 +49,43 @@ export type SalesforceMapping = { salesforce_fieldname: string; } +export type BbSalesforceConfig = { + id: number; + organization_id: number; + salesforce_url: string; + client_id: string; + client_secret: string; +} + +export type BbSalesforceConfigResponse = { + status: string; + bb_salesforce_config: BbSalesforceConfig; +} + +export type BbSalesforceConfigsResponse = { + status: string; + bb_salesforce_configs: BbSalesforceConfig[]; +} + +export type BbSalesforceLoginUrlResponse = { + status: string; + url?: string; + response?: string; +} + +export type BbSalesforceTokenResponse = { + status: string; + response?: string; +} + +export type BbSalesforceVerifyTokenResponse = { + valid: boolean; +} + +export type BbSalesforceLogoutResponse = { + status: string; +} + export type SalesforceMappingDeleteResponse = { status: string; message: string; diff --git a/src/app/modules/organizations/settings/salesforce/salesforce.component.html b/src/app/modules/organizations/settings/salesforce/salesforce.component.html index 7acb3d54..9eb8342f 100644 --- a/src/app/modules/organizations/settings/salesforce/salesforce.component.html +++ b/src/app/modules/organizations/settings/salesforce/salesforce.component.html @@ -7,11 +7,13 @@
-
Enable this if you would like to sync data between SEED and a Salesforce instance
+
+ {{ t('SF_BUILDING_DESC') }} +
{{ - t('Enable Salesforce Integration') + t('Enable Salesforce Integration (Individual Properties)') }}
@@ -21,7 +23,7 @@
{{ t('Salesforce Connection') }}
-
{{ t('Enter your Salesforce instance details and ensure your connection is successful') }}
+
{{ t('SF_CONNECTION_TEXT') }}
{{ t('Salesforce URL') }} @@ -64,16 +66,14 @@ > - {{ t('Security token set in Salesforce') }} + {{ t('SF_SECURITY_TOKEN_TEXT') }}
{{ t('Domain') }} - {{ - t("If your Salesforce instance is a sandbox, set this field to the value 'test'; otherwise leave blank.") - }} + {{ t('SF_DOMAIN_TEXT') }}
@@ -99,30 +99,30 @@
-
{{ t('Scheduled Daily Updates') }}
+
{{ t('Scheduled Daily Update') }}
- {{ t('If you would like to automatically update Salesforce on a daily basis, configure the fields below') }} + {{ t('SF_SCHEDULED_UPDATE_TEXT') }}
{{ t('Hour') }} - Enter the hour when the update should be run daily (0-23) Timezone: America/New_York + {{ t('SF_UPDATE_HOUR_TEXT') }}
{{ t('Minute') }} - Enter the minute after the hour when the update should be run daily (0-59) + {{ t('SF_UPDATE_MINUTE_TEXT') }}
{{ t('Logging Email') }} - Enter the e-mail address to use when reporting errors during the Salesforce updating process + {{ t('SF_LOGGING_EMAIL_TEXT') }} @if (salesforceForm.get('salesforceConfig.logging_email').hasError('email')) { Please enter a valid email address } @@ -148,7 +148,7 @@
{{ t('Configuration') }}
-
{{ t('Configure a few parameters needed for data transfer to Salesforce') }}
+
{{ t('SF_CONFIGURATION_TEXT') }}
{{ t('Indication Label') }} @@ -157,10 +157,7 @@ {{ l.name }} } - Label used to designate that a SEED property should be updated in Salesforce. Example: 'Add to - Salesforce' + {{ t('SF_INDICATION_LABEL_TEXT') }}
@@ -170,8 +167,7 @@ - Check this checkbox to automatically remove the Indication Label from properties that were successfully updated in - Salesforce in order to prevent future automatic updates. + {{ t('SF_DELETE_LABEL_AFTER_SYNC_TEXT') }}
@@ -182,9 +178,7 @@ {{ l.name }} } - Label used to designate that a SEED property has a violation. Example: 'Violation - Insufficient Data' + {{ t('SF_VIOLATION_LABEL_TEXT') }}
@@ -195,7 +189,7 @@ {{ l.name }} } - Label used to designate that a SEED property is in compliance. Example: 'Complied' + {{ t('SF_COMPLIANCE_LABEL_TEXT') }}
{{ t('Salesforce Unique Benchmark ID Fieldname') }} - The API field name in the Salesforce Benchmark object that stores the unique identifier. Ex: - Unique_Benchmark_ID__c + {{ t('SF_BENCHMARK_ID_FIELDNAME_TEXT') }}
@@ -229,31 +220,28 @@ {{ c.display_name }} } - Select the SEED property column that contains the unique identifier matching the Salesforce Benchmark ID - field + {{ t('SF_SEED_BENCHMARK_ID_FIELDNAME_TEXT') }}
{{ t('Cycle Name Benchmark Field') }} - The API field name in the Salesforce Benchmark object that stores the cycle name. Ex: Cycle__c + {{ t('SF_CYCLE_FIELDNAME_TEXT') }}
{{ t('Status Label Benchmark Field') }} - The API field name in the Salesforce Benchmark object that stores the status label. Ex: Status__c + {{ t('SF_STATUS_FIELDNAME_TEXT') }}
{{ t('All Labels Benchmark Field') }} - The API field name in the Salesforce Benchmark object that stores all labels. Ex: Labels__c + {{ t('SF_LABELS_FIELDNAME_TEXT') }}
{{ t('Contacts and Accounts') }}
- {{ - t( - 'Configure the contact information used for benchmark status notifications in Salesforce. When configured, this functionality will create or update contact records in Salesforce and link them to the Benchmark object' - ) - }} + {{ t('SF_CONTACTS_AND_ACCOUNTS_TEXT') }}
{{ t('Salesforce Account Object Record Type') }} - If your Salesforce instance has multiple account types, provide the Record Type ID of the type of account to use - when accounts are automatically created from SEED + {{ t('SF_ACCOUNT_RECORD_TYPE_TEXT') }}
{{ t('Salesforce Contact Object Record Type') }} - If your Salesforce instance has multiple contact types, provide the Record Type ID of the type to use when contacts - are automatically created from SEED + {{ t('SF_CONTACT_RECORD_TYPE_TEXT') }}
{{ t('Main Contact Settings') }}
- {{ - t( - 'The following fields will be used to retrieve or create a main contact to associate with the Benchmark object in Salesforce. Leave blank if your instance does not use this functionality.' - ) - }} + {{ t('SF_CONTACT_HEADER_TEXT') }}
@@ -310,21 +284,14 @@ {{ c.display_name }} } - Select the SEED field that holds the account name for the contact record to be created in Salesforce. Ex: - 'Organization' + {{ t('SF_ACCOUNT_NAME_FIELDNAME_TEXT') }}
{{ t('Default Contact Account Name') }} - Provide a default account name for Salesforce to use when there is no valid data in the Contact Account Name Column - specified above. Leave this field blank to report an error and abort sync instead when the Contact Account Name - Column is blank. + {{ t('SF_DEFAULT_ACCOUNT_NAME_TEXT') }}
@@ -335,7 +302,7 @@ {{ c.display_name }} } - Select the SEED field that holds the contact name for the benchmark record. Ex: 'On Behalf Of' + {{ t('SF_CONTACT_NAME_FIELDNAME_TEXT') }}
@@ -346,26 +313,19 @@ {{ c.display_name }} } - Select the SEED field that holds the contact email for the benchmark record. Ex: 'Email' + {{ t('SF_CONTACT_EMAIL_FIELDNAME_TEXT') }}
{{ t('Contact Benchmark Field') }} - If your Salesforce Benchmark Record stores a Salesforce Contact relation, provide the Salesforce field name here, - ex: Contact_Name__c + {{ t('SF_BENCHMARK_CONTACT_FIELDNAME_TEXT') }}
{{ t('Data Administrator Contact Settings') }}
- {{ - t( - 'The following fields will be used to retrieve or create a property data administrator contact to associate with the Benchmark object in Salesforce. Leave blank if your instance does not use this functionality.' - ) - }} + {{ t('SF_DATA_ADMIN_HEADER_TEXT') }}
@@ -375,21 +335,14 @@ {{ c.display_name }} } - Select the SEED field that holds the account name for the data administrator contact record to be created in - Salesforce. Ex: 'Organization' + {{ t('SF_DATA_ADMIN_ACCOUNT_NAME_FIELDNAME_TEXT') }}
{{ t('Default Data Administrator Account Name') }} - Provide a default account name for Salesforce to use when there is no valid data in the Data Administrator Account - Name Column specified above. Leave this field blank to report an error and abort sync instead when the Data - Administrator Account Name Column is blank + {{ t('SF_DEFAULT_DATA_ADMIN_ACCOUNT_NAME_TEXT') }}
@@ -400,10 +353,7 @@ {{ c.display_name }} } - Select the SEED field that holds the property data administrator's name for the benchmark record. Ex: 'Property - Data Administrator' + {{ t('SF_DATA_ADMIN_NAME_FIELDNAME_TEXT') }}
@@ -414,20 +364,14 @@ {{ c.display_name }} } - Select the SEED field that holds the property data administrator's email for the benchmark record. Ex: 'Property - Data Administrator - Email' + {{ t('SF_DATA_ADMIN_EMAIL_FIELDNAME_TEXT') }}
{{ t('Data Administrator Contact Field') }} - - If your Salesforce Benchmark Record stores a Salesforce Contact relation for a Property Data Administrator, provide - the Salesforce field name here, ex: Property_Data_Administrator__c - + {{ t('SF_DATA_ADMIN_CONTACT_FIELDNAME_TEXT') }}
{{ t('Save Changes') }} + + + + +
+ {{ t('SF_PORTFOLIO_DESC') }} +
+ +
+ {{ + t('Enable Salesforce Integration (Portfolio of Properties)') + }} +
+ + @if (bbSalesforceForm.controls.bb_salesforce_enabled.value) { +
+
+
+ +
{{ t('Salesforce Connection') }}
+
+
+ {{ t('Enter your Salesforce OAuth credentials to connect portfolio data') }} +
+
+ + {{ t('Salesforce URL') }} + + {{ t('The URL of your Salesforce instance') }} + +
+
+ + {{ t('Client ID') }} + + {{ t('The Client ID from your Salesforce Connected App') }} + +
+
+ + {{ t('Client Secret') }} + + + {{ t('The Client Secret from your Salesforce Connected App') }} + +
+ +
+ +
{{ t('Connection Status') }}
+
+
+ @if (isLoggedIntoBbSalesforce) { +
+ + {{ t('Connected to Salesforce') }} +
+ + } @else { +
+ + {{ t('Not connected to Salesforce') }} +
+ + } +
+
+
+ + } + +
+ +
- {{ - t( - "Map your Salesforce Benchmark Object's fields to columns in SEED. Use the Object Manager in Salesforce to retrieve the field names (not the field labels), ex: Cycle__c (not Cycle). These fields will only be sent to Salesforce when a property has the compliant label applied." - ) - }} + {{ t('SF_MAPPINGS_TEXT') }}
diff --git a/src/app/modules/organizations/settings/salesforce/salesforce.component.ts b/src/app/modules/organizations/settings/salesforce/salesforce.component.ts index 465dad4b..edaf17fa 100644 --- a/src/app/modules/organizations/settings/salesforce/salesforce.component.ts +++ b/src/app/modules/organizations/settings/salesforce/salesforce.component.ts @@ -4,8 +4,8 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { MatDialog } from '@angular/material/dialog' import { MatTableDataSource } from '@angular/material/table' import { Subject, takeUntil, tap } from 'rxjs' -import type { Column, Label, Organization, SalesforceConfig, SalesforceMapping } from '@seed/api' -import { ColumnService, LabelService, OrganizationService, SalesforceService } from '@seed/api' +import type { BbSalesforceConfig, Column, Label, Organization, SalesforceConfig, SalesforceMapping } from '@seed/api' +import { BbSalesforceService, ColumnService, LabelService, OrganizationService, SalesforceService } from '@seed/api' import { PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' @@ -20,18 +20,22 @@ import { DeleteModalComponent, FormModalComponent } from './modal' export class SalesforceComponent implements OnDestroy, OnInit { private _organizationService = inject(OrganizationService) private _salesforceService = inject(SalesforceService) + private _bbSalesforceService = inject(BbSalesforceService) private _labelService = inject(LabelService) private _columnService = inject(ColumnService) private readonly _unsubscribeAll$ = new Subject() private _dialog = inject(MatDialog) passwordHidden = true tokenHidden = true + bbClientSecretHidden = true testConnectionStatus: 'idle' | 'success' | 'error' = 'idle' testConnectionMessage = '' + isLoggedIntoBbSalesforce = false labels: Label[] columns: Column[] organization: Organization salesforceConfig: SalesforceConfig + bbSalesforceConfig: BbSalesforceConfig salesforceMappings: SalesforceMapping[] salesforceMappingsDataSource = new MatTableDataSource([]) salesforceMappingColumns = ['salesforce_fieldname', 'column', 'actions'] @@ -71,12 +75,25 @@ export class SalesforceComponent implements OnDestroy, OnInit { update_at_minute: new FormControl(0, [Validators.min(0), Validators.max(59)]), }), }) + bbSalesforceForm = new FormGroup({ + bb_salesforce_enabled: new FormControl(false), + bbSalesforceConfig: new FormGroup({ + salesforce_url: new FormControl(''), + client_id: new FormControl(''), + client_secret: new FormControl(''), + }), + }) ngOnInit(): void { this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { this.organization = organization this.salesforceForm.get('salesforce_enabled').setValue(this.organization.salesforce_enabled) this._setFormEnabledState(this.organization.salesforce_enabled) + this.bbSalesforceForm.get('bb_salesforce_enabled').setValue(this.organization.bb_salesforce_enabled) + this._setBbFormEnabledState(this.organization.bb_salesforce_enabled) + if (this.organization.bb_salesforce_enabled) { + this._checkBbLoginStatus() + } }) this._salesforceService.config$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((config) => { this.salesforceConfig = config @@ -87,6 +104,15 @@ export class SalesforceComponent implements OnDestroy, OnInit { } } }) + this._bbSalesforceService.config$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((config) => { + this.bbSalesforceConfig = config + for (const field of Object.keys(config)) { + const key = `bbSalesforceConfig.${field}` + if (this.bbSalesforceForm.get(key)) { + this.bbSalesforceForm.get(key).patchValue(config[field]) + } + } + }) this._salesforceService.mappings$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((mappings) => { this.salesforceMappings = mappings this.salesforceMappingsDataSource.data = mappings @@ -233,6 +259,66 @@ export class SalesforceComponent implements OnDestroy, OnInit { } } + toggleBbForm(): void { + const enabled = this.bbSalesforceForm.get('bb_salesforce_enabled').value + this.organization.bb_salesforce_enabled = enabled + this._organizationService.updateSettings(this.organization).subscribe() + this._setBbFormEnabledState(enabled) + if (enabled) { + this._checkBbLoginStatus() + } + } + + toggleBbClientSecret(): void { + this.bbClientSecretHidden = !this.bbClientSecretHidden + } + + bbLogin(): void { + this._bbSalesforceService.getLoginUrl(this.organization.id).subscribe((data) => { + if (data.status === 'error') { + this.testConnectionMessage = data.response || 'Cannot login to Salesforce' + } else if (data.url) { + window.location.href = data.url + } + }) + } + + bbLogout(): void { + this._bbSalesforceService.logout(this.organization.id).subscribe((data) => { + if (data.status === 'success') { + this.isLoggedIntoBbSalesforce = false + } + }) + } + + submitBb(): void { + this.organization.bb_salesforce_enabled = this.bbSalesforceForm.get('bb_salesforce_enabled').value + this._organizationService.updateSettings(this.organization).subscribe() + const configValues = this.bbSalesforceForm.controls.bbSalesforceConfig.value + if (this.bbSalesforceConfig?.id) { + this._bbSalesforceService.update(this.organization.id, { ...this.bbSalesforceConfig, ...configValues }).subscribe((config) => { + this.bbSalesforceConfig = config + }) + } else { + this._bbSalesforceService + .create(this.organization.id, { ...configValues, organization_id: this.organization.id }) + .subscribe((config) => { + this.bbSalesforceConfig = config + }) + } + } + + private _checkBbLoginStatus(): void { + this._bbSalesforceService.verifyToken(this.organization.id).subscribe({ + next: (response) => { + this.isLoggedIntoBbSalesforce = response.valid + }, + error: () => { + this.isLoggedIntoBbSalesforce = false + }, + }) + } + private _setFormEnabledState(enabled: boolean): void { const fg = this.salesforceForm.get('salesforceConfig') as FormGroup for (const field of Object.keys(fg.controls)) { @@ -243,4 +329,15 @@ export class SalesforceComponent implements OnDestroy, OnInit { } } } + + private _setBbFormEnabledState(enabled: boolean): void { + const fg = this.bbSalesforceForm.get('bbSalesforceConfig') as FormGroup + for (const field of Object.keys(fg.controls)) { + if (enabled) { + this.bbSalesforceForm.get(`bbSalesforceConfig.${field}`).enable() + } else { + this.bbSalesforceForm.get(`bbSalesforceConfig.${field}`).disable() + } + } + } }