diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index 2f8aa20..39d9ead 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -121,16 +121,17 @@ definitions: - rm -rf /var/lib/apt/lists/* - export CHROME_BIN=/usr/bin/google-chrome - dotnet tool install --global dotnet-sonarscanner - - dotnet tool install --global JetBrains.dotCover.GlobalTool + - dotnet tool install --global JetBrains.dotCover.CommandLineTools - export PATH="$PATH:/root/.dotnet/tools" - dotnet sonarscanner begin /k:"$SONAR_REPOSITORY" /d:sonar.host.url="$SONAR_URL" /d:sonar.login="$SONAR_TOKEN" /d:sonar.cs.dotcover.reportsPaths=dotCover.Output.html /d:sonar.javascript.lcov.reportPaths=./ClientApp/coverage/lcov-report/lcov.info - dotnet build --no-incremental - - dotnet dotcover test --dcReportType=HTML + - dotCover cover-dotnet --ReportType=HTML --Output=dotCover.Output.html -- test - npm install --prefix ./src/MicrosoftTeamsIntegration.Jira/ClientApp - npm run test:pipeline --no-sandbox --prefix ./src/MicrosoftTeamsIntegration.Jira/ClientApp - dotnet sonarscanner end /d:sonar.login="$SONAR_TOKEN" - | ENCODED_BRANCH=$(echo "$BITBUCKET_BRANCH" | jq -sRr @uri) + sleep 30 # wait for Sonar Quality Gate to process the results STATUS=$(curl -s -u $SONAR_TOKEN: "https://sonar-enterprise.internal.atlassian.com/api/qualitygates/project_status?projectKey=msteams-jira-onprem&branch=$ENCODED_BRANCH" | jq -r .projectStatus.status) if [ "$STATUS" != "OK" ]; then echo "Quality gate failed: https://sonar-enterprise.internal.atlassian.com/dashboard?id=msteams-jira-onprem&branch=$ENCODED_BRANCH&" diff --git a/dataportal.yml b/dataportal.yml index 6c16d62..6166f2b 100644 --- a/dataportal.yml +++ b/dataportal.yml @@ -71,6 +71,8 @@ attributes: - 'createIssueCommentModal' - 'addIssueCommentModal' - 'connectToJira' + - 'configurePersonalNotificationsModal' + - 'configureChannelNotificationsModal' track: - id: 24765 action: completed @@ -151,6 +153,40 @@ track: - 'groupChat' - 'channel' - 'personal' + - id: 82676 + action: processed + subject: notification + library: server + description: | + Track information about processed notifications + attributes: + - name: notificationEventType + required: true + description: | + Shows the type of notification event + enumeration: + - 'personal' + - 'channel' + - id: 82677 + action: processingFailed + subject: notification + library: server + description: | + Track information about failed to process notifications + attributes: + - name: notificationEventType + required: true + description: | + Shows the type of notification event + enumeration: + - 'personal' + - 'channel' + - id: 82678 + action: processing + subject: notification + library: server + description: | + Track information about all notifications that needs to be processed screen: - id: 25484 @@ -219,6 +255,18 @@ screen: attributes: - name: application - name: source + - id: 82686 + name: configurePersonalNotificationsModal + description: Open the configure personal notifications modal + attributes: + - name: application + - name: source + - id: 82687 + name: configureChannelNotificationsModal + description: Open the configure channel notifications modal + attributes: + - name: application + - name: source ui: - id: 25297 @@ -356,6 +404,59 @@ ui: type: string description: The error message accompanying the erroneous result. required: false - - - + - id: 82679 + action: clicked + subject: button + subjectId: updatePersonalNotification + description: | + The user clicked on the update notification button + attributes: + - name: source + - id: 82680 + action: clicked + subject: button + subjectId: createPersonalNotification + description: | + The user clicked on the create notification button + attributes: + - name: source + - id: 82681 + action: clicked + subject: button + subjectId: updateChannelNotification + description: | + The user clicked on the update channel notification button + attributes: + - name: source + - id: 82682 + action: clicked + subject: button + subjectId: createChannelNotification + description: | + The user clicked on the create channel notification button + attributes: + - name: source + - id: 82683 + action: clicked + subject: button + subjectId: deleteChannelNotification + description: | + The user clicked on the delete channel notification button + attributes: + - name: source + - id: 82684 + action: clicked + subject: button + subjectId: muteChannelNotification + description: | + The user clicked on the mute channel notification button + attributes: + - name: source + - id: 82685 + action: clicked + subject: button + subjectId: unmuteChannelNotification + description: | + The user clicked on the unmute channel notification button + attributes: + - name: source \ No newline at end of file diff --git a/manifests/server/development/manifest.json b/manifests/server/development/manifest.json index 5970c9e..7823d79 100644 --- a/manifests/server/development/manifest.json +++ b/manifests/server/development/manifest.json @@ -78,8 +78,8 @@ ], "commands": [ { - "title": "assign", - "description": "Assign issue to yourself" + "title": "notifications", + "description": "Set up notifications for Jira issues" }, { "title": "vote", @@ -110,6 +110,10 @@ "title": "assign", "description": "Assign issue to yourself" }, + { + "title": "notifications", + "description": "Set up personal notifications for Jira issues" + }, { "title": "watch", "description": "Watch issue" diff --git a/manifests/server/integration/manifest.json b/manifests/server/integration/manifest.json index 49a086e..370c64c 100644 --- a/manifests/server/integration/manifest.json +++ b/manifests/server/integration/manifest.json @@ -78,8 +78,8 @@ ], "commands": [ { - "title": "assign", - "description": "Assign issue to yourself" + "title": "notifications", + "description": "Set up notifications for Jira issues" }, { "title": "vote", @@ -110,6 +110,10 @@ "title": "assign", "description": "Assign issue to yourself" }, + { + "title": "notifications", + "description": "Set up personal notifications for Jira issues" + }, { "title": "watch", "description": "Watch issue" diff --git a/manifests/server/production/manifest.json b/manifests/server/production/manifest.json index c451fc2..3e92ea4 100644 --- a/manifests/server/production/manifest.json +++ b/manifests/server/production/manifest.json @@ -90,8 +90,8 @@ ], "commands": [ { - "title": "assign", - "description": "Assign issue to yourself" + "title": "notifications", + "description": "Set up notifications for Jira issues" }, { "title": "vote", @@ -124,6 +124,10 @@ "title": "assign", "description": "Assign issue to yourself" }, + { + "title": "notifications", + "description": "Set up personal notifications for Jira issues" + }, { "title": "watch", "description": "Watch issue" diff --git a/manifests/server/staging/manifest.json b/manifests/server/staging/manifest.json index 28f2e5c..e74277b 100644 --- a/manifests/server/staging/manifest.json +++ b/manifests/server/staging/manifest.json @@ -77,8 +77,8 @@ ], "commands": [ { - "title": "assign", - "description": "Assign issue to yourself" + "title": "notifications", + "description": "Set up notifications for Jira issues" }, { "title": "vote", @@ -109,6 +109,10 @@ "title": "assign", "description": "Assign issue to yourself" }, + { + "title": "notifications", + "description": "Set up personal notifications for Jira issues" + }, { "title": "watch", "description": "Watch issue" diff --git a/src/MicrosoftTeamsIntegration.Artifacts/MicrosoftTeamsIntegration.Artifacts.csproj b/src/MicrosoftTeamsIntegration.Artifacts/MicrosoftTeamsIntegration.Artifacts.csproj index f958fff..45170e0 100644 --- a/src/MicrosoftTeamsIntegration.Artifacts/MicrosoftTeamsIntegration.Artifacts.csproj +++ b/src/MicrosoftTeamsIntegration.Artifacts/MicrosoftTeamsIntegration.Artifacts.csproj @@ -1,4 +1,4 @@ - + 255.255.255 @@ -11,29 +11,29 @@ - - + + - - + + - - - - - - - - - - - - + + + + + + + + + + + + - + @@ -43,12 +43,12 @@ all runtime; build; native; contentfiles; analyzers - runtime; build; native; contentfiles; analyzers; buildtransitive + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + diff --git a/src/MicrosoftTeamsIntegration.Jira/BotMessages.cs b/src/MicrosoftTeamsIntegration.Jira/BotMessages.cs index 21f8ed4..11d3fac 100644 --- a/src/MicrosoftTeamsIntegration.Jira/BotMessages.cs +++ b/src/MicrosoftTeamsIntegration.Jira/BotMessages.cs @@ -12,6 +12,7 @@ public static class BotMessages public const string ConnectDialogCardTitle = "In order to use bot, please connect it first and rerun the command."; public const string JiraDisconnectDialogConfirmPrompt = "Are you sure you want to disconnect?"; + public const string JiraDisconnectDialogConfirmPromptWithNotificationSubscriptions = "Are you sure you want to disconnect? You will stop receiving notifications from Jira."; public const string JiraDisconnectDialogNotConnected = "You are not connected to any Jira at the moment."; } } diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/package.json b/src/MicrosoftTeamsIntegration.Jira/ClientApp/package.json index ca14ce9..0ab4df1 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/package.json +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/package.json @@ -14,33 +14,33 @@ }, "private": true, "dependencies": { - "@angular/animations": "19.0.6", - "@angular/cdk": "19.0.5", - "@angular/cli": "19.0.7", - "@angular/common": "19.0.6", - "@angular/compiler": "19.0.6", - "@angular/compiler-cli": "19.0.6", - "@angular/core": "19.0.6", - "@angular/forms": "19.0.6", - "@angular/material": "19.0.5", - "@angular/material-moment-adapter": "19.0.5", - "@angular/platform-browser": "19.0.6", - "@angular/platform-browser-dynamic": "19.0.6", - "@angular/router": "19.0.6", - "@microsoft/applicationinsights-web": "^3.3.0", + "@angular/animations": "19.2.6", + "@angular/cdk": "19.2.9", + "@angular/cli": "19.2.7", + "@angular/common": "19.2.6", + "@angular/compiler": "19.2.6", + "@angular/compiler-cli": "19.2.6", + "@angular/core": "19.2.6", + "@angular/forms": "19.2.6", + "@angular/material": "19.2.9", + "@angular/material-moment-adapter": "19.2.9", + "@angular/platform-browser": "19.2.6", + "@angular/platform-browser-dynamic": "19.2.6", + "@angular/router": "19.2.6", + "@microsoft/applicationinsights-web": "^3.3.6", "@microsoft/teams-js": "2.18.0", - "@ng-select/ng-select": "14.2.0", + "@ng-select/ng-select": "14.2.8", "angular2-spinner": "1.0.10", - "compare-versions": "^4.1.3", + "compare-versions": "^6.1.1", "moment": "2.30.1", - "ng-click-outside2": "16.0.0", + "ng-click-outside2": "17.0.0", "placeholder-loading": "^0.7.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", + "rxjs": "7.8.2", + "tslib": "2.8.1", "zone.js": "~0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "19.0.7", + "@angular-devkit/build-angular": "19.2.6", "@angular-eslint/builder": "18.2.0", "@angular-eslint/eslint-plugin": "18.2.0", "@types/jasmine": "~3.6.0", @@ -65,5 +65,8 @@ "protractor": "~7.0.0", "ts-node": "10.9.2", "typescript": "~5.5.4" + }, + "overrides": { + "@nevware21/ts-utils": "0.11.8" } } diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app-routing.module.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app-routing.module.ts index 5c40972..3be6707 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app-routing.module.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app-routing.module.ts @@ -11,7 +11,9 @@ import { ConnectJiraComponent, CreateIssueDialogComponent, EditIssueDialogComponent, CreateCommentDialogComponent, - CommentIssueDialogComponent, SignoutDialogComponent + CommentIssueDialogComponent, SignoutDialogComponent, + ConfigurePersonalNotificationsDialogComponent, + ConfigureChannelNotificationsDialogComponent } from '@app/components'; import { AuthGuard } from '@core/guards/auth.guard'; @@ -79,6 +81,16 @@ export const routes: Routes = [ path: 'issues/commentIssue', component: CommentIssueDialogComponent, canActivate: [(route: ActivatedRouteSnapshot) => inject(AuthGuard).canActivate(route)] + }, + { + path: 'notifications/configure-personal', + component: ConfigurePersonalNotificationsDialogComponent, + canActivate: [(route: ActivatedRouteSnapshot) => inject(AuthGuard).canActivate(route)] + }, + { + path: 'notifications/configure-channel', + component: ConfigureChannelNotificationsDialogComponent, + canActivate: [(route: ActivatedRouteSnapshot) => inject(AuthGuard).canActivate(route)] } ]; diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app.module.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app.module.ts index 044b62f..352eea1 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app.module.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/app.module.ts @@ -38,7 +38,8 @@ import { IssueCommentComponent, NewCommentComponent, IssuesTableMobileComponent, ConnectJiraComponent, CreateCommentDialogComponent, IssueDetailsComponent, SignoutMaterialDialogComponent, SignoutDialogComponent, - CommentIssueDialogComponent + CommentIssueDialogComponent, ConfigurePersonalNotificationsDialogComponent, + ConfigureChannelNotificationsDialogComponent } from '@app/components'; import { DynamicFieldsDirective } from './components/issues/fields/dynamic-fields.directive'; import { SelectFieldComponent } from './components/issues/fields/select-field.component'; @@ -58,6 +59,8 @@ import { SelectCascadingFieldComponent } from './components/issues/fields/select import { MatSnackBarModule } from '@angular/material/snack-bar'; import { SuccessSnackbarComponent } from './components/snack-bars/success-bar/success-snackbar.component'; import { ErrorSnackbarComponent } from './components/snack-bars/error-bar/error-snackbar.component'; +import { MatMenuModule } from '@angular/material/menu'; + @NgModule({ declarations: [ AppComponent, @@ -80,6 +83,8 @@ import { ErrorSnackbarComponent } from './components/snack-bars/error-bar/error- ConnectJiraComponent, CreateCommentDialogComponent, CommentIssueDialogComponent, + ConfigurePersonalNotificationsDialogComponent, + ConfigureChannelNotificationsDialogComponent, IssueDetailsComponent, SignoutMaterialDialogComponent, SignoutDialogComponent, @@ -124,7 +129,8 @@ bootstrap: [AppComponent], imports: [BrowserAnimationsModule, SharedModule, MatSnackBarModule, MatExpansionPanel, - MatExpansionPanelTitle], providers: [ + MatExpansionPanelTitle, + MatMenuModule], providers: [ { provide: MAT_MOMENT_DATE_ADAPTER_OPTIONS, useValue: { useUtc: true } }, provideHttpClient(withInterceptorsFromDi()) ] }) diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.html b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.html index 997bbf7..739ee72 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.html +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.html @@ -1,17 +1,20 @@
-

Please enter Jira ID to connect to your Jira Data Center instance.

+

Please enter Jira base URL or Jira ID to connect to your Jira Data Center instance.

+

+ Jira base URL is the foundation part of your Jira instance address +

Jira ID is a unique identifier generated by Jira Data Center for Microsoft Teams app installed for Jira. It can be found in app settings after installation on Jira Data Center and should be provided to users by Jira admin. - Learn more. + Learn more.

This value is required @@ -26,11 +29,7 @@
- - Please check if - {{ jiraServerId }} - is valid Jira unique ID and Jira Data Center for Microsoft Teams app for your organization is installed. - +
diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.ts index 9568f80..f2754e4 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/connect-jira/connect-jira.component.ts @@ -53,6 +53,7 @@ export class ConnectJiraComponent implements OnInit { public loading = false; public showLoginForm = false; public errorMessage: string | undefined; + public addonStatusErrorMessage: string | undefined; public addonVersion = ''; public jiraAuthUrl = ''; public authClicked = false; @@ -93,11 +94,26 @@ export class ConnectJiraComponent implements OnInit { {source: 'connectToJira'}); try { - const jiraId: string = this.jiraId.value; - this.jiraServerId = jiraId; + let jiraId: string = this.jiraId.value; localStorage.setItem(ConnectJiraComponent.JIRA_ID_STORAGE_KEY, jiraId); + if (this.isValidUrl(jiraId)) { + try { + jiraId = await this.apiService.getJiraId(jiraId); + } catch (error) { + // eslint-disable-next-line max-len + this.addonStatusErrorMessage = `Jira Data Center is not found or not accessible. Please try to visit the URL to get Jira ID: ${jiraId}/plugins/servlet/teams/getJiraServerId or visit documentation for more details`; + this.showAddonStatusError = true; + return; + } + } + + this.jiraServerId = jiraId; + + this.addonStatusErrorMessage = `Please check if ${this.jiraServerId} is valid Jira unique ID or Jira base URL + and Jira Data Center for Microsoft Teams app for your organization is installed.`; + const { addonStatus, addonVersion } = await this.apiService.getAddonStatus(jiraId); const addonIsInstalled = addonStatus === AddonStatus.Installed || addonStatus === AddonStatus.Connected; const userExists = addonStatus === AddonStatus.Connected; @@ -138,6 +154,15 @@ export class ConnectJiraComponent implements OnInit { } } + isValidUrl(urlString: string): boolean { + try { + new URL(urlString); + return true; + } catch (err) { + return false; + } + } + public async onSubmitLoginForm(): Promise { this.showAddonStatusError = false; this.loadingIndicatorService.show(); diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/index.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/index.ts index 7ac123c..5f7cd64 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/index.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/index.ts @@ -22,3 +22,5 @@ export * from '@app/components/issues/create-comment-dialog/create-comment-dialo export * from '@app/components/settings/signout-dialog/signout-dialog.component'; export * from '@app/components/issues/issue-details/issue-details.component'; export * from '@app/components/issues/comment-issue-dialog/comment-issue-dialog.component'; +export * from '@app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component'; +export * from '@app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component'; diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.html b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.html index 08cc92f..e6dc0e9 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.html +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.html @@ -5,7 +5,7 @@ Sign out

-

Are you sure you want to sign out?

+

{{signOutMessage}}

diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.spec.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.spec.ts index 9de859d..38c4a07 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.spec.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.spec.ts @@ -1,104 +1,71 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SignoutMaterialDialogComponent } from './signout-material-dialog.component'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { ApiService, AuthService, UtilService, AppInsightsService } from '@core/services'; -import { of, throwError } from 'rxjs'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; - -describe('SignoutMaterialDialogComponent', () => { - let component: SignoutMaterialDialogComponent; - let fixture: ComponentFixture; - let apiService: jasmine.SpyObj; - let authService: jasmine.SpyObj; - let utilService: jasmine.SpyObj; - let appInsightsService: jasmine.SpyObj; - let dialogRef: jasmine.SpyObj>; - - beforeEach(async () => { - const apiServiceSpy = jasmine.createSpyObj('ApiService', ['logOut']); - const authServiceSpy = jasmine.createSpyObj('AuthService', ['']); - const utilServiceSpy = jasmine.createSpyObj('UtilService', ['convertStringToNull']); - const appInsightsServiceSpy = jasmine.createSpyObj('AppInsightsService', ['trackException']); - const dialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['close']); - - await TestBed.configureTestingModule({ - declarations: [SignoutMaterialDialogComponent], - providers: [ - { provide: ApiService, useValue: apiServiceSpy }, - { provide: AuthService, useValue: authServiceSpy }, - { provide: UtilService, useValue: utilServiceSpy }, - { provide: AppInsightsService, useValue: appInsightsServiceSpy }, - { provide: MatDialogRef, useValue: dialogRefSpy }, - { provide: MAT_DIALOG_DATA, useValue: { jiraUrl: 'http://example.com' } } - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }).compileComponents(); - - fixture = TestBed.createComponent(SignoutMaterialDialogComponent); - component = fixture.componentInstance; - apiService = TestBed.inject(ApiService) as jasmine.SpyObj; - authService = TestBed.inject(AuthService) as jasmine.SpyObj; - utilService = TestBed.inject(UtilService) as jasmine.SpyObj; - appInsightsService = TestBed.inject(AppInsightsService) as jasmine.SpyObj; - dialogRef = TestBed.inject(MatDialogRef) as jasmine.SpyObj>; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize correctly', async () => { - utilService.convertStringToNull.and.returnValue('http://example.com'); - - await component.ngOnInit(); - - expect(utilService.convertStringToNull).toHaveBeenCalledWith('http://example.com'); - }); - - it('should handle error during initialization', async () => { - const error = new Error('Test error'); - utilService.convertStringToNull.and.throwError(error); - - await component.ngOnInit(); +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. - expect(appInsightsService.trackException).toHaveBeenCalledWith(new Error('Error: Test error'), - 'SignoutMaterialDialogComponent::ngOnInit'); - }); - - it('should handle any error during initialization', async () => { - const error = { status: 401 }; - utilService.convertStringToNull.and.throwError(error as any); - - await component.ngOnInit(); - - expect(dialogRef.close).toHaveBeenCalledWith(false); - }); - - it('should sign out successfully', async () => { - utilService.convertStringToNull.and.returnValue('http://example.com'); - apiService.logOut.and.returnValue(Promise.resolve({ isSuccess: true })); - - await component.ngOnInit(); - await component.signOut(); - - expect(apiService.logOut).toHaveBeenCalledWith('http://example.com'); - expect(dialogRef.close).toHaveBeenCalledWith(true); - }); - - it('should handle error during sign out', async () => { - const error = new Error('Test error'); - apiService.logOut.and.returnValue(Promise.reject(error)); - utilService.convertStringToNull.and.returnValue('http://example.com'); - - await component.ngOnInit(); - await component.signOut(); - - expect(apiService.logOut).toHaveBeenCalledWith('http://example.com'); - expect(appInsightsService.trackException).toHaveBeenCalledWith( - new Error('Error while signout from Jira'), - 'SignoutMaterialDialogComponent::signOut', - { originalErrorMessage: error.message } - ); - expect(dialogRef.close).toHaveBeenCalledWith(true); - }); -}); +import { Component, OnInit, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + ApiService, + AuthService, + UtilService, + AppInsightsService, +} from '@core/services'; + +@Component({ + selector: 'app-signout', + templateUrl: './signout-material-dialog.component.html', + styleUrls: ['./signout-material-dialog.component.scss'], + standalone: false +}) +export class SignoutMaterialDialogComponent implements OnInit { + + private jiraUrl: string | undefined; + + public isSigningOut = false; + public signOutMessage = 'Are you sure you want to sign out?'; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: any, + public dialogRef: MatDialogRef, + private authService: AuthService, + private apiService: ApiService, + private utilService: UtilService, + private appInsightsService: AppInsightsService + ) { } + + public async ngOnInit(): Promise { + try { + this.jiraUrl = this.utilService.convertStringToNull(this.data.jiraUrl); + this.signOutMessage = + await this.apiService.getNotificationSettings(this.jiraUrl as string) ? + 'Are you sure you want to sign out and stop receiving notifications from Jira?' : + 'Are you sure you want to sign out?'; + } catch (error) { + this.appInsightsService.trackException( + new Error(error as any), + 'SignoutMaterialDialogComponent::ngOnInit' + ); + + if ((error as any).status && (error as any).status === 401) { + this.dialogRef.close(false); + } + } + } + + public async signOut(): Promise { + try { + this.isSigningOut = true; + // remove personal subscription if exists + await this.apiService.removePersonalNotification(this.jiraUrl as string); + await this.apiService.logOut(this.jiraUrl as string); + } catch (error) { + this.appInsightsService.trackException( + new Error('Error while signout from Jira'), + 'SignoutMaterialDialogComponent::signOut', + { originalErrorMessage: (error as any).message } + ); + } finally { + this.isSigningOut = false; + this.dialogRef.close(true); + } + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.ts index 84addbd..38c4a07 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/issues/signout-material-dialog/signout-material-dialog.component.ts @@ -21,6 +21,7 @@ export class SignoutMaterialDialogComponent implements OnInit { private jiraUrl: string | undefined; public isSigningOut = false; + public signOutMessage = 'Are you sure you want to sign out?'; constructor( @Inject(MAT_DIALOG_DATA) public data: any, @@ -34,6 +35,10 @@ export class SignoutMaterialDialogComponent implements OnInit { public async ngOnInit(): Promise { try { this.jiraUrl = this.utilService.convertStringToNull(this.data.jiraUrl); + this.signOutMessage = + await this.apiService.getNotificationSettings(this.jiraUrl as string) ? + 'Are you sure you want to sign out and stop receiving notifications from Jira?' : + 'Are you sure you want to sign out?'; } catch (error) { this.appInsightsService.trackException( new Error(error as any), @@ -49,6 +54,8 @@ export class SignoutMaterialDialogComponent implements OnInit { public async signOut(): Promise { try { this.isSigningOut = true; + // remove personal subscription if exists + await this.apiService.removePersonalNotification(this.jiraUrl as string); await this.apiService.logOut(this.jiraUrl as string); } catch (error) { this.appInsightsService.trackException( diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.html b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.html new file mode 100644 index 0000000..968060a --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.html @@ -0,0 +1,214 @@ +
+
+
+
+ +

To start receiving Jira Data Center notifications in this channel/chat please click "Add new" button to create new configuration

+
+
+ +
+ +
+
+

Customise the type of project notifications you get from

+ +

Jira Data Center ID

+ + +

Jira project

+ + +

Type

+ + +

Priority

+ + +

Status

+ + +
+
+

Issue is

+ +
+
+

Comment is

+ +
+
+ +
+ +
+ +
+
+

You have {{notifications.length}} notification(s) configured for this channel/chat

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project{{notification.projectName}}CriteriaEvents: {{notification.eventTypes.join(', ')}}Jira Data Center ID{{notification.jiraId}} + + + + + + + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.scss b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.scss new file mode 100644 index 0000000..6521f86 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.scss @@ -0,0 +1,131 @@ +@use "../../../../styles/app-colors"; + +.dialog { + margin-left: 3.2rem; + margin-right: 3.2rem; + margin-top: 0.2rem; + + &__content { + overflow: auto; + max-height: calc(100vh - 80px); + + // styles to make scrollbar shifted to the right + padding-right: 3.2rem; + margin-right: -3rem; + padding-bottom: 9rem; + } + + .field-group { + width: 100%; + + &__jiraId-textarea { + height: 3.2rem; + overflow: hidden; + } + } + + .field-label { + font-size: 12px; + font-weight: lighter; + margin-bottom: 0.8rem; + } + + &__footer { + position: absolute; + bottom: 3.2rem; + right: 3.2rem; + } +} + +.initial-container { + text-align: center; + font-weight: bold; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 400px; + font-size: 14px !important; + font-style: normal !important; + + img { + max-height: calc(100% - 80px) !important; + } +} + +.mat-mdc-header-row { + height: 10px !important; + padding: 0 0 0 0 !important; +} + +.mat-mdc-table mat-header-row.mat-mdc-header-row { + font-size: 12px !important; +} + +.mat-mdc-table { + background-color: inherit; + overflow-x: hidden; +} + +.mat-mdc-cell { + height: 16px !important; + top: 0; + left: 0; + right: 0; + padding-bottom: 5px; + padding-top: 5px; +} + +::ng-deep .dark { + .mat-mdc-menu-panel { + background-color: app-colors.$app-gray-10-dark !important; + } + .mat-mdc-menu-item{ + color: app-colors.$white !important; + } + .mat-mdc-header-cell { + color: app-colors.$white; + } + .mat-mdc-cell { + color: app-colors.$white; + } +} + +::ng-deep .contrast { + .mat-mdc-menu-content { + background-color: app-colors.$black !important; + + } + .mat-mdc-menu-item{ + color: app-colors.$white; + } + .mat-mdc-header-cell { + color: app-colors.$white; + } + .mat-mdc-cell { + color: app-colors.$white; + } +} + +::ng-deep .mat-mdc-menu-item { + font-size: 14px !important; + min-height: 0 !important; + min-width: 0 !important; + --mat-menu-item-label-text-size: 14px; +} + +.buttons { + &__button { + width: auto; + height: 32px; + } + + .buttons__button:first-of-type { + margin-right: 15px; + } +} + +.template .ph-item { + margin-bottom: 0px !important; + padding: 15px !important; +} \ No newline at end of file diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.spec.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.spec.ts new file mode 100644 index 0000000..47c9f73 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.spec.ts @@ -0,0 +1,296 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ConfigureChannelNotificationsDialogComponent } from './configure-channel-notifications-dialog.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApiService, AppInsightsService, UtilService } from '@core/services'; +import { AnalyticsService } from '@core/services/analytics.service'; +import { NotificationService } from '@shared/services/notificationService'; +import { DropdownUtilService } from '@shared/services/dropdown.util.service'; +import { IssueTransitionService } from '@core/services/entities/transition.service'; +import { of } from 'rxjs'; +import { NotificationSubscription, SubscriptionType } from '@core/models/NotificationSubscription'; +import { NotificationSubscriptionEvent, NotificationSubscriptionAction } from '@core/models/NotificationSubscriptionEvent'; +import { Project } from '@core/models'; +import * as microsoftTeams from '@microsoft/teams-js'; + +describe('ConfigureChannelNotificationsDialogComponent', () => { + let component: ConfigureChannelNotificationsDialogComponent; + let fixture: ComponentFixture; + let mockApiService: jasmine.SpyObj; + let mockNotificationService: jasmine.SpyObj; + let mockUtilsService: jasmine.SpyObj; + let mockAppInsightsService: jasmine.SpyObj; + let mockTransitionService: jasmine.SpyObj; + let mockDropdownUtilService: jasmine.SpyObj; + let mockRouter: jasmine.SpyObj; + let mockActivatedRoute: any; + let mockAnalyticsService: jasmine.SpyObj; + + const mockNotificationSubscription: NotificationSubscription = { + jiraId: 'test-jira-id', + subscriptionType: SubscriptionType.Channel, + eventTypes: ['IssueCreated', 'CommentCreated'], + isActive: true, + filter: 'project = TEST AND type in ("Bug")', + microsoftUserId: '', + conversationId: 'test-conversation-id', + conversationReferenceId: 'test-reference-id', + projectId: 'TEST', + projectName: 'Test Project' + }; + + const mockProject: Project = { + id: 'TEST', + key: 'TEST', + name: 'Test Project', + projectTypeKey: 'software', + issueTypes: [], + avatarUrls: { + '16x16': '', + '24x24': '', + '32x32': '', + '48x48': '' + }, + simplified: false + }; + + const mockIssueType = { + id: '1', + name: 'Bug', + fields: [], + description: '', + iconUrl: '' + }; + + const mockStatuses = [ + { + id: '1', + name: 'To Do', + description: 'Task is not started', + iconUrl: 'https://example.com/todo.png' + }, + { + id: '2', + name: 'In Progress', + description: 'Task is being worked on', + iconUrl: 'https://example.com/inprogress.png' + }, + { + id: '3', + name: 'Done', + description: 'Task is completed', + iconUrl: 'https://example.com/done.png' + } + ]; + + beforeEach(async () => { + spyOn(microsoftTeams.dialog.url, 'submit').and.callFake(() => {}); + + mockApiService = jasmine.createSpyObj('ApiService', [ + 'getAllNotificationsByConversationId', + 'getProjects', + 'getCreateMetaIssueTypes', + 'getCreateMetaFields', + 'updateNotification', + 'addNotification', + 'deleteNotification', + 'sendNotificationSubscriptionEvent', + 'getAddonStatus', + 'getStatusesByProject' + ]); + + mockNotificationService = jasmine.createSpyObj('NotificationService', [ + 'notifySuccess', + 'notifyError' + ]); + + mockUtilsService = jasmine.createSpyObj('UtilService', [ + 'isAddonUpdatedToVersion', + 'getMinAddonVersionForNotifications', + 'getUpgradeAddonMessageForNotifications', + 'getMsTeamsContext' + ]); + + mockAppInsightsService = jasmine.createSpyObj('AppInsightsService', ['logNavigation']); + mockTransitionService = jasmine.createSpyObj('IssueTransitionService', ['getTransitionsByProjectKeyOrId']); + mockDropdownUtilService = jasmine.createSpyObj('DropdownUtilService', [ + 'mapProjectToDropdownOption', + 'mapIssueTypeToDropdownOption', + 'mapPriorityToDropdownOption', + 'mapTransitionToDropdownOptionString', + 'mapStatusToDropdownOption' + ]); + + mockAnalyticsService = jasmine.createSpyObj('AnalyticsService', [ + 'sendScreenEvent', + 'sendUiEvent' + ]); + + mockRouter = jasmine.createSpyObj('Router', ['navigate']); + + const mockSnackBarRef = jasmine.createSpyObj('MatSnackBarRef', ['afterDismissed']); + mockSnackBarRef.afterDismissed.and.returnValue(of({ dismissedByAction: false })); + + mockNotificationService.notifySuccess.and.returnValue(mockSnackBarRef); + mockNotificationService.notifyError.and.returnValue(mockSnackBarRef); + + mockUtilsService.isAddonUpdatedToVersion.and.returnValue(true); + mockUtilsService.getMsTeamsContext.and.returnValue({ + tid: 'test-tenant-id', + loginHint: 'test-login-hint', + userObjectId: 'test-user-id', + locale: 'en-US' + }); + mockApiService.getAddonStatus.and.returnValue(Promise.resolve({ addonStatus: 1, addonVersion: '1.0.0' })); + + mockActivatedRoute = { + snapshot: { + params: { + jiraId: 'test-jira-id', + microsoftUserId: '', + conversationId: 'test-conversation-id', + conversationReferenceId: 'test-reference-id' + } + } + }; + + // Setup mock implementations for dropdown mapping methods + mockDropdownUtilService.mapStatusToDropdownOption.and.callFake((status: any) => ({ + id: status.id, + value: status.id, + label: status.name + })); + + mockDropdownUtilService.mapProjectToDropdownOption.and.callFake((project: any) => ({ + id: project.id, + value: project.id, + label: project.name + })); + + mockDropdownUtilService.mapIssueTypeToDropdownOption.and.callFake((issueType: any) => ({ + id: issueType.id, + value: issueType.id, + label: issueType.name + })); + + await TestBed.configureTestingModule({ + declarations: [ConfigureChannelNotificationsDialogComponent], + providers: [ + { provide: ApiService, useValue: mockApiService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: UtilService, useValue: mockUtilsService }, + { provide: AppInsightsService, useValue: mockAppInsightsService }, + { provide: IssueTransitionService, useValue: mockTransitionService }, + { provide: DropdownUtilService, useValue: mockDropdownUtilService }, + { provide: Router, useValue: mockRouter }, + { provide: AnalyticsService, useValue: mockAnalyticsService } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ConfigureChannelNotificationsDialogComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should initialize component with route parameters and display notifications list', async () => { + mockApiService.getAllNotificationsByConversationId.and.returnValue(Promise.resolve([mockNotificationSubscription])); + + await component.ngOnInit(); + + expect(component.jiraId).toBe('test-jira-id'); + expect(component.conversationId).toBe('test-conversation-id'); + expect(component.conversationReferenceId).toBe('test-reference-id'); + expect(component.showNotificationsListGroup).toBeTrue(); + expect(mockAppInsightsService.logNavigation).toHaveBeenCalled(); + expect(mockAnalyticsService.sendScreenEvent).toHaveBeenCalled(); + }); + + it('should display initial container when no notifications exist', async () => { + mockApiService.getAllNotificationsByConversationId.and.returnValue(Promise.resolve([])); + + await component.ngOnInit(); + + expect(component.showInitialContainer).toBeTrue(); + expect(component.showNotificationsListGroup).toBeFalse(); + }); + }); + + describe('createForm', () => { + it('should create form with default values', async () => { + mockApiService.getProjects.and.returnValue(Promise.resolve([mockProject])); + mockApiService.getCreateMetaIssueTypes.and.returnValue(Promise.resolve([mockIssueType])); + mockApiService.getStatusesByProject.and.returnValue(Promise.resolve(mockStatuses)); + mockTransitionService.getTransitionsByProjectKeyOrId.and.returnValue(Promise.resolve([{ + expand: 'transitions', + transitions: [] + }])); + + await component.displayCreateNotificationsGroup(); + + expect(component.issueForm).toBeDefined(); + expect(component.issueForm.get('project')).toBeDefined(); + expect(component.issueForm.get('issuetype')).toBeDefined(); + }); + + it('should handle project selection and update issue types', async () => { + mockApiService.getProjects.and.returnValue(Promise.resolve([mockProject])); + mockApiService.getCreateMetaIssueTypes.and.returnValue(Promise.resolve([mockIssueType])); + mockApiService.getStatusesByProject.and.returnValue(Promise.resolve(mockStatuses)); + + await component.onProjectSelected('TEST'); + + expect(mockDropdownUtilService.mapStatusToDropdownOption).toHaveBeenCalled(); + expect(component.availableIssueTypesOptions.length).toBeGreaterThan(0); + expect(component.statusesOptions.length).toBeGreaterThan(0); + }); + + it('should handle error when fetching statuses', async () => { + mockApiService.getProjects.and.returnValue(Promise.resolve([mockProject])); + mockApiService.getCreateMetaIssueTypes.and.returnValue(Promise.resolve([mockIssueType])); + mockApiService.getStatusesByProject.and.returnValue(Promise.reject('Error')); + + await component.onProjectSelected('TEST'); + + expect(mockNotificationService.notifyError).toHaveBeenCalledWith('Failed to fetch statuses. Please try again later.'); + }); + }); + + describe('deleteNotification', () => { + it('should delete notification successfully', async () => { + mockApiService.deleteNotification.and.returnValue(Promise.resolve()); + mockApiService.sendNotificationSubscriptionEvent.and.returnValue(Promise.resolve()); + + await component.deleteNotification(mockNotificationSubscription); + + expect(mockNotificationService.notifySuccess).toHaveBeenCalledWith('Notification deleted successfully.'); + expect(mockAnalyticsService.sendUiEvent).toHaveBeenCalled(); + }); + }); + + describe('toggleNotification', () => { + it('should toggle notification status successfully', async () => { + mockApiService.updateNotification.and.returnValue(Promise.resolve()); + + await component.toggleNotification(mockNotificationSubscription); + + expect(mockApiService.updateNotification).toHaveBeenCalled(); + expect(mockNotificationService.notifySuccess).toHaveBeenCalled(); + expect(mockAnalyticsService.sendUiEvent).toHaveBeenCalled(); + }); + }); + + describe('checkAddonUpdated', () => { + it('should show error when addon is not updated', async () => { + mockUtilsService.isAddonUpdatedToVersion.and.returnValue(false); + mockUtilsService.getUpgradeAddonMessageForNotifications.and.returnValue('Please upgrade addon'); + + await component['checkAddonUpdated'](); + + expect(mockNotificationService.notifyError).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.ts new file mode 100644 index 0000000..098c28a --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-channel-notifications-dialog/configure-channel-notifications-dialog.component.ts @@ -0,0 +1,560 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { IssueType, Project } from '@core/models'; +import { DropDownOption } from '@shared/models/dropdown-option.model'; +import { ApiService, AppInsightsService, UtilService } from '@core/services'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DropdownUtilService } from '@shared/services/dropdown.util.service'; +import { NotificationService } from '@shared/services/notificationService'; +import { DropDownComponent } from '@shared/components/dropdown/dropdown.component'; +import { IssueTransitionService } from '@core/services/entities/transition.service'; +import { SelectOption } from '@shared/models/select-option.model'; +import { NotificationSubscription, SubscriptionType } from '@core/models/NotificationSubscription'; +import * as microsoftTeams from '@microsoft/teams-js'; +import { AnalyticsService, EventAction, UiEventSubject } from '@core/services/analytics.service'; +import { NotificationSubscriptionEvent, NotificationSubscriptionAction } from '@core/models/NotificationSubscriptionEvent'; + +@Component({ + selector: 'app-configure-channel-notifications-dialog', + templateUrl: './configure-channel-notifications-dialog.component.html', + styleUrls: ['./configure-channel-notifications-dialog.component.scss'], + standalone: false +}) +export class ConfigureChannelNotificationsDialogComponent implements OnInit { + // Loading and view state + public loading = false; + public isFetchingProjects = false; + public submitting = false; + public showInitialContainer = false; + public showCreateNotificationsGroup = false; + public showNotificationsListGroup = false; + + public issueForm: UntypedFormGroup | any; + + // Project related + public projects: Project[] | any; + public availableProjectsOptions: DropDownOption[] = []; + public projectFilteredOptions: DropDownOption[] = []; + private selectedProject: Project | undefined; + @ViewChild('projectsDropdown', { static: false }) projectsDropdown: DropDownComponent | any; + + public fields: any; + public issueTypes: IssueType[] | any; + public availableIssueTypesOptions: DropDownOption[] = []; + private selectedIssueType: IssueType | undefined; + public prioritiesOptions: DropDownOption[] = []; + public statusesOptions: DropDownOption[] = []; + + // Notification options + private notificationOptions: SelectOption[] = [ + { id: 'issueCreated', label: 'Issue Created', value: 'IssueCreated' }, + { id: 'issueUpdated', label: 'Issue Updated', value: 'IssueUpdated' }, + { id: 'commentCreated', label: 'Comment Created', value: 'CommentCreated' }, + { id: 'commentUpdated', label: 'Comment Updated', value: 'CommentUpdated' }, + ]; + + public issueIsSelectOptions: SelectOption[] = [ + { id: 'issueCreated', label: 'Created', value: 'IssueCreated' }, + { id: 'issueUpdated', label: 'Updated', value: 'IssueUpdated' }, + ]; + public issueIsSelectOptionsSelected: SelectOption[] = []; + + public commentIsSelectOptions: SelectOption[] = [ + { id: 'commentCreated', label: 'Created', value: 'CommentCreated' }, + { id: 'commentUpdated', label: 'Updated', value: 'CommentUpdated' }, + ]; + + public commentIsSelectOptionsSelected: SelectOption[] = []; + + + public jiraId: string | any; + public notifications: NotificationSubscription[] | any; + public conversationReferenceId: string | any; + public conversationId: string | any; + + private readonly DEFAULT_UNAVAILABLE_OPTION: DropDownOption = { + id: null, + value: null, + label: 'Unavailable' + }; + + private readonly ALL_OPTION: DropDownOption = { + id: 'all', + value: 'all', + label: 'All' + }; + + constructor( + private apiService: ApiService, + private transitionService: IssueTransitionService, + private route: ActivatedRoute, + private router: Router, + private dropdownUtilService: DropdownUtilService, + private notificationService: NotificationService, + private appInsightsService: AppInsightsService, + private utilService: UtilService, + private analyticsService: AnalyticsService + ) { } + + public async ngOnInit(): Promise { + this.appInsightsService.logNavigation('ConfigureChannelNotifications', this.route); + this.analyticsService.sendScreenEvent( + 'configureChannelNotificationsModal', + EventAction.viewed, + UiEventSubject.taskModule, + 'configureChannelNotificationsModal', {}); + + const { jiraId, conversationReferenceId, conversationId } + = this.route.snapshot.params; + + this.jiraId = jiraId; + this.conversationReferenceId = conversationReferenceId; + this.conversationId = conversationId; + + if (!this.jiraId) { + const response = await this.apiService.getJiraUrlForPersonalScope(); + this.jiraId = response.jiraUrl; + } + + this.notifications = await this.apiService.getAllNotificationsByConversationId(this.jiraId as string, this.conversationId); + + if (this.notifications && this.notifications.length > 0) { + await this.displayNotificationsListGroup(); + } else { + this.displayInitialContainer(); + } + } + + private async createForm(notification?: NotificationSubscription): Promise { + this.loading = true; + + this.projects = (await this.getProjects(this.jiraId as string)) as any; + + await this.checkAddonUpdated(); + + if (!this.projects || this.projects.length === 0) { + const message = 'You don\'t have permission to perform this action'; + await this.router.navigate(['/error'], {queryParams: {message}}); + return; + } + + this.availableProjectsOptions = this.projects.map(this.dropdownUtilService.mapProjectToDropdownOption); + this.projectFilteredOptions = this.availableProjectsOptions; + + let defaultProjectOption: string = this.availableProjectsOptions[0]?.value || ''; + let defaultIssueTypeOption: string = this.availableIssueTypesOptions[0]?.value || ''; + let defaultStatusOption: string = this.statusesOptions[0]?.value || ''; + let defaultPriorityOption: string = this.prioritiesOptions[0]?.value || ''; + this.issueIsSelectOptionsSelected = [ + { id: 'issueCreated', label: 'Created', value: 'IssueCreated' }, + { id: 'issueUpdated', label: 'Updated', value: 'IssueUpdated' }, + ]; + this.commentIsSelectOptionsSelected = [ + { id: 'commentCreated', label: 'Created', value: 'CommentCreated' }, + { id: 'commentUpdated', label: 'Updated', value: 'CommentUpdated' }, + ]; + + + this.addRemovePriorityFromForm(); + + if (notification) { + const { defaultProject, defaultIssueType, defaultPriority, defaultStatus } + = await this.initializeDefaultsForNotification(notification); + defaultProjectOption = defaultProject; + defaultIssueTypeOption = defaultIssueType; + defaultStatusOption = defaultStatus; + defaultPriorityOption = defaultPriority; + + this.issueIsSelectOptionsSelected + = this.issueIsSelectOptions.filter(opt => notification.eventTypes.includes(opt.value as string)); + this.commentIsSelectOptionsSelected + = this.commentIsSelectOptions.filter(opt => notification.eventTypes.includes(opt.value as string)); + } else { + await this.onProjectSelected(defaultProjectOption); + this.addRemovePriorityFromForm(); + } + + // Create form with default values + this.issueForm = new UntypedFormGroup({ + subscriptionId: new UntypedFormControl(notification?.subscriptionId), + jiraId: new UntypedFormControl(this.jiraId, [Validators.required]), + project: new UntypedFormControl(defaultProjectOption), + issuetype: new UntypedFormControl(defaultIssueTypeOption), + status: new UntypedFormControl(defaultStatusOption), + priority: new UntypedFormControl(defaultPriorityOption), + issueIs: new UntypedFormControl(this.issueIsSelectOptions), + commentIs: new UntypedFormControl(this.commentIsSelectOptions) + }); + + this.loading = false; + } + + public async onProjectSelected(optionOrValue: DropDownOption | string): Promise { + const projectId = typeof optionOrValue === 'string' ? optionOrValue : optionOrValue.value; + + this.selectedProject = this.projects?.find((proj: { id: string | null }) => proj.id === projectId); + const [issueTypesResult, statusesResult] = await Promise.all([ + this.apiService.getCreateMetaIssueTypes(this.jiraId as string, projectId as string).catch(error => { + console.error('Error fetching issue types:', error); + return null; + }), + await this.apiService.getStatusesByProject(this.jiraId as string, projectId as string).catch(error => { + console.error('Error fetching statuses:', error); + return null; + }) + ]); + + if (issueTypesResult) { + this.issueTypes = issueTypesResult; + this.availableIssueTypesOptions = this.getIssueTypesOptions(); + } else { + this.availableIssueTypesOptions = [this.DEFAULT_UNAVAILABLE_OPTION]; + const errorMessage = 'Failed to fetch issue types. Please try again later.'; + this.notificationService.notifyError(errorMessage); + } + + if(statusesResult) { + this.statusesOptions = statusesResult.map(this.dropdownUtilService.mapStatusToDropdownOption); + this.statusesOptions.unshift(this.ALL_OPTION); + } else { + this.statusesOptions = [this.DEFAULT_UNAVAILABLE_OPTION]; + const errorMessage = 'Failed to fetch statuses. Please try again later.'; + this.notificationService.notifyError(errorMessage); + } + + await this.onIssueTypeSelected(this.availableIssueTypesOptions[0]); + } + + public async onSearchChanged(filterName: string): Promise { + filterName = filterName.trim().toLowerCase(); + this.isFetchingProjects = true; + try { + const foundProjects = + await this.apiService.findProjects(this.jiraId, filterName, true); + this.projectsDropdown.filteredOptions = + foundProjects.map(this.dropdownUtilService.mapProjectToDropdownOption); + + this.projects = [...this.projects, ...foundProjects.filter((project: any) => + !this.projects.some((existingProject: any) => existingProject.id === project.id))]; + } catch (error) { + this.appInsightsService.trackException( + new Error('Error while searching projects'), + 'Configure Channel Notifications Dialog', + { originalErrorMessage: (error as any).message } + ); + } finally { + this.isFetchingProjects = false; + } + } + + public async onIssueTypeSelected(optionOrValue: DropDownOption | string): Promise { + const issueTypeId = typeof optionOrValue === 'string' ? optionOrValue : optionOrValue.value; + + if (issueTypeId) { + this.selectedIssueType = this.issueTypes?.find((issueType: { id: string }) => issueType.id === issueTypeId); + + this.fields = await this.apiService.getCreateMetaFields( + this.jiraId as string, + this.selectedProject?.key as string, + this.selectedIssueType?.id as string, + this.selectedIssueType?.name as string); + + this.addRemovePriorityFromForm(); + } + } + + public compareSelectOptions(option1: SelectOption, option2: SelectOption): boolean { + return option1 && option2 ? option1.id === option2.id : option1 === option2; + } + + public async saveNotification(): Promise { + if (this.issueForm.valid) { + this.submitting = true; + const formValue = this.issueForm.value; + const selectedProject = this.projects.find((project: { id: string }) => project.id === formValue.project); + const selectedIssueType = this.issueTypes.find((issueType: { id: string }) => issueType.id === formValue.issuetype); + const selectedPriority = this.prioritiesOptions.find((priority) => priority.id === formValue?.priority)?.label; + const selectedStatus = this.statusesOptions.find(opt => opt.id === formValue.status)?.label; + + const jqlQuery = this.buildJqlQuery( + formValue, + selectedProject?.key, + selectedIssueType?.name, + selectedPriority, + selectedStatus); + + const issueIsOptions = this.getMappedNotificationTypeOptions(formValue.issueIs); + const commentIsOptions = this.getMappedNotificationTypeOptions(formValue.commentIs); + + const notificationSubscription: NotificationSubscription = { + jiraId: this.jiraId, + subscriptionType: SubscriptionType.Channel, + projectId: selectedProject?.id, + projectName: selectedProject?.name, + filter: jqlQuery, + microsoftUserId: '', + conversationId: this.conversationId, + conversationReferenceId: this.conversationReferenceId, + eventTypes: [...issueIsOptions, ...commentIsOptions], + isActive: true + }; + + const isDuplicate = this.notifications + .filter((notification: NotificationSubscription) => + notification.subscriptionId !== this.issueForm.value.subscriptionId) + .find((subscription: NotificationSubscription) => this.areNotificationsEqual(subscription, notificationSubscription)); + + if (isDuplicate) { + this.notificationService.notifyError( + 'The subscription with the same configuration already exists. Please use a different configuration.'); + this.submitting = false; + return; + } + + const notifyMessage = this.issueForm.value.subscriptionId + ? 'Notification updated successfully.' + : 'Notification saved successfully.'; + + let notificationSubscriptionEvent: NotificationSubscriptionEvent | undefined; + + if (this.issueForm.value.subscriptionId) { + notificationSubscription.subscriptionId = this.issueForm.value.subscriptionId; + await this.apiService.updateNotification(this.jiraId, notificationSubscription); + + notificationSubscriptionEvent = { + subscription: notificationSubscription, + action: NotificationSubscriptionAction.Update + }; + this.analyticsService.sendUiEvent( + 'configureChannelNotificationsModal', + EventAction.clicked, + UiEventSubject.button, + 'updateChannelNotification', + {source: 'configureChannelNotificationsModal'}); + } else { + await this.apiService.addNotification(this.jiraId, notificationSubscription); + + notificationSubscriptionEvent = { + subscription: notificationSubscription, + action: NotificationSubscriptionAction.Create + }; + this.analyticsService.sendUiEvent( + 'configureChannelNotificationsModal', + EventAction.clicked, + UiEventSubject.button, + 'createChannelNotification', + {source: 'configureChannelNotificationsModal'}); + } + + this.notificationService.notifySuccess(notifyMessage); + + await this.displayNotificationsListGroup(); + + await this.apiService.sendNotificationSubscriptionEvent(notificationSubscriptionEvent); + this.submitting = false; + } + } + + public async deleteNotification(notification: NotificationSubscription): Promise { + this.analyticsService.sendUiEvent( + 'configureChannelNotificationsModal', + EventAction.clicked, + UiEventSubject.button, + 'deleteChannelNotification', + {source: 'configureChannelNotificationsModal'}); + await this.apiService.deleteNotification(this.jiraId as string, notification.subscriptionId as string); + this.notificationService.notifySuccess('Notification deleted successfully.'); + await this.displayNotificationsListGroup(); + + const notificationSubscriptionEvent: NotificationSubscriptionEvent = { + subscription: notification, + action: NotificationSubscriptionAction.Delete + }; + + await this.apiService.sendNotificationSubscriptionEvent(notificationSubscriptionEvent); + } + + public async toggleNotification(notification: NotificationSubscription): Promise { + this.analyticsService.sendUiEvent( + 'configureChannelNotificationsModal', + EventAction.clicked, + UiEventSubject.button, + !notification.isActive ? 'muteChannelNotification' : 'unmuteChannelNotification', + {source: 'configureChannelNotificationsModal'}); + notification.isActive = !notification.isActive; + await this.apiService.updateNotification(this.jiraId, notification); + this.notificationService.notifySuccess('Notification successfully ' + (notification.isActive ? 'enabled' : 'disabled')); + await this.displayNotificationsListGroup(); + + const notificationSubscriptionEvent: NotificationSubscriptionEvent = { + subscription: notification, + action: notification.isActive ? NotificationSubscriptionAction.Enabled : NotificationSubscriptionAction.Disabled + }; + + await this.apiService.sendNotificationSubscriptionEvent(notificationSubscriptionEvent); + } + + public onCancel(): void { + microsoftTeams.dialog.url.submit(); + } + + public displayInitialContainer(): void { + this.showInitialContainer = true; + this.showCreateNotificationsGroup = false; + this.showNotificationsListGroup = false; + } + + public async displayCreateNotificationsGroup(): Promise { + this.showInitialContainer = false; + this.showCreateNotificationsGroup = true; + this.showNotificationsListGroup = false; + + await this.createForm(); + } + + public async displayNotificationsListGroup(): Promise { + this.notifications = await this.apiService.getAllNotificationsByConversationId(this.jiraId as string, this.conversationId); + + if (this.notifications && this.notifications.length > 0) { + this.showInitialContainer = false; + this.showCreateNotificationsGroup = false; + this.showNotificationsListGroup = true; + } else { + this.displayInitialContainer(); + } + } + + public async displayCreateNotificationsGroupWithNotification(notification: NotificationSubscription): Promise { + this.showInitialContainer = false; + this.showCreateNotificationsGroup = true; + this.showNotificationsListGroup = false; + + await this.createForm(notification); + } + + private async getProjects(jiraUrl: string): Promise { + return await this.apiService.getProjects(jiraUrl, true); + } + + private addRemovePriorityFromForm(): void { + if (!this.fields || Object.keys(this.fields).length === 0) { + this.prioritiesOptions = [this.ALL_OPTION]; + return; + } + + const priorities = this.fields.priority; + + const priorityControlName = 'priority'; + + if (priorities) { + const prioritiesOptions = priorities.allowedValues.map(this.dropdownUtilService.mapPriorityToDropdownOption); + prioritiesOptions.unshift(this.ALL_OPTION); + this.prioritiesOptions = prioritiesOptions; + } else if (this.issueForm.contains(priorityControlName)) { + this.issueForm.removeControl(priorityControlName); + this.prioritiesOptions = []; + } + } + + private getIssueTypesOptions(): DropDownOption[] { + if (this.issueTypes && this.issueTypes.length > 0) { + const issueTypes = this.issueTypes + .map(this.dropdownUtilService.mapIssueTypeToDropdownOption); + issueTypes.unshift(this.ALL_OPTION); + + return issueTypes; + } + return [this.DEFAULT_UNAVAILABLE_OPTION]; + } + + private buildJqlQuery( + formValue: any, + projectKey: string | undefined, + issueTypeName: string | undefined, + priority: string | undefined, + status: string | undefined + ): string { + let jqlQuery = `project = "${projectKey}"`; + + if (formValue.issuetype && formValue.issuetype !== this.ALL_OPTION.value) { + jqlQuery += ` AND type in ("${issueTypeName}")`; + } + + if (formValue.priority && formValue.priority !== this.ALL_OPTION.value) { + jqlQuery += ` AND priority in ("${priority}")`; + } + + if (formValue.status && formValue.status !== this.ALL_OPTION.value) { + jqlQuery += ` AND status in ("${status}")`; + } + + return jqlQuery; + } + + private getMappedNotificationTypeOptions(options: (string | SelectOption)[]): any { + return options.map((option: string | SelectOption) => + typeof option === 'string' + ? this.notificationOptions.find(n => n.id === option)?.value + : this.notificationOptions.find(n => n.id === option.id)?.value + ); + } + + private async initializeDefaultsForNotification(notification: NotificationSubscription): + Promise<{ defaultProject: string; defaultIssueType: string; defaultPriority: string; defaultStatus: string }> { + const defaultProject = this.availableProjectsOptions.find(project => project.id === notification.projectId)?.value || ''; + await this.onProjectSelected(defaultProject); + + let defaultIssueType = this.availableIssueTypesOptions[0]?.value || ''; + let defaultPriority = this.prioritiesOptions[0]?.value || ''; + let defaultStatus = this.statusesOptions[0]?.value || ''; + + if (notification.filter) { + const jqlParts = notification.filter.split(' AND '); + + const typeMatch = jqlParts.find(part => part.trim().startsWith('type in')); + if (typeMatch) { + const typeValue = typeMatch.match(/"([^"]+)"/)?.[1]; + defaultIssueType = this.availableIssueTypesOptions.find(opt => opt.label === typeValue)?.value || defaultIssueType; + await this.onIssueTypeSelected(defaultIssueType); + } + + const priorityMatch = jqlParts.find(part => part.trim().startsWith('priority in')); + if (priorityMatch) { + const priorityValue = priorityMatch.match(/"([^"]+)"/)?.[1]; + defaultPriority = this.prioritiesOptions.find(opt => opt.label === priorityValue)?.value || defaultPriority; + } + + const statusMatch = jqlParts.find(part => part.trim().startsWith('status in')); + if (statusMatch) { + const statusValue = statusMatch.match(/"([^"]+)"/)?.[1]; + defaultStatus = this.statusesOptions.find(opt => opt.label === statusValue)?.value || defaultStatus; + } + } + + return { defaultProject, defaultIssueType, defaultPriority, defaultStatus }; + } + + private async checkAddonUpdated(): Promise { + const addonVersion + = await this.apiService.getAddonStatus(this.jiraId); + + const isAddonUpdated + = this.utilService.isAddonUpdatedToVersion(addonVersion.addonVersion, this.utilService.getMinAddonVersionForNotifications()); + if(!isAddonUpdated) { + this.notificationService.notifyError(this.utilService.getUpgradeAddonMessageForNotifications(), 5000, false); + } + } + + private areNotificationsEqual( + notification1: NotificationSubscription, + notification2: NotificationSubscription + ): boolean { + return notification1.jiraId === notification2.jiraId && + notification1.subscriptionType === notification2.subscriptionType && + notification1.conversationId === notification2.conversationId && + JSON.stringify(notification1.eventTypes) === JSON.stringify(notification2.eventTypes) && + notification1.projectId === notification2.projectId && + notification1.projectName === notification2.projectName && + notification1.filter === notification2.filter; + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.html b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.html new file mode 100644 index 0000000..ffa1d71 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.html @@ -0,0 +1,105 @@ +
+
+

Customise the type of notifications you get from

+ +

+ Jira Data Center ID +

+
+ + +
+
+

Assignee

+ Activity on your assigned issues + Comments on your assigned issues +
+ +
+

Reporter

+ Activity on issues you've reported + Comments on issues you've reported +
+ +
+

Watcher

+ Activity on issues you're watching + Comments on issues you're watching +
+ +
+

Mentions

+ Someone mentions you +
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.scss b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.scss new file mode 100644 index 0000000..3f1d7ea --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.scss @@ -0,0 +1,101 @@ +@use '../../../../styles/styles'; +@use '../../../../styles/app-colors'; +@use '../../../../styles/common/checkboxes.component'; + +.field-label { + font-size: 12px; + line-height: 1.6rem; + font-weight: 400; +} + +.dialog { + margin-left: 3.2rem; + margin-right: 3.2rem; + margin-top: 2.0rem; + + &__content { + overflow: auto; + max-height: calc(100vh - 48px); + margin-bottom: 12px; + + padding-right: 3.2rem; + margin-right: -3rem; + } + + &__footer { + margin-bottom: 12px; + } + + .field-group { + width: 100%; + + &__jiraId-textarea { + height: 3.2rem; + overflow: hidden; + } + } + + .header-text { + font-size: 16px !important; + margin-bottom: 8px; + } + + + .checkbox-container { + display:flex; + align-items: center; + justify-content: center; + } + + .content { + .notification-groups { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 20px; + + .group { + h4 { + margin-bottom: 8px; + font-size: 14px; + } + + p { + vertical-align: middle !important; + line-height: 18px; + } + + label { + display: block; + margin-bottom: 6px; + font-size: 14px; + + input { + margin-right: 8px; + } + } + } + } + } + + .actions { + text-align: right; + margin-top: 24px; + } + + .buttons { + display: flex; + justify-content: flex-end; + + &__button { + width: 150px; + height: 32px; + margin-top: 50px; + } + } +} + + +.template .ph-item { + margin-bottom: 0px !important; + padding: 15px !important; +} \ No newline at end of file diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.spec.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.spec.ts new file mode 100644 index 0000000..1261292 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.spec.ts @@ -0,0 +1,197 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ConfigurePersonalNotificationsDialogComponent } from './configure-personal-notifications-dialog.component'; +import { ActivatedRoute } from '@angular/router'; +import {ApiService, UtilService} from '@core/services'; +import { NotificationService } from '@shared/services/notificationService'; +import { of } from 'rxjs'; +import { NotificationSubscription, SubscriptionType } from '@core/models/NotificationSubscription'; +import * as microsoftTeams from '@microsoft/teams-js'; +import {AnalyticsService} from '@core/services/analytics.service'; + +describe('ConfigurePersonalNotificationsDialogComponent', () => { + let component: ConfigurePersonalNotificationsDialogComponent; + let fixture: ComponentFixture; + let mockApiService: jasmine.SpyObj; + let mockNotificationService: jasmine.SpyObj; + let mockUtilsService: jasmine.SpyObj; + let mockAnalyticsService: jasmine.SpyObj; + let mockActivatedRoute: any; + + const mockNotificationSubscription: NotificationSubscription = { + jiraId: 'test-jira-id', + subscriptionType: SubscriptionType.Personal, + eventTypes: ['activityAssignee', 'commentsAssignee'], + isActive: true, + filter: '', + microsoftUserId: 'test-user-id', + conversationId: 'test-conversation-id', + conversationReferenceId: 'test-reference-id', + projectId: '', + projectName: '' + }; + + beforeEach(async () => { + spyOn(microsoftTeams.dialog.url, 'submit').and.callFake(() => {}); + + mockApiService = jasmine.createSpyObj('ApiService', [ + 'getNotificationSettings', + 'updateNotification', + 'addNotification', + 'getAddonStatus', + ]); + + mockNotificationService = jasmine.createSpyObj('NotificationService', [ + 'notifySuccess', + 'notifyError' + ]); + + mockUtilsService = jasmine.createSpyObj('UtilService', [ + 'isAddonUpdatedToVersion', + 'getMinAddonVersionForNotifications' + ]); + + const analyticsServiceSpy = jasmine.createSpyObj( + 'AnalyticsService', [ + 'sendScreenEvent', + 'sendUiEvent' + ]); + + const mockSnackBarRef = jasmine.createSpyObj('MatSnackBarRef', ['afterDismissed']); + mockSnackBarRef.afterDismissed.and.returnValue(of({ dismissedByAction: false })); + + mockNotificationService.notifySuccess.and.returnValue(mockSnackBarRef); + mockNotificationService.notifyError.and.returnValue(mockSnackBarRef); + + mockUtilsService.isAddonUpdatedToVersion.and.returnValue(true); + mockApiService.getAddonStatus.and.returnValue(Promise.resolve({ addonStatus: 1, addonVersion: '1.0.0' })); + + mockActivatedRoute = { + snapshot: { + params: { + jiraId: 'test-jira-id', + microsoftUserId: 'test-user-id', + conversationId: 'test-conversation-id', + conversationReferenceId: 'test-reference-id' + } + } + }; + + await TestBed.configureTestingModule({ + declarations: [ConfigurePersonalNotificationsDialogComponent], + providers: [ + {provide: ApiService, useValue: mockApiService}, + {provide: NotificationService, useValue: mockNotificationService}, + {provide: ActivatedRoute, useValue: mockActivatedRoute}, + {provide: UtilService, useValue: mockUtilsService}, + {provide: AnalyticsService, useValue: analyticsServiceSpy} + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ConfigurePersonalNotificationsDialogComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should initialize component with route parameters', async () => { + await component.ngOnInit(); + + expect(component.jiraId).toBe('test-jira-id'); + expect(component.microsoftUserId).toBe('test-user-id'); + expect(component.conversationId).toBe('test-conversation-id'); + expect(component.conversationReferenceId).toBe('test-reference-id'); + expect(component.notificationsForm).toBeDefined(); + }); + }); + + describe('getNotificationSettings', () => { + it('should load and set notification settings successfully', async () => { + mockApiService.getNotificationSettings.and.returnValue(Promise.resolve({ + eventTypes: ['ActivityIssueAssignee', 'CommentIssueAssignee'], + jiraId: 'test-jira-id', + subscriptionType: SubscriptionType.Personal, + isActive: true, + filter: '', + microsoftUserId: 'test-user-id', + conversationId: 'test-conversation-id', + conversationReferenceId: 'test-reference-id', + projectId: '', + projectName: '' + })); + + await component.ngOnInit(); + + expect(mockApiService.getNotificationSettings).toHaveBeenCalledWith('test-jira-id'); + expect(component.notificationsForm?.get('ActivityIssueAssignee')?.value).toBeTrue(); + expect(component.notificationsForm?.get('CommentIssueAssignee')?.value).toBeTrue(); + }); + + it('should handle error when loading notification settings', async () => { + mockApiService.getAddonStatus.and.returnValue(Promise.reject('Error')); + + await component.ngOnInit(); + await component.getNotificationSettings(); + + expect(mockNotificationService.notifyError).toHaveBeenCalledWith('Failed to load notification settings. Please try again.'); + }); + }); + + describe('saveNotification', () => { + beforeEach(async () => { + await component.ngOnInit(); + }); + + it('should not save if form is invalid', async () => { + component.notificationsForm?.setErrors({invalid: true}); + await component.saveNotification(); + expect(mockApiService.updateNotification).not.toHaveBeenCalled(); + expect(mockApiService.addNotification).not.toHaveBeenCalled(); + }); + + it('should update existing notification', async () => { + component.savedNotificationSubscription = mockNotificationSubscription; + mockApiService.updateNotification.and.returnValue(Promise.resolve()); + + await component.saveNotification(); + + expect(mockApiService.updateNotification).toHaveBeenCalled(); + expect(mockNotificationService.notifySuccess).toHaveBeenCalledWith('Notification updated successfully.'); + }); + + it('should create new notification', async () => { + mockApiService.addNotification.and.returnValue(Promise.resolve()); + + await component.saveNotification(); + + expect(mockApiService.addNotification).toHaveBeenCalled(); + expect(mockNotificationService.notifySuccess).toHaveBeenCalledWith('Notification saved successfully.'); + }); + + it('should handle error when saving notification', async () => { + mockApiService.addNotification.and.returnValue(Promise.reject('Error')); + + await component.saveNotification(); + + expect(mockNotificationService.notifyError).toHaveBeenCalledWith('Failed to save notification. Please try again.'); + }); + }); + + describe('getSelectedEventTypes', () => { + it('should return selected event types', async () => { + await component.ngOnInit(); + component.notificationsForm?.patchValue({ + ActivityIssueAssignee: true, + CommentIssueAssignee: true, + ActivityIssueCreator: false + }); + + const eventTypes = component['getSelectedEventTypes'](); + expect(eventTypes).toContain('ActivityIssueAssignee'); + expect(eventTypes).toContain('CommentIssueAssignee'); + expect(eventTypes).not.toContain('ActivityIssueCreator'); + }); + }); +}); diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.ts new file mode 100644 index 0000000..128f047 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/components/notifications/configure-notifications-dialog/configure-personal-notifications-dialog.component.ts @@ -0,0 +1,212 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { NotificationSubscription, SubscriptionType } from '@core/models/NotificationSubscription'; +import {ApiService, UtilService} from '@core/services'; +import { NotificationService } from '@shared/services/notificationService'; +import * as microsoftTeams from '@microsoft/teams-js'; +import { AnalyticsService, EventAction, UiEventSubject } from '@core/services/analytics.service'; + +@Component({ + selector: 'app-configure-personal-notifications-dialog', + templateUrl: './configure-personal-notifications-dialog.component.html', + styleUrls: ['./configure-personal-notifications-dialog.component.scss'], + standalone: false +}) +export class ConfigurePersonalNotificationsDialogComponent implements OnInit { + public notificationsForm: UntypedFormGroup | undefined; + public jiraId: string | any; + public microsoftUserId: string | any; + public conversationId: string | any; + public conversationReferenceId: string | any; + public savedNotificationSubscription: NotificationSubscription | any; + public loading = false; + public submitting = false; + public isAddonUpdated = false; + public replyToActivityId: string | any; + + constructor( + private route: ActivatedRoute, + private apiService: ApiService, + private notificationService: NotificationService, + private utilService: UtilService, + private analyticsService: AnalyticsService + ) { } + + public async ngOnInit() { + const { jiraId, microsoftUserId, conversationId, conversationReferenceId, replyToActivityId } = this.route.snapshot.params; + + this.jiraId = jiraId; + this.microsoftUserId = microsoftUserId; + this.conversationId = conversationId; + this.conversationReferenceId = conversationReferenceId; + this.loading = true; + this.replyToActivityId = replyToActivityId; + + if (!this.jiraId) { + const response = await this.apiService.getJiraUrlForPersonalScope(); + this.jiraId = response.jiraUrl; + } + + this.analyticsService.sendScreenEvent( + 'configurePersonalNotificationsModal', + EventAction.viewed, + UiEventSubject.taskModule, + 'configurePersonalNotificationsModal', {}); + + await this.createForm(); + + await this.getNotificationSettings(); + } + + public async getNotificationSettings(): Promise { + try { + const getAddonStatusPromise + = this.apiService.getAddonStatus(this.jiraId); + + let getCurrentNotificationSettingsPromise; + try { + getCurrentNotificationSettingsPromise + = await this.apiService.getNotificationSettings(this.jiraId); + } catch { + this.loading = false; + return; + } + + const [{ addonVersion }, notificationSettings] = await Promise.all([ + getAddonStatusPromise, + getCurrentNotificationSettingsPromise + ]); + this.isAddonUpdated + = this.utilService.isAddonUpdatedToVersion(addonVersion, this.utilService.getMinAddonVersionForNotifications()); + if(!this.isAddonUpdated) { + this.notificationService.notifyError(this.utilService.getUpgradeAddonMessageForNotifications(), 5000, false); + } + + if (notificationSettings) { + this.notificationsForm?.patchValue({ + ActivityIssueAssignee: notificationSettings.eventTypes.includes('ActivityIssueAssignee'), + CommentIssueAssignee: notificationSettings.eventTypes.includes('CommentIssueAssignee'), + ActivityIssueCreator: notificationSettings.eventTypes.includes('ActivityIssueCreator'), + CommentIssueCreator: notificationSettings.eventTypes.includes('CommentIssueCreator'), + IssueViewer: notificationSettings.eventTypes.includes('IssueViewer'), + CommentViewer: notificationSettings.eventTypes.includes('CommentViewer'), + MentionedOnIssue: notificationSettings.eventTypes.includes('MentionedOnIssue') + }); + + this.savedNotificationSubscription = notificationSettings; + } + } catch (error: any) { + this.notificationService.notifyError('Failed to load notification settings. Please try again.') + .afterDismissed().subscribe(() => { + microsoftTeams.dialog.url.submit(); + }); + } finally { + this.loading = false; + } + } + + public eventTypeSelected() { + if (this.notificationsForm) { + this.notificationsForm.markAsTouched(); + } + } + + public async saveNotification(): Promise { + if (!this.notificationsForm?.valid) { + return; + } + this.submitting = true; + + if (this.savedNotificationSubscription) { + this.savedNotificationSubscription.eventTypes = this.getSelectedEventTypes(); + this.savedNotificationSubscription.microsoftUserId = this.microsoftUserId; + this.savedNotificationSubscription.jiraId = this.jiraId; + this.savedNotificationSubscription.conversationId = this.conversationId; + this.savedNotificationSubscription.conversationReferenceId = this.conversationReferenceId; + this.savedNotificationSubscription.isActive = true; + + await this.apiService.updateNotification(this.jiraId, this.savedNotificationSubscription); + + this.notificationService.notifySuccess('Notification updated successfully.').afterDismissed().subscribe(() => { + microsoftTeams.dialog.url.submit({ + commandName: 'showNotificationSettings', + replyToActivityId: this.replyToActivityId}); + this.submitting = false; + microsoftTeams.dialog.url.submit(); + }); + this.analyticsService.sendUiEvent( + 'configurePersonalNotificationsModal', + EventAction.clicked, + UiEventSubject.button, + 'updatePersonalNotification', + {source: 'configurePersonalNotificationsModal'}); + + return; + } + + const notification: NotificationSubscription = { + jiraId: this.jiraId, + subscriptionType: SubscriptionType.Personal, + eventTypes: this.getSelectedEventTypes(), + isActive: true, + filter: '', + microsoftUserId: this.microsoftUserId, + conversationId: this.conversationId, + conversationReferenceId: this.conversationReferenceId, + projectId: '', + projectName: '' + }; + this.analyticsService.sendUiEvent( + 'configurePersonalNotificationsModal', + EventAction.clicked, + UiEventSubject.button, + 'createPersonalNotification', + {source: 'configurePersonalNotificationsModal'}); + + try { + await this.apiService.addNotification(this.jiraId, notification); + this.notificationService.notifySuccess('Notification saved successfully.').afterDismissed().subscribe(() => { + microsoftTeams.dialog.url.submit({ + commandName: 'showNotificationSettings', + replyToActivityId: this.replyToActivityId}); + this.submitting = false; + microsoftTeams.dialog.url.submit(); + }); + } catch (error) { + this.notificationService.notifyError('Failed to save notification. Please try again.').afterDismissed().subscribe(() => { + this.submitting = false; + }); + } + } + + private getSelectedEventTypes(): string[] { + const eventTypes: string[] = []; + if (this.notificationsForm) { + const formControls = this.notificationsForm.controls; + + for (const controlName in formControls) { + if (formControls.hasOwnProperty(controlName)) { + const control = formControls[controlName]; + if (control.value === true) { + eventTypes.push(controlName); // Use the control name as the event type + } + } + } + } + + return eventTypes; + } + + private async createForm(): Promise { + this.notificationsForm = new UntypedFormGroup({ + ActivityIssueAssignee: new UntypedFormControl(false), + CommentIssueAssignee: new UntypedFormControl(false), + ActivityIssueCreator: new UntypedFormControl(false), + CommentIssueCreator: new UntypedFormControl(false), + IssueViewer: new UntypedFormControl(false), + CommentViewer: new UntypedFormControl(false), + MentionedOnIssue: new UntypedFormControl(false) + }); + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/models/NotificationSubscription.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/models/NotificationSubscription.ts new file mode 100644 index 0000000..e6b5fc2 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/models/NotificationSubscription.ts @@ -0,0 +1,36 @@ +export interface NotificationSubscription { + subscriptionId?: string; + jiraId: string; + subscriptionType: SubscriptionType; + conversationId: string; + conversationReference?: string; + conversationReferenceId: string; + eventTypes: string[]; + projectId: string; + projectName: string; + microsoftUserId: string; + filter: string; + isActive: boolean; +} + +export enum SubscriptionType { + Personal = 0, + Channel = 1 +} + +export enum PersonalEventType { + ActivityIssueCreator = 'ActivityIssueCreator', + CommentIssueCreator = 'CommentIssueCreator', + ActivityIssueAssignee = 'ActivityIssueAssignee', + CommentIssueAssignee = 'CommentIssueAssignee', + IssueViewer = 'IssueViewer', + MentionedOnIssue = 'MentionedOnIssue', + CommentViewer = 'CommentViewer' +} + +export enum ChannelEventType { + IssueCreated = 'IssueCreated', + IssueUpdated = 'IssueUpdated', + CommentCreated = 'CommentCreated', + CommentUpdated = 'CommentUpdated' +} diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/models/NotificationSubscriptionEvent.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/models/NotificationSubscriptionEvent.ts new file mode 100644 index 0000000..ed57ef4 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/models/NotificationSubscriptionEvent.ts @@ -0,0 +1,14 @@ +import { NotificationSubscription } from './NotificationSubscription'; + +export interface NotificationSubscriptionEvent { + subscription: NotificationSubscription; + action: NotificationSubscriptionAction; +} + +export enum NotificationSubscriptionAction { + Create = 'Created', + Update = 'Updated', + Delete = 'Deleted', + Enabled = 'Enabled', + Disabled = 'Disabled', +} diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/api.service.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/api.service.ts index 5cf02b0..a12767b 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/api.service.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/api.service.ts @@ -26,7 +26,9 @@ import { JiraIssueFieldMeta } from '@core/models/Jira/jira-issue-field-meta.mode import { JiraFieldAutocomplete } from '@core/models/Jira/jira-field-autocomplete-data.model'; import { JiraIssueSprint } from '@core/models/Jira/jira-issue-sprint.model'; import { JiraIssueEpic } from '@core/models/Jira/jira-issue-epic.model'; -import {firstValueFrom} from 'rxjs'; +import { firstValueFrom } from 'rxjs'; +import { NotificationSubscription } from '@core/models/NotificationSubscription'; +import { NotificationSubscriptionEvent } from '@core/models/NotificationSubscriptionEvent'; export interface JiraAddonStatus { addonStatus: number; @@ -264,4 +266,40 @@ export class ApiService { return firstValueFrom(this.http .get(`/api/validate-connection?jiraServerId=${jiraServerId}`)); } + + public async getJiraId(jiraBaseUrl: string | undefined): Promise { + return firstValueFrom(this.http + .get(`/api/getJiraId?jiraUrl=${jiraBaseUrl}`, { responseType: 'text' })); + } + + public addNotification(jiraServerId: string, notification: NotificationSubscription): Promise { + return firstValueFrom(this.http.post(`/api/notificationSubscription/add?jiraServerId=${jiraServerId}`, notification)); + } + + public getNotificationSettings(jiraServerId: string): Promise { + return firstValueFrom(this.http.get( + `/api/notificationSubscription/get?jiraServerId=${jiraServerId}`)); + } + + public getAllNotificationsByConversationId(jiraServerId: string, conversationId: string): Promise { + return firstValueFrom(this.http.get( + `/api/notificationSubscription/getAllByConversationId?jiraServerId=${jiraServerId}&conversationId=${conversationId}`)); + } + + public updateNotification(jiraServerId: string, notification: NotificationSubscription): Promise { + return firstValueFrom(this.http.put(`/api/notificationSubscription/update?jiraServerId=${jiraServerId}`, notification)); + } + + public removePersonalNotification(jiraServerId: string): Promise { + return firstValueFrom(this.http.post(`/api/notificationSubscription/removePersonal?jiraServerId=${jiraServerId}`, {})); + } + + public deleteNotification(jiraServerId: string, subscriptionId: string): Promise { + return firstValueFrom(this.http + .delete(`/api/notificationSubscription/delete?jiraServerId=${jiraServerId}&subscriptionId=${subscriptionId}`)); + } + + public sendNotificationSubscriptionEvent(notificationSubscriptionEvent: NotificationSubscriptionEvent): Promise { + return firstValueFrom(this.http.post('/api/notificationSubscription/sendChannelNotificationEvent', notificationSubscriptionEvent)); + } } diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/entities/transition.service.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/entities/transition.service.ts index b5e1291..ee4eeec 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/entities/transition.service.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/entities/transition.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { JiraTransitionsResponse } from '@core/models/Jira/jira-transition.model'; import { JiraApiActionCallResponse } from '@core/models/Jira/jira-api-action-call-response.model'; -import {firstValueFrom} from 'rxjs'; +import { firstValueFrom } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class IssueTransitionService { @@ -16,6 +16,11 @@ export class IssueTransitionService { .get(`/api/issue/transitions?jiraUrl=${jiraUrl}&issueIdOrKey=${issueIdOrKey}`)); } + public getTransitionsByProjectKeyOrId(jiraUrl: string, projectKeyOrId: string): Promise { + return firstValueFrom(this.http + .get(`/api/issue/transitionsByProject?jiraUrl=${jiraUrl}&projectKeyOrId=${projectKeyOrId}`)); + } + public doTransition(jiraUrl: string, issueIdOrKey: string, transitionId: string): Promise { return firstValueFrom(this.http .post( diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.spec.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.spec.ts index 8e1eee0..8dc29a3 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.spec.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.spec.ts @@ -1,138 +1,143 @@ -import { TestBed } from '@angular/core/testing'; -import { UtilService } from './util.service'; +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import {Injectable} from '@angular/core'; +import * as microsoftTeams from '@microsoft/teams-js'; +import {HostClientType} from '@microsoft/teams-js'; +import {compare} from 'compare-versions'; + +interface PredefinedFilters { + id: number; + value: string; + label: string; +} + +type IconSize = 'small' | 'medium'; + +@Injectable({ providedIn: 'root' }) +export class UtilService { + private readonly PREDEFINED_FILTERS: PredefinedFilters[] = [ + { id: 0, value: 'all-issues', label: 'All issues' }, + { id: 1, value: 'open-issues', label: 'Open issues' }, + { id: 2, value: 'done-issues', label: 'Done issues' }, + { id: 3, value: 'viewed-recently', label: 'Viewed recently' }, + { id: 4, value: 'created-recently', label: 'Created recently' }, + { id: 5, value: 'resolved-recently', label: 'Resolved recently' }, + { id: 6, value: 'updated-recently', label: 'Updated recently' } + ]; + + private readonly UPGRADE_ADDON_MESSAGE + = 'Please upgrade Jira Data Center for Microsoft Teams app on your Jira Data Center to perform projects search.'; + private readonly NOTIFICATIONS_UPGRADE_ADDON_MESSAGE + = 'Please upgrade Jira Data Center for Microsoft Teams app on your Jira Data Center' + + ' to receive notifications from Jira.'; + private readonly ADDON_VERSION = '2022.08.103'; + private readonly NOTIFICATIONS_ADDON_VERSION = '2025.05.13'; + + public isMobile = async (): Promise => { + const context = await microsoftTeams.app.getContext(); + return context.app.host.clientType === HostClientType.ios || + context.app.host.clientType === HostClientType.android; + }; + + public getFilters = (): PredefinedFilters[] => this.PREDEFINED_FILTERS; + + public encode(value: string): string { + if (value.match(/[!'()*]/)) { + return encodeURIComponent(value).replace(/[!'()*]/g, c => + // Also encode !, ', (, ), and * + `%${c.charCodeAt(0).toString(16)}` + ); + } + + return value; + } + + public getMsTeamsContext = (): { tid: string; loginHint: string; userObjectId: string; locale: string } => + JSON.parse(localStorage.getItem('msTeamsContext') as string); + + public setTeamsContext = (tenantId: string): void => localStorage.setItem('msTeamsContext', JSON.stringify({ tid: tenantId })); + + public getUserClientId = (): string => localStorage.getItem('userClientId') as string; + + public getAADInstance = (): string => { + const microsoftLoginBaseUrl = localStorage.getItem('microsoftLoginBaseUrl'); + const baseUrl = microsoftLoginBaseUrl ? microsoftLoginBaseUrl : 'https://login.microsoftonline.com'; + return `${baseUrl}/`; + }; + + public convertStringToNull = (value: any) => value === 'null' || value === 'undefined' ? null : value; + + public capitalizeFirstLetter = (value: string) => String(value).charAt(0).toUpperCase() + String(value).slice(1); + + public capitalizeFirstLetterAndJoin = (...value: string[]) => value.map(this.capitalizeFirstLetter).join(''); + + public getDefaultUserIcon(size: IconSize = 'small'): string { + const iconSizeInPixels = size === 'small' ? '24x24' : '32x32'; + return `/assets/useravatar${iconSizeInPixels}.png`; + } + + /** + * Be careful when using this type of copy, beacuse it will not copy function properties + */ + public jsonCopy = (obj: any): T => JSON.parse(JSON.stringify(obj)) as T; + + /** + * Be careful: if object property is present but in different position it will show false result. + * e.g. jsonEqual({ prop1: 1, prop2: 2 }, { prop2: 2, prop1: 1}) => false; + * + * Also it will not track function properties. + */ + public jsonEqual = (obj1: any, obj2: any): boolean => JSON.stringify(obj1) === JSON.stringify(obj2); + + /** + * Appends params to specified link + * @param link with or without params + * @param paramMap + */ + public appendParamsToLink(link: string, paramMap: any): string { + // if link doesn't contain params - append '?' + // else if there is already some params - append '&' if necessary + link += link.indexOf('?') === -1 ? '?' : + link.endsWith('&') ? '' : '&'; + + Object.keys(paramMap).forEach(key => { + // if value is not empty or not undefined - append it + if (paramMap[key]) { + link += `${key}=${paramMap[key]}&`; + } + }); -describe('UtilService', () => { - let service: UtilService; + return encodeURI(link); + } - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [UtilService] - }); + public getJiraServerId = (): string => localStorage.getItem('jiraServer.jiraId') as string; + + public getQueryParam(paramName: string, url?: string): string { + try { + const query = url ? new URL(url).search : window.location.search; + const params = new URLSearchParams(query); + return params.get(paramName) || ''; + } catch (error) { + console.error('Error parsing query parameter:', error); + return ''; + } + } + + public isElectron = (): boolean => { + const userAgent = navigator.userAgent.toLowerCase(); + return userAgent.indexOf('electron') > -1; + }; + + public getMinAddonVersion = (): string => this.ADDON_VERSION; + + public getMinAddonVersionForNotifications = (): string => this.NOTIFICATIONS_ADDON_VERSION; + + public getUpgradeAddonMessage = (): string => this.UPGRADE_ADDON_MESSAGE; + public getUpgradeAddonMessageForNotifications = (): string => this.NOTIFICATIONS_UPGRADE_ADDON_MESSAGE; - service = TestBed.inject(UtilService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should return predefined filters', () => { - const filters = service.getFilters(); - expect(filters.length).toBe(7); - expect(filters[0].value).toBe('all-issues'); - }); - - it('should encode special characters', () => { - const encodedValue = service.encode('test!()*\''); - expect(encodedValue).toBe('test%21%28%29%2a%27'); - }); - - it('should get and set Microsoft Teams context', () => { - const context = { tid: 'test-tenant', loginHint: 'test-login', userObjectId: 'test-user', locale: 'en-US' }; - localStorage.setItem('msTeamsContext', JSON.stringify(context)); - - const retrievedContext = service.getMsTeamsContext(); - expect(retrievedContext.tid).toBe('test-tenant'); - - service.setTeamsContext('new-tenant'); - const updatedContext = service.getMsTeamsContext(); - expect(updatedContext.tid).toBe('new-tenant'); - }); - - it('should get user client ID', () => { - localStorage.setItem('userClientId', 'test-client-id'); - const clientId = service.getUserClientId(); - expect(clientId).toBe('test-client-id'); - }); - - it('should get AAD instance', () => { - localStorage.setItem('microsoftLoginBaseUrl', 'https://login.microsoftonline.com'); - const aadInstance = service.getAADInstance(); - expect(aadInstance).toBe('https://login.microsoftonline.com/'); - }); - - it('should convert string to null', () => { - expect(service.convertStringToNull('null')).toBeNull(); - expect(service.convertStringToNull('undefined')).toBeNull(); - expect(service.convertStringToNull('test')).toBe('test'); - }); - - it('should capitalize first letter', () => { - expect(service.capitalizeFirstLetter('test')).toBe('Test'); - }); - - it('should capitalize first letter and join', () => { - expect(service.capitalizeFirstLetterAndJoin('test', 'case')).toBe('TestCase'); - }); - - it('should get default user icon', () => { - expect(service.getDefaultUserIcon('small')).toBe('/assets/useravatar24x24.png'); - expect(service.getDefaultUserIcon('medium')).toBe('/assets/useravatar32x32.png'); - }); - - it('should perform JSON copy', () => { - const obj = { a: 1, b: 2 }; - const copiedObj = service.jsonCopy(obj); - expect(copiedObj).toEqual(obj); - }); - - it('should compare JSON objects', () => { - const obj1 = { a: 1, b: 2 }; - const obj2 = { a: 1, b: 2 }; - const obj3 = { a: 2, b: 3 }; - expect(service.jsonEqual(obj1, obj2)).toBeTrue(); - expect(service.jsonEqual(obj1, obj3)).toBeFalse(); - }); - - it('should append params to link', () => { - const link = 'https://example.com'; - const params = { param1: 'value1', param2: 'value2' }; - const updatedLink = service.appendParamsToLink(link, params); - expect(updatedLink).toBe('https://example.com?param1=value1¶m2=value2&'); - }); - - it('should get Jira server ID', () => { - localStorage.setItem('jiraServer.jiraId', 'test-jira-id'); - const jiraId = service.getJiraServerId(); - expect(jiraId).toBe('test-jira-id'); - }); - - it('should get query param', () => { - const paramValue = service.getQueryParam('param1', 'https://example.com?param1=value1¶m2=value2'); - expect(paramValue).toBe('value1'); - }); - - it('should return empty string if query param is not found', () => { - const paramValue = service.getQueryParam('param3', 'https://example.com?param1=value1¶m2=value2'); - expect(paramValue).toBe(''); - }); - - it('should handle URL without query params', () => { - const paramValue = service.getQueryParam('param1', 'https://example.com'); - expect(paramValue).toBe(''); - }); - - it('should handle malformed URL', () => { - const paramValue = service.getQueryParam('param1', 'malformed-url'); - expect(paramValue).toBe(''); - }); - - it('should detect Electron environment', () => { - spyOnProperty(navigator, 'userAgent', 'get').and.returnValue('electron'); - expect(service.isElectron()).toBeTrue(); - }); - - it('should get minimum addon version', () => { - expect(service.getMinAddonVersion()).toBe('2022.08.103'); - }); - - it('should get upgrade addon message', () => { - expect(service.getUpgradeAddonMessage()) - .toBe('Please upgrade Jira Server for Microsoft Teams app on your Jira Data Center to perform projects search.'); - }); - - it('should check if addon is updated', () => { - spyOn(service, 'getMinAddonVersion').and.returnValue('2022.08.103'); - expect(service.isAddonUpdated('2022.08.104')).toBeTrue(); - }); -}); + public isAddonUpdated + = (addonVersion: string): boolean => compare(addonVersion, this.getMinAddonVersion(), '>='); + public isAddonUpdatedToVersion + = (addonVersion: string, minAddonVersion: string): boolean => compare(addonVersion, minAddonVersion, '>='); +} diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.ts index b123c16..8dc29a3 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/core/services/util.service.ts @@ -26,9 +26,13 @@ export class UtilService { { id: 6, value: 'updated-recently', label: 'Updated recently' } ]; - private readonly UPGRADE_ADDON_MESSAGE = - 'Please upgrade Jira Server for Microsoft Teams app on your Jira Data Center to perform projects search.'; + private readonly UPGRADE_ADDON_MESSAGE + = 'Please upgrade Jira Data Center for Microsoft Teams app on your Jira Data Center to perform projects search.'; + private readonly NOTIFICATIONS_UPGRADE_ADDON_MESSAGE + = 'Please upgrade Jira Data Center for Microsoft Teams app on your Jira Data Center' + + ' to receive notifications from Jira.'; private readonly ADDON_VERSION = '2022.08.103'; + private readonly NOTIFICATIONS_ADDON_VERSION = '2025.05.13'; public isMobile = async (): Promise => { const context = await microsoftTeams.app.getContext(); @@ -127,7 +131,13 @@ export class UtilService { public getMinAddonVersion = (): string => this.ADDON_VERSION; + public getMinAddonVersionForNotifications = (): string => this.NOTIFICATIONS_ADDON_VERSION; + public getUpgradeAddonMessage = (): string => this.UPGRADE_ADDON_MESSAGE; + public getUpgradeAddonMessageForNotifications = (): string => this.NOTIFICATIONS_UPGRADE_ADDON_MESSAGE; - public isAddonUpdated = (addonVersion: string): boolean => compare(addonVersion, this.getMinAddonVersion(), '>='); + public isAddonUpdated + = (addonVersion: string): boolean => compare(addonVersion, this.getMinAddonVersion(), '>='); + public isAddonUpdatedToVersion + = (addonVersion: string, minAddonVersion: string): boolean => compare(addonVersion, minAddonVersion, '>='); } diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/shared/services/dropdown.util.service.ts b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/shared/services/dropdown.util.service.ts index 6d91f50..816e573 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/shared/services/dropdown.util.service.ts +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/app/shared/services/dropdown.util.service.ts @@ -111,4 +111,12 @@ export class DropdownUtilService { label: transition.name }; } + + public mapTransitionToDropdownOptionString(transition: JiraTransition): DropDownOption { + return { + id: transition.id, + label: transition.name, + value: transition.id, + }; + } } diff --git a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/styles/angular-material.overrides.scss b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/styles/angular-material.overrides.scss index 2a93537..605ae40 100644 --- a/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/styles/angular-material.overrides.scss +++ b/src/MicrosoftTeamsIntegration.Jira/ClientApp/src/styles/angular-material.overrides.scss @@ -63,6 +63,25 @@ color: colors.$white; } +.mat-mdc-checkbox .mdc-label, .mdc-checkbox__background { + color: colors.$black !important; +} + +.mat-mdc-checkbox .mdc-label { + font-weight: normal !important; + margin-bottom: 0px !important; +} + +.mat-mdc-checkbox .mdc-checkbox__background { + border-color: colors.$app-brand-17 !important; +} + +.mat-mdc-checkbox .mdc-checkbox__background .mdc-checkbox__checkmark +{ + background-color: colors.$app-brand !important; + color: colors.$white; +} + .mat-mdc-radio-button.mat-mdc-radio-checked .mat-ripple-element { background-color: colors.$app-brand !important; } @@ -534,7 +553,21 @@ body.contrast { border-color: colors.$black !important; background-color: colors.$yellow !important; } + + .mat-mdc-checkbox .mdc-label, .mdc-checkbox__background { + color: colors.$white !important; + } + + .mat-mdc-checkbox .mdc-label { + color: colors.$white !important; + font-weight: normal !important; + } + .mat-mdc-checkbox .mdc-checkbox__background { + border-color: colors.$black !important; + background-color: colors.$yellow !important; + } + .mat-mdc-radio-button.mat-mdc-radio-checked .mat-ripple-element { background-color: colors.$yellow !important; } @@ -597,7 +630,12 @@ body.contrast { background-color: transparent !important; color: colors.$black !important; } - + + .mat-mdc-checkbox .mdc-checkbox__background .mdc-checkbox__checkmark { + background-color: transparent !important; + color: colors.$black !important; + } + .mat-mdc-radio-button.mat-accent.mat-radio-checked .mat-radio-outer-circle { border-color: colors.$yellow !important; } diff --git a/src/MicrosoftTeamsIntegration.Jira/Controllers/BaseApiController.cs b/src/MicrosoftTeamsIntegration.Jira/Controllers/BaseApiController.cs index 91ba705..67a7e0e 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Controllers/BaseApiController.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Controllers/BaseApiController.cs @@ -1,15 +1,32 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authorization; +using System; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; -using MicrosoftTeamsIntegration.Jira.Filters; +using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Jira.Exceptions; +using MicrosoftTeamsIntegration.Jira.Extensions; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using Refit; namespace MicrosoftTeamsIntegration.Jira.Controllers { - [Authorize(AuthenticationSchemes = "Bearer API")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = "Bearer API")] [ApiController] public class BaseApiController : ControllerBase { + private readonly IDatabaseService _databaseService; + private readonly IJiraAuthService _jiraAuthService; + + public BaseApiController(IDatabaseService databaseService, IJiraAuthService jiraAuthService) + { + _databaseService = databaseService; + _jiraAuthService = jiraAuthService; + } + protected string GetUserOid() => User.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier"); protected string GetUserTid() => User.FindFirstValue("http://schemas.microsoft.com/identity/claims/tenantid"); @@ -30,5 +47,54 @@ protected string GetUserAccessToken() return msIdToken; } + + protected async Task GetAndVerifyUser(string jiraUrl) + { + if (jiraUrl.HasValue()) + { + jiraUrl = Uri.UnescapeDataString(jiraUrl); + } + + var msTeamsUserId = GetUserOid(); + var user = await _databaseService.GetUserByTeamsUserIdAndJiraUrl(msTeamsUserId, jiraUrl); + var isJiraConnected = await _jiraAuthService.IsJiraConnected(user); + + if (!isJiraConnected) + { + throw new UnauthorizedException(); + } + + if (user != null) + { + user.AccessToken = GetUserAccessToken(); + } + + if (user.HasJiraAuthInfo()) + { + return user; + } + + if (user is null) + { + throw new UnauthorizedException(); + } + + var addonStatus = await _databaseService.GetJiraServerAddonSettingsByJiraId(jiraUrl); + + var message = user.HasJiraAuthInfo() + ? addonStatus.GetErrorMessage(jiraUrl) + : JiraConstants.UserNotAuthorizedMessage; + + var response = new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent(message) + }; + var exception = await ApiException.Create( + new HttpRequestMessage(), + HttpMethod.Post, + response, + new RefitSettings()); + throw exception; + } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Controllers/ClientAppController.cs b/src/MicrosoftTeamsIntegration.Jira/Controllers/ClientAppController.cs index e46467e..399716a 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Controllers/ClientAppController.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Controllers/ClientAppController.cs @@ -1,21 +1,48 @@ -using Microsoft.ApplicationInsights.Extensibility; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; using Microsoft.Extensions.Options; +using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Filters; using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Settings; +using Newtonsoft.Json; namespace MicrosoftTeamsIntegration.Jira.Controllers { + [JiraAuthentication] public class ClientAppController : BaseApiController { private readonly TelemetryConfiguration _telemetryConfiguration; private readonly AppSettings _appSettings; + private readonly INotificationSubscriptionService _notificationSubscriptionService; + private readonly IProactiveMessagesService _proactiveMessagesService; + private readonly IBotMessagesService _botMessagesService; + private readonly IDistributedCacheService _distributedCacheService; - public ClientAppController(IOptions appSettings, IOptions telemetryConfiguration) + public ClientAppController( + IOptions appSettings, + IOptions telemetryConfiguration, + INotificationSubscriptionService notificationSubscriptionService, + IProactiveMessagesService proactiveMessagesService, + IBotMessagesService botMessagesService, + IDistributedCacheService distributedCacheService, + IDatabaseService databaseService, + IJiraAuthService jiraAuthService) + : base(databaseService, jiraAuthService) { _telemetryConfiguration = telemetryConfiguration.Value; _appSettings = appSettings.Value; + _notificationSubscriptionService = notificationSubscriptionService; + _proactiveMessagesService = proactiveMessagesService; + _botMessagesService = botMessagesService; + _distributedCacheService = distributedCacheService; } [HttpGet("api/app-settings")] @@ -30,5 +57,109 @@ public ActionResult GetClientAppSettings() _appSettings.AnalyticsEnvironment); return Ok(settings); } + + [HttpPost("api/notificationSubscription/add")] + public async Task AddNotificationSubscription(string jiraServerId, NotificationSubscription notificationSubscription) + { + var user = await GetAndVerifyUser(jiraServerId); + await _notificationSubscriptionService.CreateNotificationSubscription( + user, + notificationSubscription, + notificationSubscription.ConversationReferenceId); + + return Ok(); + } + + [HttpGet("api/notificationSubscription/get")] + public async Task GetNotificationSubscriptionForUser(string jiraServerId) + { + var user = await GetAndVerifyUser(jiraServerId); + NotificationSubscription notificationSubscription = await _notificationSubscriptionService.GetNotificationSubscription(user); + + return Ok(notificationSubscription); + } + + [HttpGet("api/notificationSubscription/getAll")] + public async Task GetNotifications(string jiraServerId) + { + var user = await GetAndVerifyUser(jiraServerId); + IEnumerable notificationSubscription = + await _notificationSubscriptionService.GetNotifications(user); + + return Ok(notificationSubscription); + } + + [HttpGet("api/notificationSubscription/getAllByConversationId")] + public async Task GetNotificationsByConversationId(string jiraServerId, string conversationId) + { + await GetAndVerifyUser(jiraServerId); + + var notifications = await _notificationSubscriptionService.GetNotificationSubscriptionByConversationId(conversationId); + + return Ok(notifications); + } + + [HttpPut("api/notificationSubscription/update")] + public async Task UpdateNotificationSubscription(string jiraServerId, NotificationSubscription notificationSubscription) + { + var user = await GetAndVerifyUser(jiraServerId); + await _notificationSubscriptionService.UpdateNotificationSubscription( + user, + notificationSubscription, + notificationSubscription.ConversationReferenceId); + + return Ok(); + } + + [HttpPost("api/notificationSubscription/removePersonal")] + public async Task RemoveNotificationSubscriptionForUser(string jiraServerId) + { + var user = await GetAndVerifyUser(jiraServerId); + await _notificationSubscriptionService.DeleteNotificationSubscriptionByMicrosoftUserId(user); + + return Ok(); + } + + [HttpDelete("api/notificationSubscription/delete")] + public async Task DeleteNotificationSubscriptionBySubscriptionId(string jiraServerId, string subscriptionId) + { + var user = await GetAndVerifyUser(jiraServerId); + + await _notificationSubscriptionService.DeleteNotificationSubscriptionBySubscriptionId(user, subscriptionId); + + return Ok(); + } + + [HttpPost("api/notificationSubscription/sendChannelNotificationEvent")] + [AllowAnonymous] + public async Task SendChannelNotificationEvent(NotificationSubscriptionEvent subscriptionEvent) + { + string conversationReferenceJson; + + if (string.IsNullOrEmpty(subscriptionEvent.Subscription.ConversationReference)) + { + conversationReferenceJson = + await _distributedCacheService + .Get(subscriptionEvent.Subscription.ConversationReferenceId); + } + else + { + conversationReferenceJson = subscriptionEvent.Subscription.ConversationReference; + } + + ConversationReference conversationReference = + JsonConvert.DeserializeObject(conversationReferenceJson); + + var channelNotificationEventAdaptiveCard = _botMessagesService.BuildChannelNotificationConfigurationSummaryCard( + subscriptionEvent, conversationReference.User.Name); + + var activity = MessageFactory.Attachment(channelNotificationEventAdaptiveCard.ToAttachment()); + + await _proactiveMessagesService.SendActivity( + activity, + conversationReference); + + return Ok(); + } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Controllers/JiraApiController.cs b/src/MicrosoftTeamsIntegration.Jira/Controllers/JiraApiController.cs index ac77488..c62a090 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Controllers/JiraApiController.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Controllers/JiraApiController.cs @@ -5,13 +5,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using System.Web; using AutoMapper; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; using MicrosoftTeamsIntegration.Artifacts.Extensions; using MicrosoftTeamsIntegration.Artifacts.Models; using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; @@ -22,6 +20,7 @@ using MicrosoftTeamsIntegration.Jira.Models.Dto; using MicrosoftTeamsIntegration.Jira.Models.Jira; using MicrosoftTeamsIntegration.Jira.Models.Jira.Issue; +using MicrosoftTeamsIntegration.Jira.Services; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Settings; using Refit; @@ -33,7 +32,6 @@ namespace MicrosoftTeamsIntegration.Jira.Controllers public class JiraApiController : BaseApiController { private readonly IDatabaseService _databaseService; - private readonly AppSettings _appSettings; private readonly IJiraService _jiraService; private readonly IMapper _mapper; private readonly IJiraAuthService _jiraAuthService; @@ -43,19 +41,18 @@ public class JiraApiController : BaseApiController public JiraApiController( IDatabaseService databaseService, - IOptions appSettings, IJiraService jiraService, IMapper mapper, IJiraAuthService jiraAuthService, IDistributedCacheService distributedCacheService, ILogger logger, IOptions clientAppOptions) + : base(databaseService, jiraAuthService) { _databaseService = databaseService; _jiraService = jiraService; _mapper = mapper; _jiraAuthService = jiraAuthService; - _appSettings = appSettings.Value; _distributedCacheService = distributedCacheService; _logger = logger; _clientAppOptions = clientAppOptions.Value; @@ -504,6 +501,15 @@ public async Task GetTransitions(string jiraUrl, string issueIdOr return Ok(result); } + [HttpGet("issue/transitionsByProject")] + public async Task GetTransitionsByProject(string jiraUrl, string projectKeyOrId) + { + var user = await GetAndVerifyUser(jiraUrl); + var result = await _jiraService.GetTransitionsByProject(user, projectKeyOrId); + + return Ok(result); + } + [HttpPost("issue/transitions")] public async Task DoTransition(string jiraUrl, string issueIdOrKey, [FromBody] DoTransitionRequest model) { @@ -537,7 +543,7 @@ public async Task SubmitLoginInfo([FromBody] JiraAuthParamMessage if (shouldReturnError) { - var errorMessage = $"Please contact your Jira Data Center administrator and ask him to install Jira addon application."; + var errorMessage = $"Please contact your Jira Data Center administrator to install or update Jira Data Center for Microsoft Teams application."; var error = new ApiError(errorMessage); return BadRequest(error); } @@ -637,53 +643,26 @@ public async Task GetEpics(string jiraUrl, string projectKeyOrId) return Ok(result); } - private async Task GetAndVerifyUser(string jiraUrl) + [HttpGet("getJiraId")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + public async Task GetJiraId(string jiraUrl) { - if (jiraUrl.HasValue()) - { - jiraUrl = Uri.UnescapeDataString(jiraUrl); - } - - var msTeamsUserId = GetUserOid(); - var user = await _databaseService.GetUserByTeamsUserIdAndJiraUrl(msTeamsUserId, jiraUrl); - var isJiraConnected = await _jiraAuthService.IsJiraConnected(user); - - if (!isJiraConnected) - { - throw new UnauthorizedException(); - } - - if (user != null) - { - user.AccessToken = GetUserAccessToken(); - } - - if (user.HasJiraAuthInfo()) - { - return user; - } - - if (user is null) + using HttpClient httpClient = new HttpClient(); + var response = await httpClient.GetAsync($"{jiraUrl}/plugins/servlet/teams/getJiraServerId"); + if (response.IsSuccessStatusCode) { - throw new UnauthorizedException(); + string jiraIdResponse = await response.Content.ReadAsStringAsync(); + if (Guid.TryParse(jiraIdResponse.Trim(), out Guid jiraId)) + { + return Ok(jiraId.ToString()); + } } - var addonStatus = await _databaseService.GetJiraServerAddonSettingsByJiraId(jiraUrl); - - var message = user.HasJiraAuthInfo() - ? addonStatus.GetErrorMessage(jiraUrl) - : JiraConstants.UserNotAuthorizedMessage; - - var response = new HttpResponseMessage(HttpStatusCode.Forbidden) - { - Content = new StringContent(message) - }; - var exception = await ApiException.Create( - new HttpRequestMessage(), - HttpMethod.Post, - response, - new RefitSettings()); - throw exception; + var errorMessage = $"Can't get Jira Data Center ID. Please contact your Jira Data Center administrator to install or update Jira Data Center for Microsoft Teams application."; + var error = new ApiError(errorMessage); + return BadRequest(error); } private async Task GetDefaultMetadataMessage(string metadataRef) diff --git a/src/MicrosoftTeamsIntegration.Jira/Dialogs/DialogMatchesAndCommands.cs b/src/MicrosoftTeamsIntegration.Jira/Dialogs/DialogMatchesAndCommands.cs index 8f50cbd..a5d13aa 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Dialogs/DialogMatchesAndCommands.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Dialogs/DialogMatchesAndCommands.cs @@ -39,5 +39,11 @@ public static class DialogMatchesAndCommands public const string CommentIssueTaskModuleCommand = "commentIssue"; public const string SignoutMsAccountDialogCommand = "signout"; + + public const string ConfigureNotificationsCommand = "notifications"; + + public const string TurnOnNotificationsCommand = "turnOnNotifications"; + + public const string TurnOnChannelNotificationsCommand = "turnOnChannelNotifications"; } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Dialogs/DisconnectJiraDialog.cs b/src/MicrosoftTeamsIntegration.Jira/Dialogs/DisconnectJiraDialog.cs index 336a03c..921e850 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Dialogs/DisconnectJiraDialog.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Dialogs/DisconnectJiraDialog.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.ApplicationInsights; using Microsoft.Bot.Builder; @@ -24,13 +25,15 @@ public class DisconnectJiraDialog : ComponentDialog private readonly AppSettings _appSettings; private readonly TelemetryClient _telemetry; private readonly IAnalyticsService _analyticsService; + private readonly INotificationSubscriptionService _notificationSubscriptionService; public DisconnectJiraDialog( JiraBotAccessors accessors, IJiraAuthService jiraAuthService, AppSettings appSettings, TelemetryClient telemetry, - IAnalyticsService analyticsService) + IAnalyticsService analyticsService, + INotificationSubscriptionService notificationSubscriptionService) : base(nameof(DisconnectJiraDialog)) { _telemetry = telemetry; @@ -38,6 +41,7 @@ public DisconnectJiraDialog( _jiraAuthService = jiraAuthService; _appSettings = appSettings; _analyticsService = analyticsService; + _notificationSubscriptionService = notificationSubscriptionService; var waterfallSteps = new WaterfallStep[] { @@ -70,11 +74,14 @@ await stepContext.Context.SendActivityAsync( } _analyticsService.SendBotDialogEvent(stepContext.Context, "disconnectJira", "replied"); + var promptMessage = await _notificationSubscriptionService.GetNotificationSubscription(user) != null + ? BotMessages.JiraDisconnectDialogConfirmPromptWithNotificationSubscriptions + : BotMessages.JiraDisconnectDialogConfirmPrompt; return await stepContext.PromptAsync( ConfirmationPrompt, new PromptOptions { - Prompt = MessageFactory.Text(BotMessages.JiraDisconnectDialogConfirmPrompt) + Prompt = MessageFactory.Text(promptMessage) }, cancellationToken); } @@ -90,6 +97,9 @@ public async Task OnDisconnectJiraConfirmAsync(WaterfallStepCo var jiraId = user.JiraServerId; + // remove personal subscription if exists + await _notificationSubscriptionService.DeleteNotificationSubscriptionByMicrosoftUserId(user); + var result = await _jiraAuthService.Logout(user); if (result.IsSuccess) diff --git a/src/MicrosoftTeamsIntegration.Jira/Dialogs/Dispatcher/MainDispatcher.cs b/src/MicrosoftTeamsIntegration.Jira/Dialogs/Dispatcher/MainDispatcher.cs index 0c2a526..77220d0 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Dialogs/Dispatcher/MainDispatcher.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Dialogs/Dispatcher/MainDispatcher.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading; @@ -41,7 +41,8 @@ public MainDispatcher( IUserTokenService userTokenService, ICommandDialogReferenceService commandDialogReferenceService, IBotFrameworkAdapterService botFrameworkAdapter, - IAnalyticsService analyticsService) + IAnalyticsService analyticsService, + INotificationSubscriptionService notificationSubscriptionService) : base(nameof(MainDispatcher)) { _accessors = accessors; @@ -54,7 +55,7 @@ public MainDispatcher( _analyticsService = analyticsService; // Add dialogs - AddDialog(new HelpDialog(_accessors, _appSettings, telemetry, analyticsService)); + AddDialog(new HelpDialog(_accessors, _appSettings, telemetry, analyticsService, botMessagesService)); AddDialog(new IssueByKeyDialog(_accessors, botMessagesService, _appSettings, telemetry, analyticsService)); AddDialog(new WatchDialog(_accessors, jiraService, botMessagesService, _appSettings, telemetry, analyticsService)); AddDialog(new UnwatchDialog(_accessors, jiraService, botMessagesService, _appSettings, telemetry, analyticsService)); @@ -67,8 +68,9 @@ public MainDispatcher( AddDialog(new CommentDialog(_accessors, jiraService, _appSettings, telemetry, analyticsService)); AddDialog(new AssignDialog(_accessors, jiraService, _appSettings, botMessagesService, telemetry, analyticsService)); AddDialog(new ConnectToJiraDialog(_accessors, _appSettings, botMessagesService, telemetry, botFrameworkAdapter)); - AddDialog(new DisconnectJiraDialog(_accessors, jiraAuthService, _appSettings, telemetry, analyticsService)); + AddDialog(new DisconnectJiraDialog(_accessors, jiraAuthService, _appSettings, telemetry, analyticsService, notificationSubscriptionService)); AddDialog(new SignoutMsAccountDialog(_accessors, appSettings, telemetry, botFrameworkAdapter, analyticsService)); + AddDialog(new NotificationsDialog(_accessors, botMessagesService, _appSettings, telemetry, notificationSubscriptionService)); } protected override async Task OnBeginDialogAsync(DialogContext innerDc, object options, CancellationToken cancellationToken = default) diff --git a/src/MicrosoftTeamsIntegration.Jira/Dialogs/HelpDialog.cs b/src/MicrosoftTeamsIntegration.Jira/Dialogs/HelpDialog.cs index 5a6e02a..1477cea 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Dialogs/HelpDialog.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Dialogs/HelpDialog.cs @@ -1,6 +1,7 @@ -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Microsoft.ApplicationInsights; +using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using MicrosoftTeamsIntegration.Artifacts.Extensions; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; @@ -12,53 +13,30 @@ public class HelpDialog : Dialog { private readonly TelemetryClient _telemetry; private readonly IAnalyticsService _analyticsService; + private readonly IBotMessagesService _botMessagesService; - public HelpDialog(JiraBotAccessors accessors, AppSettings appSettings, TelemetryClient telemetry, IAnalyticsService analyticsService) + public HelpDialog( + JiraBotAccessors accessors, + AppSettings appSettings, + TelemetryClient telemetry, + IAnalyticsService analyticsService, + IBotMessagesService botMessagesService) : base(nameof(HelpDialog)) { _telemetry = telemetry; _analyticsService = analyticsService; + _botMessagesService = botMessagesService; } - public override async Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default) + public override async Task BeginDialogAsync( + DialogContext dc, + object options = null, + CancellationToken cancellationToken = default) { _telemetry.TrackPageView("HelpDialog"); - var message = "Here’s a list of the commands I can process:\n\n"; - var isGroup = dc.Context.Activity.IsGroupConversation(); - var jiraWording = "Jira Data Center instance"; - - message += (isGroup - ? string.Empty : - "type an issue key (e.g. **MP-47**) to view issue card with actions") + "\n\n" + - (isGroup - ? string.Empty - : $"**{DialogMatchesAndCommands.ConnectToJiraDialogCommand}** - connect a {jiraWording} to your Microsoft Teams account") + "\n\n" + - (isGroup - ? string.Empty - : $"**{DialogMatchesAndCommands.CreateNewIssueDialogCommand}** - create a new issue") + "\n\n" + - (isGroup - ? string.Empty : - $"**{DialogMatchesAndCommands.FindDialogCommand}** - obtain issue(s) by a summary search phrase or an issue key (e.g. **{DialogMatchesAndCommands.FindDialogCommand} MP-47** or **{DialogMatchesAndCommands.FindDialogCommand} search_phrase**)") + "\n\n" + - (isGroup - ? string.Empty - : $"**{DialogMatchesAndCommands.AssignDialogCommand}** - assign the issue to yourself (e.g. **{DialogMatchesAndCommands.AssignDialogCommand} MP-47**)") + "\n\n" + - (isGroup - ? string.Empty - : $"**{DialogMatchesAndCommands.IssueEditDialogCommand}** - open the issue card to change priority, summary, and description of the issue") + "\n\n" + - (isGroup - ? string.Empty - : $"**{DialogMatchesAndCommands.LogTimeDialogCommand}** - log time spent on the issue") + "\n\n" + - $"**{DialogMatchesAndCommands.WatchDialogCommand}** - start watching the issue (e.g. **{DialogMatchesAndCommands.WatchDialogCommand} MP-47**)\n\n" + - $"**{DialogMatchesAndCommands.UnwatchDialogCommand}** - stop watching the issue (e.g. **{DialogMatchesAndCommands.UnwatchDialogCommand} MP-47**)\n\n" + - $"**{DialogMatchesAndCommands.VoteDialogCommand}** - vote on the issue\n\n" + - $"**{DialogMatchesAndCommands.UnvoteDialogCommand}** - unvote on the issue\n\n" + - $"**{DialogMatchesAndCommands.CommentDialogCommand}** - comment the issue\n\n" + - (isGroup - ? string.Empty - : $"**{DialogMatchesAndCommands.DisconnectJiraDialogCommand}** - disconnect {jiraWording} you've connected from Microsoft Teams") + "\n\n" + - $"**{DialogMatchesAndCommands.CancelCommand}** - cancel current dialog\n\n" - ; + var card = _botMessagesService.BuildHelpCard(dc.Context); + var message = MessageFactory.Attachment(card.ToAttachment()); await dc.Context.SendActivityAsync(message, cancellationToken: cancellationToken); _analyticsService.SendBotDialogEvent(dc.Context, "help", "completed"); diff --git a/src/MicrosoftTeamsIntegration.Jira/Dialogs/NotificationsDialog.cs b/src/MicrosoftTeamsIntegration.Jira/Dialogs/NotificationsDialog.cs new file mode 100644 index 0000000..48ab0d7 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Dialogs/NotificationsDialog.cs @@ -0,0 +1,69 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Jira.Helpers; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Settings; + +namespace MicrosoftTeamsIntegration.Jira.Dialogs; + +public class NotificationsDialog : Dialog +{ + private readonly JiraBotAccessors _accessors; + private readonly IBotMessagesService _botMessagesService; + private readonly AppSettings _appSettings; + private readonly TelemetryClient _telemetry; + private readonly INotificationSubscriptionService _notificationSubscriptionService; + + public NotificationsDialog( + JiraBotAccessors accessors, + IBotMessagesService botMessagesService, + AppSettings appSettings, + TelemetryClient telemetry, + INotificationSubscriptionService notificationSubscriptionService) + : base(nameof(NotificationsDialog)) + { + _accessors = accessors; + _botMessagesService = botMessagesService; + _appSettings = appSettings; + _telemetry = telemetry; + _notificationSubscriptionService = notificationSubscriptionService; + } + + public override async Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default) + { + _telemetry.TrackPageView("NotificationsDialog"); + + var user = await JiraBotAccessorsHelper.GetUser(_accessors, dc.Context, _appSettings, cancellationToken); + + // if user was not connected, do nothing + if (user == null) + { + return await dc.EndDialogAsync(cancellationToken: cancellationToken); + } + + if (dc.Context.Activity.IsGroupConversation()) + { + await _botMessagesService.SendConfigureNotificationsCard(dc.Context, cancellationToken); + } + else + { + var personalSubscription = await _notificationSubscriptionService.GetNotificationSubscription(user); + if (personalSubscription != null && personalSubscription.IsActive && personalSubscription.EventTypes.Length != 0) + { + var adaptiveCard = _botMessagesService.BuildNotificationConfigurationSummaryCard(personalSubscription); + var message = MessageFactory.Attachment(adaptiveCard.ToAttachment()); + await dc.Context.SendToDirectConversationAsync(message, cancellationToken: cancellationToken); + } + else + { + await _botMessagesService.SendConfigureNotificationsCard(dc.Context, cancellationToken); + } + } + + return await dc.EndDialogAsync(cancellationToken: cancellationToken); + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/GatewayHub.cs b/src/MicrosoftTeamsIntegration.Jira/GatewayHub.cs index 103bc6b..c9ce159 100644 --- a/src/MicrosoftTeamsIntegration.Jira/GatewayHub.cs +++ b/src/MicrosoftTeamsIntegration.Jira/GatewayHub.cs @@ -1,5 +1,4 @@ using System; -using System.Threading; using System.Threading.Tasks; using System.Web; using JetBrains.Annotations; @@ -34,20 +33,47 @@ public Task Callback(Guid identifier, string response) identifier.ToString(), Context.ConnectionId, response, - Thread.CurrentThread.ManagedThreadId.ToString()); + Environment.CurrentManagedThreadId.ToString()); return _signalRService.Callback(identifier, response); } + [UsedImplicitly] + public Task Broadcast(Guid identifier, string response) + { + _logger.LogTrace( + "Broadcast: {Identifier} | {ConnectionId} | {ResponseMessage} | {CurrentThreadId}", + identifier.ToString(), + Context.ConnectionId, + response, + Environment.CurrentManagedThreadId.ToString()); + + return _signalRService.Broadcast(identifier, response); + } + + [UsedImplicitly] + public Task Notification(Guid identifier, string response) + { + _logger.LogTrace( + "Notification: {Identifier} | {ConnectionId} | {ResponseMessage} | {CurrentThreadId}", + identifier.ToString(), + Context.ConnectionId, + response, + Environment.CurrentManagedThreadId.ToString()); + + return _signalRService.Notification(identifier, response); + } + public override async Task OnConnectedAsync() { - var queryString = Context.GetHttpContext()?.Request?.QueryString; + var queryString = Context.GetHttpContext()?.Request.QueryString; if (queryString.HasValue) { var uriComponent = queryString.Value.ToUriComponent(); var jiraId = HttpUtility.ParseQueryString(uriComponent).Get("atlasId"); var jiraInstanceUrl = HttpUtility.ParseQueryString(uriComponent).Get("atlasUrl"); var version = HttpUtility.ParseQueryString(uriComponent).Get("pluginVersion"); + var groupName = HttpUtility.ParseQueryString(uriComponent).Get("groupName"); _logger.LogTrace( "OnConnectedAsync: {ConnectionId} | {JiraId} | {JiraInstanceUrl} | {Version} | {CurrentThreadId}", @@ -55,12 +81,24 @@ public override async Task OnConnectedAsync() jiraId, jiraInstanceUrl, version, - Thread.CurrentThread.ManagedThreadId.ToString()); + Environment.CurrentManagedThreadId.ToString()); if (!string.IsNullOrEmpty(jiraId) && jiraId != "null") { await _databaseService.CreateOrUpdateJiraServerAddonSettings(jiraId, jiraInstanceUrl, Context.ConnectionId, version); } + + if (!string.IsNullOrEmpty(groupName) && groupName != "null") + { + try + { + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + } + catch (Exception e) + { + _logger.LogError(e, "Cannot add client to the group: {GroupName}", groupName); + } + } } await base.OnConnectedAsync(); @@ -71,7 +109,7 @@ public override async Task OnDisconnectedAsync(Exception exception) _logger.LogTrace( "OnDisconnectedAsync: {ConnectionId} | {CurrentThreadId}", Context.ConnectionId, - Thread.CurrentThread.ManagedThreadId.ToString()); + Environment.CurrentManagedThreadId.ToString()); await _databaseService.DeleteJiraServerAddonSettingsByConnectionId(Context.ConnectionId); await base.OnDisconnectedAsync(exception); diff --git a/src/MicrosoftTeamsIntegration.Jira/Helpers/JiraUrlQueryBuilder.cs b/src/MicrosoftTeamsIntegration.Jira/Helpers/JiraUrlQueryBuilder.cs index a24d63b..e34ca18 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Helpers/JiraUrlQueryBuilder.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Helpers/JiraUrlQueryBuilder.cs @@ -36,6 +36,18 @@ public JiraUrlQueryBuilder Edit() return this; } + public JiraUrlQueryBuilder PersonalNotifications() + { + _jiraUrlStringBuilder.Append("/#/notifications/configure-personal"); + return this; + } + + public JiraUrlQueryBuilder ChannelNotifications() + { + _jiraUrlStringBuilder.Append("/#/notifications/configure-channel"); + return this; + } + public JiraUrlQueryBuilder JiraUrl(string jiraUrl) { _jiraUrlStringBuilder.Append($";jiraUrl={Uri.EscapeDataString(jiraUrl ?? string.Empty)}"); @@ -90,6 +102,24 @@ public JiraUrlQueryBuilder ReplyToActivityId(string replyToActivityId) return this; } + public JiraUrlQueryBuilder MicrosoftUserId(string userId) + { + _jiraUrlStringBuilder.Append($";microsoftUserId={userId}"); + return this; + } + + public JiraUrlQueryBuilder ConversationId(string conversationId) + { + _jiraUrlStringBuilder.Append($";conversationId={conversationId}"); + return this; + } + + public JiraUrlQueryBuilder ConversationReferenceId(string conversationReferenceId) + { + _jiraUrlStringBuilder.Append($";conversationReferenceId={conversationReferenceId}"); + return this; + } + public string Build() { return _jiraUrlStringBuilder.ToString(); diff --git a/src/MicrosoftTeamsIntegration.Jira/JiraBot.cs b/src/MicrosoftTeamsIntegration.Jira/JiraBot.cs index 6910cdd..a6733bd 100644 --- a/src/MicrosoftTeamsIntegration.Jira/JiraBot.cs +++ b/src/MicrosoftTeamsIntegration.Jira/JiraBot.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Text.RegularExpressions; using System.Threading; @@ -32,6 +32,7 @@ public class JiraBot : TeamsActivityHandler private readonly JiraBotAccessors _accessors; private readonly IMessagingExtensionService _messagingExtensionService; private readonly IDatabaseService _databaseService; + private readonly INotificationSubscriptionService _notificationSubscriptionService; private readonly IBotMessagesService _botMessagesService; private readonly IJiraService _jiraService; private readonly IActionableMessageService _actionableMessageService; @@ -50,6 +51,7 @@ public class JiraBot : TeamsActivityHandler public JiraBot( IMessagingExtensionService messagingExtensionService, IDatabaseService databaseService, + INotificationSubscriptionService notificationSubscriptionService, JiraBotAccessors accessors, IBotMessagesService botMessagesService, IJiraService jiraService, @@ -67,6 +69,7 @@ public JiraBot( _accessors = accessors; _messagingExtensionService = messagingExtensionService; _databaseService = databaseService; + _notificationSubscriptionService = notificationSubscriptionService; _botMessagesService = botMessagesService; _jiraService = jiraService; _actionableMessageService = actionableMessageService; @@ -94,7 +97,8 @@ public JiraBot( _userTokenService, _commandDialogReferenceService, _botFrameworkAdapterService, - _analyticsService)); + _analyticsService, + _notificationSubscriptionService)); } public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) @@ -242,7 +246,8 @@ protected override async Task OnSignInInvokeAsync( _userTokenService, _commandDialogReferenceService, _botFrameworkAdapterService, - _analyticsService).RunAsync( + _analyticsService, + _notificationSubscriptionService).RunAsync( turnContext, _accessors.ConversationDialogState, cancellationToken); @@ -266,6 +271,7 @@ protected override async Task OnSignInInvokeAsync( if (accessToken?.Token != null) { await _actionableMessageService.HandleSuccessfulConnection(turnContext); + await _botMessagesService.SendConfigureNotificationsCard(turnContext, cancellationToken); _analyticsService.SendBotDialogEvent(turnContext, "connectToJira", "completed"); } } @@ -448,7 +454,7 @@ await _messagingExtensionService.HandleMessagingExtensionQueryLinkAsync( } else { - var response = _messagingExtensionService.HandleBotFetchTask(turnContext, user); + var response = await _messagingExtensionService.HandleBotFetchTask(turnContext, user); await BuildInvokeResponse(turnContext, HttpStatusCode.OK, response, cancellationToken); } } diff --git a/src/MicrosoftTeamsIntegration.Jira/JiraMappingProfile.cs b/src/MicrosoftTeamsIntegration.Jira/JiraMappingProfile.cs index 121b4e1..217146f 100644 --- a/src/MicrosoftTeamsIntegration.Jira/JiraMappingProfile.cs +++ b/src/MicrosoftTeamsIntegration.Jira/JiraMappingProfile.cs @@ -6,6 +6,7 @@ using MicrosoftTeamsIntegration.Jira.Models; using MicrosoftTeamsIntegration.Jira.Models.Dto; using MicrosoftTeamsIntegration.Jira.Models.Jira.Issue; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; using MicrosoftTeamsIntegration.Jira.Settings; using MicrosoftTeamsIntegration.Jira.TypeConverters; @@ -41,6 +42,9 @@ public JiraMappingProfile( CreateMap() .ConvertUsing(new JiraIssueToMessagingExtensionAttachmentTypeConverter()); + + CreateMap() + .ConvertUsing(new NotificationMessageToAdaptiveCardConverter()); } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Jobs/NotificationJob.cs b/src/MicrosoftTeamsIntegration.Jira/Jobs/NotificationJob.cs new file mode 100644 index 0000000..ad9bb6d --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Jobs/NotificationJob.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using Newtonsoft.Json; +using Quartz; + +namespace MicrosoftTeamsIntegration.Jira.Jobs; + +public class NotificationJob : IJob +{ + private readonly ILogger _logger; + private readonly INotificationProcessorService _notificationProcessorService; + private readonly INotificationQueueService _notificationQueueService; + public NotificationJob( + INotificationProcessorService notificationProcessorService, + INotificationQueueService notificationQueueService, + ILogger logger) + { + _notificationProcessorService = notificationProcessorService; + _notificationQueueService = notificationQueueService; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + try + { + var messages = await _notificationQueueService.DequeueNotificationsMessages(); + foreach (var message in messages) + { + var notificationMessage = message.MessageText; + var notification = JsonConvert.DeserializeObject(notificationMessage); + await _notificationProcessorService.ProcessNotification(notification); + + // Delete the message from the queue after processing + await _notificationQueueService.DeleteNotificationMessage(message.MessageId, message.PopReceipt); + } + } + catch (Exception e) + { + _logger.LogError(e, "Cannot process notification messages from queue"); + } + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/MicrosoftTeamsIntegration.Jira.csproj b/src/MicrosoftTeamsIntegration.Jira/MicrosoftTeamsIntegration.Jira.csproj index be62317..4ad6b60 100644 --- a/src/MicrosoftTeamsIntegration.Jira/MicrosoftTeamsIntegration.Jira.csproj +++ b/src/MicrosoftTeamsIntegration.Jira/MicrosoftTeamsIntegration.Jira.csproj @@ -1,4 +1,4 @@ - + net9.0 True @@ -18,25 +18,30 @@ + - - + + - - - - - - - - + + + + + + + + + + - - + + + + - runtime; build; native; contentfiles; analyzers; buildtransitive + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/MicrosoftTeamsIntegration.Jira/Models/NotificationSubscription.cs b/src/MicrosoftTeamsIntegration.Jira/Models/NotificationSubscription.cs new file mode 100644 index 0000000..f729615 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Models/NotificationSubscription.cs @@ -0,0 +1,72 @@ +using System; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.IdGenerators; + +namespace MicrosoftTeamsIntegration.Jira.Models; + +[Serializable] +public sealed class NotificationSubscription +{ + [BsonId(IdGenerator = typeof(StringObjectIdGenerator))] + [BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)] + [BsonElement("subscriptionId")] + public string SubscriptionId { get; set; } + + [BsonElement("jiraId")] + public string JiraId { get; set; } + + [BsonElement("subscriptionType")] + public SubscriptionType SubscriptionType { get; set; } + + [BsonElement("conversationId")] + public string ConversationId { get; set; } + + [BsonElement("conversationReference")] + public string ConversationReference { get; set; } + + [BsonIgnore] + public string ConversationReferenceId { get; set; } + + [BsonElement("eventTypes")] + public string[] EventTypes { get; set; } + + [BsonElement("projectId")] + public string ProjectId { get; set; } + + [BsonElement("projectName")] + public string ProjectName { get; set; } + + [BsonElement("microsoftUserId")] + public string MicrosoftUserId { get; set; } + + [BsonElement("filter")] + public string Filter { get; set; } + + [BsonElement("isActive")] + public bool IsActive { get; set; } +} + +public enum SubscriptionType +{ + Personal = 0, + Channel = 1 +} + +public enum PersonalEventType +{ + ActivityIssueCreator, + CommentIssueCreator, + ActivityIssueAssignee, + CommentIssueAssignee, + IssueViewer, + MentionedOnIssue, + CommentViewer +} + +public enum ChannelEventType +{ + IssueCreated, + IssueUpdated, + CommentCreated, + CommentUpdated +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Models/NotificationSubscriptionEvent.cs b/src/MicrosoftTeamsIntegration.Jira/Models/NotificationSubscriptionEvent.cs new file mode 100644 index 0000000..dfff17f --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Models/NotificationSubscriptionEvent.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace MicrosoftTeamsIntegration.Jira.Models; + +public class NotificationSubscriptionEvent +{ + [JsonProperty("subscription")] + public NotificationSubscription Subscription { get; set; } + + [JsonProperty("action")] + public SubscriptionAction Action { get; set; } +} + +public enum SubscriptionAction +{ + Created, + Updated, + Deleted, + Enabled, + Disabled +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationEventType.cs b/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationEventType.cs new file mode 100644 index 0000000..96c7c0f --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationEventType.cs @@ -0,0 +1,58 @@ +namespace MicrosoftTeamsIntegration.Jira.Models.Notifications +{ + public enum NotificationEventType + { + Unknown, + IssueAssigned, + IssueUpdated, + IssueCreated, + CommentCreated, + CommentUpdated, + CommentDeleted + } + + public static class NotificationEventHelper + { + public static string ToEventTypeString(this NotificationEventType eventType) + { + switch (eventType) + { + case NotificationEventType.IssueAssigned: + return "ISSUE_ASSIGNED"; + case NotificationEventType.IssueUpdated: + return "ISSUE_UPDATED"; + case NotificationEventType.IssueCreated: + return "ISSUE_CREATED"; + case NotificationEventType.CommentCreated: + return "COMMENT_CREATED"; + case NotificationEventType.CommentUpdated: + return "COMMENT_UPDATED"; + case NotificationEventType.CommentDeleted: + return "COMMENT_DELETED"; + default: + return "UNKNOWN"; + } + } + + public static NotificationEventType ToEventType(this string eventType) + { + switch (eventType.ToUpperInvariant()) + { + case "ISSUE_ASSIGNED": + return NotificationEventType.IssueAssigned; + case "ISSUE_UPDATED": + return NotificationEventType.IssueUpdated; + case "ISSUE_CREATED": + return NotificationEventType.IssueCreated; + case "COMMENT_CREATED": + return NotificationEventType.CommentCreated; + case "COMMENT_UPDATED": + return NotificationEventType.CommentUpdated; + case "COMMENT_DELETED": + return NotificationEventType.CommentDeleted; + default: + return NotificationEventType.Unknown; + } + } + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationMessage.cs b/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationMessage.cs new file mode 100644 index 0000000..8e72885 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationMessage.cs @@ -0,0 +1,105 @@ +using System; +using Newtonsoft.Json; + +namespace MicrosoftTeamsIntegration.Jira.Models.Notifications; + +public class NotificationMessage +{ + [JsonProperty("jiraId")] + public string JiraId { get; set; } + + [JsonProperty("eventType")] + public string EventType { get; set; } + + [JsonProperty("user")] + public NotificationUser User { get; set; } + + [JsonProperty("issue")] + public NotificationIssue Issue { get; set; } + + [JsonProperty("changelog")] + public NotificationChangelog[] Changelog { get; set; } + + [JsonProperty("comment")] + public NotificationComment Comment { get; set; } + + [JsonProperty("watchers")] + public NotificationUser[] Watchers { get; set; } + + [JsonProperty("mentions")] + public NotificationUser[] Mentions { get; set; } +} + +public class NotificationChangelog +{ + [JsonProperty("field")] + public string Field { get; set; } + + [JsonProperty("from")] + public string From { get; set; } + + [JsonProperty("to")] + public string To { get; set; } +} + +public class NotificationComment +{ + [JsonProperty("content")] + public string Content { get; set; } + [JsonProperty("isInternal")] + public bool IsInternal { get; set; } +} + +public class NotificationIssue +{ + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("key")] + public string Key { get; set; } + + [JsonProperty("summary")] + public string Summary { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("assignee")] + public NotificationUser Assignee { get; set; } + + [JsonProperty("reporter")] + public NotificationUser Reporter { get; set; } + + [JsonProperty("priority")] + public string Priority { get; set; } + + [JsonProperty("self")] + public Uri Self { get; set; } + + [JsonProperty("projectID")] + public int ProjectId { get; set; } +} + +public class NotificationUser +{ + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("microsoftId")] + public string MicrosoftId { get; set; } + + [JsonProperty("avatarUrl")] + public Uri AvatarUrl { get; set; } + + [JsonProperty("canViewIssue")] + public bool CanViewIssue { get; set; } + + [JsonProperty("canViewComment")] + public bool CanViewComment { get; set; } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationMessageCardPayload.cs b/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationMessageCardPayload.cs new file mode 100644 index 0000000..2058ebe --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Models/Notifications/NotificationMessageCardPayload.cs @@ -0,0 +1,88 @@ +using System.Linq; + +namespace MicrosoftTeamsIntegration.Jira.Models.Notifications; + +public class NotificationMessageCardPayload : NotificationMessage +{ + public bool IsMention { get; set; } + public bool IsPersonalNotification { get; set; } + + public NotificationMessageCardPayload(NotificationMessage notification) + { + JiraId = notification.JiraId; + EventType = notification.EventType; + User = new NotificationUser + { + Name = notification.User.Name, + Id = notification.User.Id, + MicrosoftId = notification.User.MicrosoftId, + AvatarUrl = notification.User.AvatarUrl, + CanViewIssue = notification.User.CanViewIssue, + CanViewComment = notification.User.CanViewComment + }; + Issue = new NotificationIssue + { + Id = notification.Issue.Id, + Key = notification.Issue.Key, + Summary = notification.Issue.Summary, + Status = notification.Issue.Status, + Type = notification.Issue.Type, + Assignee = notification.Issue.Assignee != null + ? new NotificationUser + { + Name = notification.Issue.Assignee.Name, + Id = notification.Issue.Assignee.Id, + MicrosoftId = notification.Issue.Assignee.MicrosoftId, + AvatarUrl = notification.Issue.Assignee.AvatarUrl, + CanViewIssue = notification.Issue.Assignee.CanViewIssue, + CanViewComment = notification.Issue.Assignee.CanViewComment + } + : null, + Reporter = notification.Issue.Reporter != null + ? new NotificationUser + { + Name = notification.Issue.Reporter.Name, + Id = notification.Issue.Reporter.Id, + MicrosoftId = notification.Issue.Reporter.MicrosoftId, + AvatarUrl = notification.Issue.Reporter.AvatarUrl, + CanViewIssue = notification.Issue.Reporter.CanViewIssue, + CanViewComment = notification.Issue.Reporter.CanViewComment + } + : null, + Priority = notification.Issue.Priority, + Self = notification.Issue.Self, + ProjectId = notification.Issue.ProjectId + }; + Changelog = notification.Changelog?.Select(c => new NotificationChangelog + { + Field = c.Field, + From = c.From, + To = c.To + }).ToArray(); + Comment = notification.Comment != null + ? new NotificationComment + { + Content = notification.Comment.Content, + IsInternal = notification.Comment.IsInternal + } + : null; + Watchers = notification.Watchers?.Select(w => new NotificationUser + { + Name = w.Name, + Id = w.Id, + MicrosoftId = w.MicrosoftId, + AvatarUrl = w.AvatarUrl, + CanViewIssue = w.CanViewIssue, + CanViewComment = w.CanViewComment + }).ToArray(); + Mentions = notification.Mentions?.Select(m => new NotificationUser + { + Name = m.Name, + Id = m.Id, + MicrosoftId = m.MicrosoftId, + AvatarUrl = m.AvatarUrl, + CanViewIssue = m.CanViewIssue, + CanViewComment = m.CanViewComment + }).ToArray(); + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Models/NotificationsAnalyticsEventAttribute.cs b/src/MicrosoftTeamsIntegration.Jira/Models/NotificationsAnalyticsEventAttribute.cs new file mode 100644 index 0000000..d3fae1e --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Models/NotificationsAnalyticsEventAttribute.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace MicrosoftTeamsIntegration.Jira.Models; + +public class NotificationsAnalyticsEventAttribute : IAnalyticsEventAttribute +{ + [JsonProperty("notificationEventType")] + public string NotificationEventType { get; set; } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Program.cs b/src/MicrosoftTeamsIntegration.Jira/Program.cs index ecb7db1..d4f7f38 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Program.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Program.cs @@ -1,7 +1,9 @@ using System; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Jira.Services.SignalR; namespace MicrosoftTeamsIntegration.Jira { @@ -19,6 +21,10 @@ private static IHostBuilder CreateHostBuilder(string[] args) => { webBuilder.ConfigureMicrosoftTeamsIntegrationDefaults(); webBuilder.UseStartup(); + }) + .ConfigureServices(s => + { + s.AddHostedService(); }); } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/BotMessagesService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/BotMessagesService.cs index fa7c410..49cd53e 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/BotMessagesService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/BotMessagesService.cs @@ -11,8 +11,12 @@ using Microsoft.Bot.Schema; using Microsoft.Extensions.Options; using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Jira.Dialogs; using MicrosoftTeamsIntegration.Jira.Extensions; using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Bot; +using MicrosoftTeamsIntegration.Jira.Models.FetchTask; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Settings; @@ -266,6 +270,417 @@ public async Task SendConnectCard(ITurnContext turnContext, CancellationToken ca await turnContext.SendToDirectConversationAsync(message, cancellationToken: cancellationToken); } + public async Task SendConfigureNotificationsCard(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + var adaptiveCard = BuildConfigureNotificationsCard(turnContext); + + var message = MessageFactory.Attachment(adaptiveCard.ToAttachment()); + + if (turnContext.Activity.IsGroupConversation()) + { + await turnContext.SendActivityAsync(message, cancellationToken: cancellationToken); + } + else + { + await turnContext.SendToDirectConversationAsync(message, cancellationToken: cancellationToken); + } + } + + public AdaptiveCard BuildConfigureNotificationsCard(ITurnContext turnContext) + { + bool isGroupConversation = turnContext.Activity.IsGroupConversation(); + + string title = isGroupConversation ? "🔔 Channel notifications" : "🔔 Personal notifications"; + string turnOnCommandName = isGroupConversation + ? DialogMatchesAndCommands.TurnOnChannelNotificationsCommand + : DialogMatchesAndCommands.TurnOnNotificationsCommand; + + var adaptiveCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 3)) + { + Body = new List() + { + new AdaptiveTextBlock + { + Text = title, + Size = AdaptiveTextSize.Medium, + Weight = AdaptiveTextWeight.Bolder, + Wrap = true + }, + new AdaptiveTextBlock + { + Text = isGroupConversation + ? "Manage your project notifications for this channel. I’ll send instant updates to keep your team in sync." + : "Turn on personal notifications to stay updated across your projects in Jira Data Center without the distraction of email notifications.", + Wrap = true + } + }, + Actions = new List() + { + new AdaptiveSubmitAction + { + Title = isGroupConversation ? "Manage notifications" : "Turn on notifications", + Style = "positive", + Data = new JiraBotTeamsDataWrapper + { + FetchTaskData = new FetchTaskBotCommand(turnOnCommandName), + TeamsData = new TeamsData + { + Type = "task/fetch" + } + } + } + } + }; + return adaptiveCard; + } + + public async Task SendNotificationCard( + ITurnContext turnContext, + NotificationMessage notificationMessage, + CancellationToken cancellationToken = default) + { + var adaptiveCard = _mapper.Map(notificationMessage); + + var message = MessageFactory.Attachment(adaptiveCard.ToAttachment()); + + await turnContext.SendToDirectConversationAsync(message, cancellationToken: cancellationToken); + } + + public AdaptiveCard BuildNotificationConfigurationSummaryCard(NotificationSubscription subscription, bool showSuccessMessage = false) + { + List eventTypes + = subscription.EventTypes.AsEnumerable().Select(x + => Enum.Parse(x)).ToList(); + + var adaptiveCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 4)); + + adaptiveCard.Body = new List() + { + new AdaptiveContainer() + { + Items = new List() + { + new AdaptiveTextBlock() + { + Wrap = true, + Text = "You successfully subscribed to notifications from Jira \ud83e\udd73", + IsVisible = showSuccessMessage + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = $"{(showSuccessMessage ? string.Empty : "\ud83d\udd14 ")}" + + $"You will receive notifications when there are:" + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Updates on issues you **assigned** to", + IsVisible = eventTypes.Contains(PersonalEventType.ActivityIssueAssignee) + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Comments on issues you **assigned** to", + IsVisible = eventTypes.Contains(PersonalEventType.CommentIssueAssignee) + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Updates on issues you've **reported**", + IsVisible = eventTypes.Contains(PersonalEventType.ActivityIssueCreator) + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Comments on issues you've **reported**", + IsVisible = eventTypes.Contains(PersonalEventType.CommentIssueCreator) + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Updates on issues that you are **watching**", + IsVisible = eventTypes.Contains(PersonalEventType.IssueViewer) + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Comments on issues that you are **watching**", + IsVisible = eventTypes.Contains(PersonalEventType.CommentViewer) + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* Someone **mentioned** you in a comment or issue", + IsVisible = eventTypes.Contains(PersonalEventType.MentionedOnIssue) + } + } + } + }; + + adaptiveCard.AdditionalProperties = new SerializableDictionary + { + { "msTeams", new { width = "full" } } + }; + adaptiveCard.Actions = new List() + { + new AdaptiveSubmitAction + { + Title = "Change notifications", + Style = "positive", + Data = new JiraBotTeamsDataWrapper + { + FetchTaskData = new FetchTaskBotCommand(DialogMatchesAndCommands.TurnOnNotificationsCommand), + TeamsData = new TeamsData + { + Type = "task/fetch" + } + } + } + }; + + return adaptiveCard; + } + + public AdaptiveCard BuildChannelNotificationConfigurationSummaryCard( + NotificationSubscriptionEvent subscriptionEvent, + string callerName) + { + string title = string.Empty; + + List eventTypes + = subscriptionEvent.Subscription.EventTypes.AsEnumerable().Select(x + => Enum.Parse(x)).ToList(); + + bool showEventListMessage = subscriptionEvent.Action != SubscriptionAction.Deleted + && subscriptionEvent.Action != SubscriptionAction.Disabled + && eventTypes.Count > 0; + + switch (subscriptionEvent.Action) + { + case SubscriptionAction.Created: + title = $"**{callerName}** has set up channel notifications for **{subscriptionEvent.Subscription.ProjectName}** project"; + break; + case SubscriptionAction.Updated: + title = $"**{callerName}** has updated channel notifications for **{subscriptionEvent.Subscription.ProjectName}** project"; + break; + case SubscriptionAction.Deleted: + title = $"**{callerName}** has removed channel notifications for **{subscriptionEvent.Subscription.ProjectName}** project"; + break; + case SubscriptionAction.Enabled: + title = $"**{callerName}** has enabled channel notifications for **{subscriptionEvent.Subscription.ProjectName}** project"; + break; + case SubscriptionAction.Disabled: + title = $"**{callerName}** has disabled channel notifications for **{subscriptionEvent.Subscription.ProjectName}** project"; + break; + } + + var adaptiveCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 4)); + + adaptiveCard.Body = new List() + { + new AdaptiveContainer() + { + Items = new List() + { + new AdaptiveTextBlock() + { + Wrap = true, + Text = title, + IsVisible = true + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = $"You will now get a message when someone:", + IsVisible = showEventListMessage + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* **Created comment** on issue", + IsVisible = eventTypes.Contains(ChannelEventType.CommentCreated) + && showEventListMessage + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* **Updated comment** on issue", + IsVisible = eventTypes.Contains(ChannelEventType.CommentUpdated) + && showEventListMessage + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* **Created issue**", + IsVisible = eventTypes.Contains(ChannelEventType.IssueCreated) + && showEventListMessage + }, + new AdaptiveTextBlock() + { + Wrap = true, + Text = "* **Updated issue**", + IsVisible = eventTypes.Contains(ChannelEventType.IssueUpdated) + && showEventListMessage + }, + } + } + }; + + adaptiveCard.AdditionalProperties = new SerializableDictionary + { + { "msTeams", new { width = "full" } } + }; + adaptiveCard.Actions = new List() + { + new AdaptiveSubmitAction + { + Title = "Manage notifications", + Style = "positive", + Data = new JiraBotTeamsDataWrapper + { + FetchTaskData = new FetchTaskBotCommand(DialogMatchesAndCommands.TurnOnChannelNotificationsCommand), + TeamsData = new TeamsData + { + Type = "task/fetch" + } + } + } + }; + + return adaptiveCard; + } + + public AdaptiveCard BuildHelpCard(ITurnContext turnContext) + { + var isGroup = turnContext.Activity.IsGroupConversation(); + var adaptiveCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 4)); + + adaptiveCard.Body = new List() + { + new AdaptiveContainer() + { + Items = new List() + { + new AdaptiveTextBlock() + { + Text = "How I can help you?", + Size = AdaptiveTextSize.Large, + Weight = AdaptiveTextWeight.Bolder + }, + new AdaptiveTextBlock() + { + Text = "Here’s a list of the commands I can process:", + Wrap = true + }, + CreateActionColumnSet( + "Connect", + "Connect a Jira Data Center instance to your Microsoft Teams account", + DialogMatchesAndCommands.ConnectToJiraDialogCommand, + !isGroup, + false), + CreateActionColumnSet( + "Create", + "Create a new issue", + DialogMatchesAndCommands.CreateNewIssueDialogCommand, + !isGroup, + true), + CreateActionColumnSet( + "Notifications", + $"Set up {(isGroup ? "channel" : "personal")} notifications here in Teams", + isGroup ? DialogMatchesAndCommands.TurnOnChannelNotificationsCommand : DialogMatchesAndCommands.TurnOnNotificationsCommand, + true, + true), + CreateActionColumnSet( + "Find", + $"Obtain issue(s) by a summary search phrase or an issue key (e.g. **{DialogMatchesAndCommands.FindDialogCommand} MP-47** or **{DialogMatchesAndCommands.FindDialogCommand} search_phrase**)", + DialogMatchesAndCommands.FindDialogCommand, + !isGroup, + false), + CreateActionColumnSet( + "Assign", + $"Assign the issue to yourself (e.g. **{DialogMatchesAndCommands.AssignDialogCommand} MP-47**)", + DialogMatchesAndCommands.AssignDialogCommand, + !isGroup, + false), + CreateActionColumnSet( + "Edit", + "Open the issue card to change priority, summary, and description of the issue", + DialogMatchesAndCommands.IssueEditDialogCommand, + !isGroup, + false), + CreateActionColumnSet( + "Log", + "Log time spent on the issue", + DialogMatchesAndCommands.LogTimeDialogCommand, + !isGroup, + false), + CreateActionColumnSet( + "Watch", + $"Start watching the issue (e.g. **{DialogMatchesAndCommands.WatchDialogCommand} MP-47**)", + DialogMatchesAndCommands.WatchDialogCommand, + true, + false), + CreateActionColumnSet( + "Unwatch", + $"Stop watching the issue (e.g. **{DialogMatchesAndCommands.UnwatchDialogCommand} MP-47**)", + DialogMatchesAndCommands.UnwatchDialogCommand, + true, + false), + CreateActionColumnSet( + "Vote", + "Vote on the issue", + DialogMatchesAndCommands.VoteDialogCommand, + true, + false), + CreateActionColumnSet( + "Unvote", + "Unvote on the issue", + DialogMatchesAndCommands.UnvoteDialogCommand, + true, + false), + CreateActionColumnSet( + "Comment", + "Comment the issue", + DialogMatchesAndCommands.CommentDialogCommand, + true, + false), + CreateActionColumnSet( + "Disconnect", + $"Disconnect Jira Data Center instance you've connected from Microsoft Teams", + DialogMatchesAndCommands.DisconnectJiraDialogCommand, + !isGroup, + false), + CreateActionColumnSet( + "Cancel", + "cancel current dialog", + DialogMatchesAndCommands.CancelCommand, + true, + false), + new AdaptiveTextBlock() + { + IsVisible = !isGroup, + Wrap = true, + Text = "Type an issue key (e.g. **MP-47**) to view issue card with actions" + }, + new AdaptiveTextBlock() + { + Separator = true, + Wrap = true, + Text = + "\u24d8 For detailed instructions on configuring Jira Data Center application, please visit our [help page](https://confluence.atlassian.com/msteamsjiraserver/microsoft-teams-for-jira-server-documentation-1027116656.html)." + } + } + } + }; + adaptiveCard.AdditionalProperties = new SerializableDictionary + { + { "msTeams", new { width = "full" } } + }; + + return adaptiveCard; + } + private static async Task SendWelcomeCard(ITurnContext turnContext, IConnectorClient connectorClient, Activity activity, bool isGroupConversation, CancellationToken cancellationToken) { string welcomeText = @@ -410,5 +825,75 @@ private static async Task SendWelcomeCard(ITurnContext turnContext, IConnectorCl await turnContext.SendActivityAsync(MessageFactory.Attachment(adaptiveCard.ToAttachment()), cancellationToken); } } + + private static AdaptiveColumnSet CreateActionColumnSet(string title, string description, string command, bool isVisible, bool isFetchTask) + { + AdaptiveActionSet adaptiveActionSet = isFetchTask + ? new AdaptiveActionSet() + { + Actions = new List() + { + new AdaptiveSubmitAction() + { + Title = title, + Data = new JiraBotTeamsDataWrapper + { + FetchTaskData = new FetchTaskBotCommand(command), + TeamsData = new TeamsData + { + Type = "task/fetch" + } + } + } + } + } + : new AdaptiveActionSet() + { + Actions = new List() + { + new AdaptiveSubmitAction() + { + Title = title, + Data = new + { + msteams = new + { + type = "messageBack", + text = command + } + } + } + } + }; + + return new AdaptiveColumnSet() + { + IsVisible = isVisible, + Columns = new List() + { + new AdaptiveColumn() + { + Width = "120px", + Items = new List() + { + adaptiveActionSet + } + }, + new AdaptiveColumn() + { + Width = "stretch", + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center, + Items = new List() + { + new AdaptiveTextBlock() + { + Text = description, + Wrap = true + } + } + } + } + }; + } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/CommandDialogReferenceService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/CommandDialogReferenceService.cs index e7ad6b5..71dbdf8 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/CommandDialogReferenceService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/CommandDialogReferenceService.cs @@ -119,6 +119,12 @@ private void BuildCommandDialogReferences() isPersonal: true, isTeamAction: true, requireAuthentication: false)); + _actionCommands.Add(new JiraActionRegexReference( + nameof(NotificationsDialog), + DialogMatchesAndCommands.ConfigureNotificationsCommand, + $"{regexPrefix}{DialogMatchesAndCommands.ConfigureNotificationsCommand}", + isPersonal: true, + isTeamAction: true)); } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/DatabaseService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/DatabaseService.cs index 775c679..6e79ae3 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/DatabaseService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/DatabaseService.cs @@ -14,20 +14,14 @@ namespace MicrosoftTeamsIntegration.Jira.Services { [UsedImplicitly] - public sealed class DatabaseService : IDatabaseService + public class DatabaseService : IDatabaseService { private readonly IMongoCollection _usersCollection; - private readonly IMongoCollection _jiraAddonSettingsCollection; private static SemaphoreSlim _openConnectionSemaphore; - private readonly IOptions _appSettings; - private readonly IMongoDBContext _context; public DatabaseService(IOptions appSettings, IMongoDBContext context) { - _appSettings = appSettings; - _context = context; - _openConnectionSemaphore = new SemaphoreSlim( context.MaxConnectionPoolSize / 2, context.MaxConnectionPoolSize / 2); @@ -235,20 +229,7 @@ public async Task DeleteJiraServerUser(string msTeamsUserId, string jiraId) await ProcessThrottlingRequest(() => _usersCollection.DeleteOneAsync(deleteFilter)); } - private async Task ResetUserActiveJiraInstanceForPersonalScope(string msTeamsUserId) - { - var updateBuilder = new UpdateDefinitionBuilder(); - var updateDefinition = updateBuilder - .Set(x => x.IsUsedForPersonalScope, false) - .Set(x => x.IsUsedForPersonalScopeBefore, true); - - var filter = Builders.Filter.Where(x => - x.MsTeamsUserId == msTeamsUserId && x.IsUsedForPersonalScope); - - await ProcessThrottlingRequest(() => _usersCollection.UpdateOneAsync(filter, updateDefinition)); - } - - private static async Task ProcessThrottlingRequest(Func> func) + protected static async Task ProcessThrottlingRequest(Func> func) { T result; @@ -265,7 +246,7 @@ private static async Task ProcessThrottlingRequest(Func> func) return result; } - private static async Task ProcessThrottlingRequest(Func func) + protected static async Task ProcessThrottlingRequest(Func func) { await _openConnectionSemaphore.WaitAsync(); try @@ -277,5 +258,18 @@ private static async Task ProcessThrottlingRequest(Func func) _openConnectionSemaphore.Release(); } } + + private async Task ResetUserActiveJiraInstanceForPersonalScope(string msTeamsUserId) + { + var updateBuilder = new UpdateDefinitionBuilder(); + var updateDefinition = updateBuilder + .Set(x => x.IsUsedForPersonalScope, false) + .Set(x => x.IsUsedForPersonalScopeBefore, true); + + var filter = Builders.Filter.Where(x => + x.MsTeamsUserId == msTeamsUserId && x.IsUsedForPersonalScope); + + await ProcessThrottlingRequest(() => _usersCollection.UpdateOneAsync(filter, updateDefinition)); + } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IBotMessagesService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IBotMessagesService.cs index 6ecc34a..f748dcf 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IBotMessagesService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IBotMessagesService.cs @@ -4,6 +4,7 @@ using Microsoft.Bot.Builder; using Microsoft.Bot.Schema; using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces { @@ -15,5 +16,11 @@ public interface IBotMessagesService Task SearchIssueAndBuildIssueCard(ITurnContext turnContext, IntegratedUser user, string jiraIssueKey); Task SendAuthorizationCard(ITurnContext turnContext, string jiraUrl, CancellationToken cancellationToken = default); Task SendConnectCard(ITurnContext turnContext, CancellationToken cancellationToken = default); + Task SendConfigureNotificationsCard(ITurnContext turnContext, CancellationToken cancellationToken = default); + Task SendNotificationCard(ITurnContext turnContext, NotificationMessage notificationMessage, CancellationToken cancellationToken = default); + AdaptiveCard BuildConfigureNotificationsCard(ITurnContext turnContext); + AdaptiveCard BuildNotificationConfigurationSummaryCard(NotificationSubscription subscription, bool showSuccessMessage = false); + AdaptiveCard BuildChannelNotificationConfigurationSummaryCard(NotificationSubscriptionEvent subscriptionEvent, string callerName); + AdaptiveCard BuildHelpCard(ITurnContext turnContext); } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IHubConnectionWrapper.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IHubConnectionWrapper.cs new file mode 100644 index 0000000..4111585 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IHubConnectionWrapper.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +public interface IHubConnectionWrapper +{ + Task StartAsync(CancellationToken cancellationToken); + Task StopAsync(CancellationToken cancellationToken); +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IJiraService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IJiraService.cs index a74643e..5c3c34e 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IJiraService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IJiraService.cs @@ -5,6 +5,7 @@ using MicrosoftTeamsIntegration.Jira.Models.Jira; using MicrosoftTeamsIntegration.Jira.Models.Jira.Issue; using MicrosoftTeamsIntegration.Jira.Models.Jira.Meta; +using MicrosoftTeamsIntegration.Jira.Models.Jira.Transition; namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces { @@ -48,6 +49,7 @@ public interface IJiraService Task SearchAssignableMultiProject(IntegratedUser user, string projectKey, string username); Task> Assign(IntegratedUser user, string issueIdOrKey, string assigneeAccountId); Task GetTransitions(IntegratedUser user, string issueIdOrKey); + Task> GetTransitionsByProject(IntegratedUser user, string projectKeyOrId); Task DoTransition(IntegratedUser user, string issueIdOrKey, DoTransitionRequest doTransitionRequest); Task GetUserNameOrAccountId(IntegratedUser user); Task> GetFieldAutocompleteData(IntegratedUser user, string fieldName); diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IMessagingExtensionService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IMessagingExtensionService.cs index 75aa6cc..9312c74 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IMessagingExtensionService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/IMessagingExtensionService.cs @@ -10,7 +10,7 @@ public interface IMessagingExtensionService { Task HandleMessagingExtensionQuery(ITurnContext turnContext, IntegratedUser user); Task HandleMessagingExtensionFetchTask(ITurnContext turnContext, IntegratedUser user); - FetchTaskResponseEnvelope HandleBotFetchTask(ITurnContext turnContext, IntegratedUser user); + Task HandleBotFetchTask(ITurnContext turnContext, IntegratedUser user); Task HandleMessagingExtensionSubmitActionAsync(ITurnContext turnContext, IntegratedUser user); bool TryValidateMessageExtensionFetchTask(ITurnContext turnContext, IntegratedUser user, out FetchTaskResponseEnvelope response); Task HandleMessagingExtensionQueryLinkAsync(ITurnContext turnContext, IntegratedUser user, string jiraIssueIdOrKey); diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationProcessorService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationProcessorService.cs new file mode 100644 index 0000000..e573635 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationProcessorService.cs @@ -0,0 +1,10 @@ +using System.Reactive; +using System.Threading.Tasks; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; + +namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +public interface INotificationProcessorService +{ + public Task ProcessNotification(NotificationMessage notification); +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationQueueService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationQueueService.cs new file mode 100644 index 0000000..faa9f83 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationQueueService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Azure.Storage.Queues.Models; + +namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +public interface INotificationQueueService +{ + Task QueueNotificationMessage(string notificationMessage); + Task DequeueNotificationsMessages(int maxMessages = 32); + Task DeleteNotificationMessage(string messageId, string popReceipt); +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationSubscriptionDatabaseService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationSubscriptionDatabaseService.cs new file mode 100644 index 0000000..cf137b5 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationSubscriptionDatabaseService.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MicrosoftTeamsIntegration.Jira.Models; + +namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +public interface INotificationSubscriptionDatabaseService +{ + Task AddNotificationSubscription(NotificationSubscription notificationSubscription); + Task> GetNotificationSubscriptionBySubscriptionId(string subscriptionId); + Task> GetNotificationSubscriptionByJiraId(string jiraId); + Task> GetNotificationSubscriptionByMicrosoftUserId(string microsoftUserId); + Task> GetNotificationSubscriptionConversationId(string conversationId); + Task DeleteNotificationSubscriptionBySubscriptionId(string subscriptionId); + Task DeleteNotificationSubscriptionByMicrosoftUserId(string microsoftUserId); + Task UpdateNotificationSubscription(string subscriptionId, NotificationSubscription notificationSubscription); +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationSubscriptionService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationSubscriptionService.cs new file mode 100644 index 0000000..ecb4334 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/Interfaces/INotificationSubscriptionService.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MicrosoftTeamsIntegration.Jira.Models; + +namespace MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +public interface INotificationSubscriptionService +{ + Task CreateNotificationSubscription(IntegratedUser user, NotificationSubscription notification, string conversationReferenceId = ""); + Task GetNotificationSubscription(IntegratedUser user); + Task GetNotificationSubscriptionBySubscriptionId(string subscriptionId); + Task> GetNotifications(IntegratedUser user); + Task> GetNotificationSubscriptionByConversationId(string conversationId); + Task UpdateNotificationSubscription(IntegratedUser user, NotificationSubscription notification, string conversationReferenceId = ""); + Task DeleteNotificationSubscriptionByMicrosoftUserId(IntegratedUser user); + Task DeleteNotificationSubscriptionBySubscriptionId(IntegratedUser user, string subscriptionId); +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/JiraService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/JiraService.cs index 23f6cd8..029f8bc 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/JiraService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/JiraService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.ContractResolvers; using MicrosoftTeamsIntegration.Jira.Exceptions; using MicrosoftTeamsIntegration.Jira.Helpers; @@ -13,6 +14,7 @@ using MicrosoftTeamsIntegration.Jira.Models.Jira; using MicrosoftTeamsIntegration.Jira.Models.Jira.Issue; using MicrosoftTeamsIntegration.Jira.Models.Jira.Meta; +using MicrosoftTeamsIntegration.Jira.Models.Jira.Transition; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Services.SignalR.Interfaces; using Newtonsoft.Json; @@ -302,6 +304,27 @@ public Task GetTransitions(IntegratedUser user, string return ProcessRequest(user, $"api/2/issue/{issueIdOrKey}/transitions", "GET"); } + public async Task> GetTransitionsByProject(IntegratedUser user, string projectKeyOrId) + { + var issueTypes = await GetCreateMetaIssueTypes(user, projectKeyOrId); + var transitions = new List(); + + foreach (var issueType in issueTypes.Select(it => it.Id)) + { + try + { + var fields = await GetTransitions(user, issueType); + transitions.Add(fields); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get transitions for issue type {issueType}", issueType); + } + } + + return transitions.Distinct().ToList(); + } + public Task DoTransition(IntegratedUser user, string issueIdOrKey, DoTransitionRequest doTransitionRequest) { return ProcessRequestWithJiraApiActionCallResponse(user, $"api/2/issue/{issueIdOrKey}/transitions", "POST", doTransitionRequest); diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/MessagingExtensionService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/MessagingExtensionService.cs index 6829c22..d8fa791 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/MessagingExtensionService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/MessagingExtensionService.cs @@ -53,6 +53,7 @@ public sealed class MessagingExtensionService : IMessagingExtensionService private readonly IDistributedCacheService _distributedCacheService; private readonly TelemetryClient _telemetry; private readonly IAnalyticsService _analyticsService; + private readonly INotificationSubscriptionService _notificationSubscriptionService; public MessagingExtensionService( IOptions appSettings, @@ -62,7 +63,8 @@ public MessagingExtensionService( IBotMessagesService botMessagesService, IDistributedCacheService distributedCacheService, TelemetryClient telemetry, - IAnalyticsService analyticsService) + IAnalyticsService analyticsService, + INotificationSubscriptionService notificationSubscriptionService) { _appSettings = appSettings.Value; _logger = logger; @@ -72,6 +74,7 @@ public MessagingExtensionService( _distributedCacheService = distributedCacheService; _telemetry = telemetry; _analyticsService = analyticsService; + _notificationSubscriptionService = notificationSubscriptionService; } public async Task HandleMessagingExtensionFetchTask( @@ -96,7 +99,7 @@ public async Task HandleMessagingExtensionFetchTask( } } - public FetchTaskResponseEnvelope HandleBotFetchTask(ITurnContext turnContext, IntegratedUser user) + public async Task HandleBotFetchTask(ITurnContext turnContext, IntegratedUser user) { FetchTaskBotCommand fetchTaskCommand = null; var value = turnContext.Activity?.Value as JObject; @@ -107,7 +110,7 @@ public FetchTaskResponseEnvelope HandleBotFetchTask(ITurnContext turnContext, In fetchTaskCommand = dataWrapperObject?.FetchTaskData; } - var response = BuildTaskModuleResponse(turnContext, user, fetchTaskCommand); + var response = await BuildTaskModuleResponse(turnContext, user, fetchTaskCommand); if (response != null) { return response; @@ -433,6 +436,31 @@ public async Task HandleTaskSubmitActionAsync( await turnContext.UpdateActivityAsync(message); } + break; + case "showNotificationSettings": + var notificationConfigurationMessage = turnContext.Activity.CreateReply(); + notificationConfigurationMessage.Id = fetchTaskCommand.ReplyToActivityId; + var notificationSubscription = + await _notificationSubscriptionService.GetNotificationSubscription(user); + + if (notificationSubscription != null && notificationSubscription.IsActive && + notificationSubscription.EventTypes.Length != 0) + { + var subscriptionConfigurationCard = + _botMessagesService.BuildNotificationConfigurationSummaryCard( + notificationSubscription, true); + notificationConfigurationMessage.Attachments.Add(subscriptionConfigurationCard + .ToAttachment()); + } + else + { + var notificationConfigurationCard = + _botMessagesService.BuildConfigureNotificationsCard(turnContext); + notificationConfigurationMessage.Attachments.Add(notificationConfigurationCard + .ToAttachment()); + } + + await turnContext.UpdateActivityAsync(notificationConfigurationMessage); break; } } @@ -442,7 +470,7 @@ public async Task HandleTaskSubmitActionAsync( } } - return BuildTaskModuleResponse(turnContext, user, fetchTaskCommand); + return await BuildTaskModuleResponse(turnContext, user, fetchTaskCommand); } private Task HandleInvalidCommandId(string errorMessage) @@ -687,7 +715,7 @@ private FetchTaskResponseEnvelope BuildSubmitActionMessageResponse(string messag }; } - private FetchTaskResponseEnvelope BuildTaskModuleResponse( + private async Task BuildTaskModuleResponse( ITurnContext turnContext, IntegratedUser user, FetchTaskBotCommand fetchTaskCommand) @@ -742,6 +770,52 @@ private FetchTaskResponseEnvelope BuildTaskModuleResponse( taskModuleHeight = 250; } + if (fetchTaskCommand.CommandName.Equals( + DialogMatchesAndCommands.TurnOnNotificationsCommand, + StringComparison.OrdinalIgnoreCase)) + { + var conversationReferenceId = Guid.NewGuid().ToString(); + await _distributedCacheService.Set(conversationReferenceId, JsonConvert.SerializeObject(turnContext.Activity.GetConversationReference())); + + url = new JiraUrlQueryBuilder(_appSettings.BaseUrl) + .PersonalNotifications() + .JiraId(jiraId) + .MicrosoftUserId(user?.MsTeamsUserId) + .ConversationId(turnContext.Activity.Conversation.Id) + .ConversationReferenceId(conversationReferenceId) + .ReplyToActivityId(turnContext.Activity.ReplyToId) + .Application(application) + .Source("bot") + .Build(); + + taskModuleTitle = "Configure personal notifications"; + taskModuleHeight = 480; + } + + if (fetchTaskCommand.CommandName.Equals( + DialogMatchesAndCommands.TurnOnChannelNotificationsCommand, + StringComparison.OrdinalIgnoreCase)) + { + var conversationReferenceId = Guid.NewGuid().ToString(); + var conversationReference = turnContext.Activity.GetConversationReference(); + conversationReference.Conversation.Id = + MessagingExtensionRegex.RemoveMessageId(conversationReference.Conversation.Id); + await _distributedCacheService + .Set(conversationReferenceId, JsonConvert.SerializeObject(conversationReference)); + + url = new JiraUrlQueryBuilder(_appSettings.BaseUrl) + .ChannelNotifications() + .JiraId(jiraId) + .ConversationReferenceId(conversationReferenceId) + .ConversationId(turnContext.Activity.Conversation.Id) + .Application(application) + .Source("bot") + .Build(); + + taskModuleTitle = "Configure channel notifications"; + taskModuleHeight = 580; + } + return new FetchTaskResponseEnvelope { Task = new FetchTaskResponse @@ -1019,4 +1093,15 @@ private static T SafeCast(object value) return obj.ToObject(); } } + + public abstract partial class MessagingExtensionRegex + { + public static string RemoveMessageId(string input) + { + return RemoveMessageIdRegex().Replace(input, string.Empty); + } + + [GeneratedRegex(@";messageid=[^;]*", RegexOptions.IgnoreCase)] + private static partial Regex RemoveMessageIdRegex(); + } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/NotificationProcessorService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationProcessorService.cs new file mode 100644 index 0000000..528d9c7 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationProcessorService.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using AdaptiveCards; +using AutoMapper; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using Newtonsoft.Json; + +namespace MicrosoftTeamsIntegration.Jira.Services; + +public class NotificationProcessorService : INotificationProcessorService +{ + private readonly ILogger _logger; + private readonly IAnalyticsService _analyticsService; + private readonly IDatabaseService _databaseService; + private readonly INotificationSubscriptionDatabaseService _notificationSubscriptionDatabaseService; + private readonly IProactiveMessagesService _proactiveMessagesService; + private readonly IMapper _mapper; + + public NotificationProcessorService( + ILogger logger, + IAnalyticsService analyticsService, + IDatabaseService databaseService, + IProactiveMessagesService proactiveMessagesService, + IMapper mapper, + INotificationSubscriptionDatabaseService notificationSubscriptionDatabaseService) + { + _logger = logger; + _databaseService = databaseService; + _proactiveMessagesService = proactiveMessagesService; + _mapper = mapper; + _notificationSubscriptionDatabaseService = notificationSubscriptionDatabaseService; + _analyticsService = analyticsService; + } + + public async Task ProcessNotification(NotificationMessage notification) + { + try + { + _analyticsService.SendTrackEvent( + null, + "bot", + "processing", + "notification", + string.Empty); + + var jiraConnection = await _databaseService.GetJiraServerAddonSettingsByJiraId(notification.JiraId); + if (jiraConnection == null) + { + _logger.LogWarning( + "Received notification event from unregistered Jira Data Center Addon with Id: {JiraId}", + notification.JiraId); + return; + } + + var personalSubscriptionsForJira + = (await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByJiraId(notification.JiraId)) + ?.Where(s => s.IsActive && s.SubscriptionType == SubscriptionType.Personal) + .ToList(); + + var channelSubscriptionsForJira + = (await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByJiraId(notification.JiraId)) + ?.Where(s => s.IsActive && s.SubscriptionType == SubscriptionType.Channel) + .ToList(); + + if (personalSubscriptionsForJira != null && personalSubscriptionsForJira.Count != 0) + { + await ProcessPersonalNotifications(notification, personalSubscriptionsForJira); + } + + if (channelSubscriptionsForJira != null && channelSubscriptionsForJira.Count != 0) + { + await ProcessChannelNotifications(notification, channelSubscriptionsForJira); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while processing notification"); + } + } + + private async Task ProcessPersonalNotifications( + NotificationMessage notificationMessage, + IEnumerable personalSubscriptionsForJira) + { + NotificationMessageCardPayload notification = new NotificationMessageCardPayload(notificationMessage); + notification.IsPersonalNotification = true; + NotificationEventType notificationEventType = notification.EventType.ToEventType(); + List allowedPersonalNotificationTypes = new List() + { + NotificationEventType.IssueCreated, + NotificationEventType.IssueUpdated, + NotificationEventType.IssueAssigned, + NotificationEventType.CommentCreated, + NotificationEventType.CommentUpdated + }; + + if (!allowedPersonalNotificationTypes.Contains(notificationEventType)) + { + // Skip the notification if the event type is not allowed + return; + } + + // Process all subscriptions if the subscriber user is the one who triggered it + foreach (var subscription in personalSubscriptionsForJira + .Where(s => s.MicrosoftUserId != notification.User?.MicrosoftId)) + { + bool isMentionedAndCanViewIssue = IsMentionedAndCanViewIssue(notification, subscription); + bool isMentionedAndCanViewComment = IsMentionedAndCanViewComment(notification, subscription); + + bool isMentionedOnIssueEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.MentionedOnIssue.ToString()); + + if (ShouldSendPersonalNotification( + isMentionedOnIssueEventEnabled, + isMentionedAndCanViewIssue || isMentionedAndCanViewComment)) + { + notification.IsMention = true; + if (isMentionedAndCanViewComment + && (notificationEventType == NotificationEventType.CommentCreated + || notificationEventType == NotificationEventType.CommentUpdated)) + { + await SendNotificationCard(notification, subscription); + } + else if (isMentionedAndCanViewIssue + && (notificationEventType == NotificationEventType.IssueAssigned + || notificationEventType == NotificationEventType.IssueUpdated)) + { + await SendNotificationCard(notification, subscription); + } + } + else + { + await ProcessPersonalSubscription(notification, subscription, notificationEventType); + } + } + } + + private async Task ProcessPersonalSubscription( + NotificationMessageCardPayload notification, + NotificationSubscription subscription, + NotificationEventType notificationEventType) + { + bool isIssueWatcher = IsWatcher(notification.Watchers, subscription.MicrosoftUserId); + bool isIssueAssignee = IsAssignee(notification.Issue?.Assignee, subscription.MicrosoftUserId); + bool isIssueReporter = IsReporter(notification.Issue?.Reporter, subscription.MicrosoftUserId); + + bool isIssueViewerEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.IssueViewer.ToString()); + bool isCommentViewerEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.CommentViewer.ToString()); + bool isActivityIssueCreatorEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.ActivityIssueCreator.ToString()); + bool isCommentIssueCreatorEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.CommentIssueCreator.ToString()); + bool isActivityIssueAssigneeEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.ActivityIssueAssignee.ToString()); + bool isCommentIssueAssigneeEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == PersonalEventType.CommentIssueAssignee.ToString()); + + switch (notificationEventType) + { + case NotificationEventType.CommentCreated: + case NotificationEventType.CommentUpdated: + { + if (ShouldSendPersonalNotification(isCommentViewerEventEnabled, isIssueWatcher)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendPersonalNotification(isCommentIssueAssigneeEventEnabled, isIssueAssignee)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendPersonalNotification(isCommentIssueCreatorEventEnabled, isIssueReporter)) + { + await SendNotificationCard(notification, subscription); + } + + break; + } + + case NotificationEventType.IssueUpdated: + case NotificationEventType.IssueAssigned: + { + if (ShouldSendPersonalNotification(isIssueViewerEventEnabled, isIssueWatcher)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendPersonalNotification(isActivityIssueAssigneeEventEnabled, isIssueAssignee)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendPersonalNotification(isActivityIssueCreatorEventEnabled, isIssueReporter)) + { + await SendNotificationCard(notification, subscription); + } + + break; + } + + // send notification when somebody created issue and assigned it to subscriber + case NotificationEventType.IssueCreated: + { + if (ShouldSendPersonalNotification( + isActivityIssueAssigneeEventEnabled, + isIssueAssignee && isIssueAssignee != isIssueReporter)) + { + await SendNotificationCard(notification, subscription); + } + + break; + } + } + } + + private async Task ProcessChannelNotifications( + NotificationMessage notificationMessage, + IEnumerable channelSubscriptionsForJira) + { + NotificationMessageCardPayload notification = new NotificationMessageCardPayload(notificationMessage); + foreach (var subscription in channelSubscriptionsForJira) + { + if (ShouldSkipChannelSubscription(notification, subscription)) + { + continue; + } + + bool isIssueCreatedEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == ChannelEventType.IssueCreated.ToString()); + bool isIssueUpdatedEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == ChannelEventType.IssueUpdated.ToString()); + bool isCommentCreatedEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == ChannelEventType.CommentCreated.ToString()); + bool isCommentUpdatedEventEnabled + = subscription.EventTypes.AsEnumerable().Any(e => e == ChannelEventType.CommentUpdated.ToString()); + NotificationEventType notificationEventType = notification.EventType.ToEventType(); + + if (ShouldSendChannelNotification( + isIssueCreatedEventEnabled, + notificationEventType == NotificationEventType.IssueCreated)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendChannelNotification( + isIssueUpdatedEventEnabled, + notificationEventType == NotificationEventType.IssueUpdated || notificationEventType == NotificationEventType.IssueAssigned)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendChannelNotification( + isCommentCreatedEventEnabled, + notificationEventType == NotificationEventType.CommentCreated && !notification.Comment.IsInternal)) + { + await SendNotificationCard(notification, subscription); + } + else if (ShouldSendChannelNotification( + isCommentUpdatedEventEnabled, + (notificationEventType == NotificationEventType.CommentUpdated || notificationEventType == NotificationEventType.CommentDeleted) && !notification.Comment.IsInternal)) + { + await SendNotificationCard(notification, subscription); + } + } + } + + private static bool ShouldSkipChannelSubscription( + NotificationMessageCardPayload notification, + NotificationSubscription subscription) + { + if (notification.Issue != null && subscription.ProjectId != notification.Issue.ProjectId.ToString()) + { + // Skip the notification if the issue is not in the subscribed project + return true; + } + + string issueType = notification.Issue?.Type?.ToLower(); + string issueStatus = notification.Issue?.Status?.ToLower(); + string issuePriority = notification.Issue?.Priority?.ToLower(); + + bool doesIssueMatchFilter = true; + + if (!string.IsNullOrEmpty(subscription.Filter)) + { + var typeRegex = new Regex( + @"(?<=(?:type\s*(?:in|=)\s*)\([^()]*)['""]?([\w-\s]+)['""]?(?=[^()]*\))", + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + var statusRegex = new Regex( + @"(?<=(?:status\s*(?:in|=)\s*)\([^()]*)['""]?([\w-\s]+)['""]?(?=[^()]*\))", + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + var priorityRegex = new Regex( + @"(?<=(?:priority\s*(?:in|=)\s*)\([^()]*)['""]?([\w-\s]+)['""]?(?=[^()]*\))", + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + var issueTypeMatches = typeRegex.Matches(subscription.Filter); + var issueStatusMatches = statusRegex.Matches(subscription.Filter); + var issuePriorityMatches = priorityRegex.Matches(subscription.Filter); + + var doesTypeMatchFilter + = issueTypeMatches.Count == 0 + || issueTypeMatches.Any(m => m?.Groups[1].Value.ToLower() == issueType); + var doesStatusMatchFilter + = issueStatusMatches.Count == 0 + || issueStatusMatches.Any(m => m?.Groups[1].Value.ToLower() == issueStatus); + var doesPriorityMatchFilter + = issuePriorityMatches.Count == 0 + || issuePriorityMatches.Any(m => m?.Groups[1].Value.ToLower() == issuePriority); + doesIssueMatchFilter = doesTypeMatchFilter && doesStatusMatchFilter && doesPriorityMatchFilter; + } + + return !doesIssueMatchFilter; + } + + private static bool ShouldSendPersonalNotification(bool eventCondition, bool userCondition) + { + return eventCondition && userCondition; + } + + private static bool ShouldSendChannelNotification(bool eventCondition, bool notificationCondition) + { + return eventCondition && notificationCondition; + } + + private static bool IsWatcher(IEnumerable watchers, string userId) + { + return watchers != null && watchers.Any(w => w.MicrosoftId == userId && w.CanViewIssue); + } + + private static bool IsAssignee(NotificationUser assignee, string userId) + { + return assignee?.MicrosoftId == userId && assignee?.CanViewIssue == true; + } + + private static bool IsReporter(NotificationUser reporter, string userId) + { + return reporter?.MicrosoftId == userId && reporter?.CanViewIssue == true; + } + + private static bool IsMentionedAndCanViewIssue(NotificationMessageCardPayload notification, NotificationSubscription subscription) + { + return notification.Mentions != null + && notification.Mentions.Any(m => + m.MicrosoftId == subscription.MicrosoftUserId && m.CanViewIssue); + } + + private static bool IsMentionedAndCanViewComment(NotificationMessageCardPayload notification, NotificationSubscription subscription) + { + return notification.Mentions != null + && notification.Mentions.Any(m => + m.MicrosoftId == subscription.MicrosoftUserId && m.CanViewComment); + } + + private async Task SendNotificationCard( + NotificationMessageCardPayload notificationMessage, + NotificationSubscription subscription) + { + try + { + var adaptiveCard = _mapper.Map(notificationMessage); + var activity = MessageFactory.Attachment(adaptiveCard.ToAttachment()); + await _proactiveMessagesService.SendActivity( + activity, + JsonConvert.DeserializeObject(subscription.ConversationReference)); + _analyticsService.SendTrackEvent( + null, + "bot", + "processed", + "notification", + string.Empty, + new NotificationsAnalyticsEventAttribute() + { + NotificationEventType = notificationMessage.EventType.ToEventType().ToString() + }); + } + catch (Exception ex) + { + _analyticsService.SendTrackEvent( + null, + "bot", + "processingFailed", + "notification", + string.Empty, + new NotificationsAnalyticsEventAttribute() + { + NotificationEventType = notificationMessage.EventType.ToEventType().ToString() + }); + + _logger.LogError( + ex, + "Error while sending notification card to user {UserId} in conversation {ConversationId}", + subscription.MicrosoftUserId, + subscription.ConversationReference); + } + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/NotificationQueueService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationQueueService.cs new file mode 100644 index 0000000..00d7434 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationQueueService.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +namespace MicrosoftTeamsIntegration.Jira.Services; + +public class NotificationQueueService : INotificationQueueService +{ + private readonly ILogger _logger; + private readonly QueueClient _queueClient; + + public NotificationQueueService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + string storageConnectionString = configuration.GetValue("StorageConnectionString"); + string notificationQueueName = configuration.GetValue("NotificationQueueName"); + if (string.IsNullOrWhiteSpace(notificationQueueName)) + { + notificationQueueName = "notifications-jira-dc"; + } + + _queueClient = new QueueClient(storageConnectionString, notificationQueueName); + _queueClient.CreateIfNotExists(); + } + + public NotificationQueueService(ILogger logger, QueueClient queueClient) + { + _logger = logger; + _queueClient = queueClient; + } + + public async Task QueueNotificationMessage(string notificationMessage) + { + try + { + await _queueClient.SendMessageAsync(notificationMessage); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to send notification message to queue"); + } + } + + public async Task DequeueNotificationsMessages(int maxMessages = 32) + { + try + { + return await _queueClient.ReceiveMessagesAsync(maxMessages: maxMessages, visibilityTimeout: TimeSpan.FromMinutes(5)); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to dequeue notification messages from queue"); + return Array.Empty(); + } + } + + public async Task DeleteNotificationMessage(string messageId, string popReceipt) + { + try + { + await _queueClient.DeleteMessageAsync(messageId, popReceipt); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to delete notification message from queue"); + } + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/NotificationSubscriptionDatabaseService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationSubscriptionDatabaseService.cs new file mode 100644 index 0000000..4bd77bb --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationSubscriptionDatabaseService.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Settings; +using MongoDB.Driver; + +namespace MicrosoftTeamsIntegration.Jira.Services; + +public class NotificationSubscriptionDatabaseService : DatabaseService, INotificationSubscriptionDatabaseService +{ + private readonly IMongoCollection _notificationSubscriptionCollection; + + public NotificationSubscriptionDatabaseService(IOptions appSettings, IMongoDBContext context) + : base(appSettings, context) + { + _notificationSubscriptionCollection = + context.GetCollection("NotificationSubscription"); + } + + public async Task AddNotificationSubscription(NotificationSubscription notificationSubscription) + { + await ProcessThrottlingRequest(() => + _notificationSubscriptionCollection.InsertOneAsync(notificationSubscription)); + } + + public async Task> GetNotificationSubscriptionBySubscriptionId(string subscriptionId) + { + var filter = Builders.Filter.Where(x => + x.SubscriptionId == subscriptionId); + + return await GetNotificationByFilterAsync(filter); + } + + public async Task> GetNotificationSubscriptionByJiraId(string jiraId) + { + var filter = Builders.Filter.Where(x => + x.JiraId == jiraId); + + return await GetNotificationByFilterAsync(filter); + } + + public async Task> GetNotificationSubscriptionByMicrosoftUserId(string microsoftUserId) + { + var filter = Builders.Filter.Where(x => + x.MicrosoftUserId == microsoftUserId); + + return await GetNotificationByFilterAsync(filter); + } + + public async Task> GetNotificationSubscriptionConversationId(string conversationId) + { + var filter = Builders.Filter.Where(x => + x.ConversationId == conversationId); + + return await GetNotificationByFilterAsync(filter); + } + + public async Task DeleteNotificationSubscriptionBySubscriptionId(string subscriptionId) + { + var filter = Builders.Filter.Where(x => + x.SubscriptionId == subscriptionId); + + await ProcessThrottlingRequest(() => _notificationSubscriptionCollection.DeleteOneAsync(filter)); + } + + public async Task DeleteNotificationSubscriptionByMicrosoftUserId(string microsoftUserId) + { + var filter = Builders.Filter.Where(x => + x.MicrosoftUserId == microsoftUserId); + + await ProcessThrottlingRequest(() => _notificationSubscriptionCollection.DeleteOneAsync(filter)); + } + + public async Task UpdateNotificationSubscription(string subscriptionId, NotificationSubscription notificationSubscription) + { + var updateBuilder = new UpdateDefinitionBuilder(); + var updateDefinition = updateBuilder + .Set(x => x.EventTypes, notificationSubscription.EventTypes) + .Set(x => x.Filter, notificationSubscription.Filter) + .Set(x => x.IsActive, notificationSubscription.IsActive) + .Set(x => x.ConversationId, notificationSubscription.ConversationId) + .Set(x => x.ConversationReference, notificationSubscription.ConversationReference) + .Set(x => x.ProjectId, notificationSubscription.ProjectId) + .Set(x => x.ProjectName, notificationSubscription.ProjectName); + + var filter = Builders.Filter.Where(x => + x.SubscriptionId == subscriptionId); + + await ProcessThrottlingRequest(() => + _notificationSubscriptionCollection.UpdateOneAsync(filter, updateDefinition)); + } + + private async Task> GetNotificationByFilterAsync(FilterDefinition filter) + { + return await ProcessThrottlingRequest(() => _notificationSubscriptionCollection.Find(filter).ToListAsync()); + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/NotificationSubscriptionService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationSubscriptionService.cs new file mode 100644 index 0000000..e58913e --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/NotificationSubscriptionService.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Exceptions; +using MicrosoftTeamsIntegration.Jira.Helpers; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Jira; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Services.SignalR.Interfaces; +using Newtonsoft.Json; + +namespace MicrosoftTeamsIntegration.Jira.Services; + +public class NotificationSubscriptionService : INotificationSubscriptionService +{ + private readonly ILogger _logger; + private readonly INotificationSubscriptionDatabaseService _notificationSubscriptionDatabaseService; + private readonly IDistributedCacheService _distributedCacheService; + private readonly ISignalRService _signalRService; + + public NotificationSubscriptionService( + ILogger logger, + INotificationSubscriptionDatabaseService notificationSubscriptionDatabaseService, + IDistributedCacheService distributedCacheService, + ISignalRService signalRService) + { + _logger = logger; + _notificationSubscriptionDatabaseService = notificationSubscriptionDatabaseService; + _distributedCacheService = distributedCacheService; + _signalRService = signalRService; + } + + public async Task CreateNotificationSubscription(IntegratedUser user, NotificationSubscription notification, string conversationReferenceId = "") + { + // Enable addon notification settings when the first subscription is created for Jira by subscription type + await TryToEnableAddonNotificationSettingsOnCreation(user, notification); + + try + { + if (!string.IsNullOrEmpty(conversationReferenceId)) + { + string cachedConversationReference = await _distributedCacheService.Get(conversationReferenceId); + + if (!string.IsNullOrEmpty(cachedConversationReference)) + { + notification.ConversationReference = + await _distributedCacheService.Get(conversationReferenceId); + } + } + + await _notificationSubscriptionDatabaseService.AddNotificationSubscription(notification); + } + catch (Exception ex) + { + _logger.LogError("An error occurred on notification subscription creation: {ErrorMessage}", ex.Message); + } + } + + public async Task GetNotificationSubscription(IntegratedUser user) + { + try + { + var notifications = + await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(user.MsTeamsUserId); + return notifications.First(notification => notification.SubscriptionType == SubscriptionType.Personal); + } + catch (Exception ex) + { + _logger.LogError("An error occurred while retrieving the notification: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task GetNotificationSubscriptionBySubscriptionId(string subscriptionId) + { + try + { + var notifications = + await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionBySubscriptionId(subscriptionId); + return notifications.First(); + } + catch (Exception ex) + { + _logger.LogError("An error occurred while retrieving the notification: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task> GetNotificationSubscriptionByConversationId(string conversationId) + { + try + { + return await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionConversationId(conversationId); + } + catch (Exception ex) + { + _logger.LogError("An error occurred while retrieving the notification: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task> GetNotifications(IntegratedUser user) + { + try + { + return await _notificationSubscriptionDatabaseService + .GetNotificationSubscriptionByMicrosoftUserId(user.MsTeamsUserId); + } + catch (Exception ex) + { + _logger.LogError("An error occurred while retrieving the notifications: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task UpdateNotificationSubscription(IntegratedUser user, NotificationSubscription notification, string conversationReferenceId = "") + { + try + { + if (!string.IsNullOrEmpty(conversationReferenceId)) + { + string cachedConversationReference = await _distributedCacheService.Get(conversationReferenceId); + + if (!string.IsNullOrEmpty(cachedConversationReference)) + { + notification.ConversationReference = + await _distributedCacheService.Get(conversationReferenceId); + } + } + + if (notification.IsActive) + { + // mute notifications if the user has not selected any event types + notification.IsActive = notification.EventTypes.Length != 0; + } + + await _notificationSubscriptionDatabaseService.UpdateNotificationSubscription( + notification.SubscriptionId, + notification); + } + catch (Exception ex) + { + _logger.LogError("An error occurred while updating the notification subscription: {ErrorMessage}", ex.Message); + } + } + + public async Task DeleteNotificationSubscriptionByMicrosoftUserId(IntegratedUser user) + { + try + { + var notifications = await _notificationSubscriptionDatabaseService + .GetNotificationSubscriptionByMicrosoftUserId(user.MsTeamsUserId); + + var personalNotifications = notifications + .Where(n => n.SubscriptionType == SubscriptionType.Personal).ToList(); + + if (personalNotifications.Count == 0) + { + return; + } + + // In general, we need to have one personal notification subscription per user, but if there are multiple, remove all of them + foreach (var notification in personalNotifications) + { + await _notificationSubscriptionDatabaseService + .DeleteNotificationSubscriptionBySubscriptionId(notification.SubscriptionId); + + await TryToDisableAddonNotificationSettingsOnRemoval(user, notification); + } + } + catch (Exception ex) + { + _logger.LogError( + "An error occurred while deleting the notification subscription: {ErrorMessage}", + ex.Message); + } + } + + public async Task DeleteNotificationSubscriptionBySubscriptionId(IntegratedUser user, string subscriptionId) + { + try + { + var notifications = + await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionBySubscriptionId(subscriptionId); + + await _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionBySubscriptionId(subscriptionId); + + await TryToDisableAddonNotificationSettingsOnRemoval(user, notifications.First()); + } + catch (Exception ex) + { + _logger.LogError( + "An error occurred while deleting the notification subscription: {ErrorMessage}", + ex.Message); + throw new BadRequestException("Failed to delete notification subscription"); + } + } + + private async Task TryToEnableAddonNotificationSettingsOnCreation(IntegratedUser user, NotificationSubscription notification) + { + List notificationSubscriptions; + try + { + var jiraNotificationSubscriptions + = await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByJiraId(notification.JiraId); + notificationSubscriptions = jiraNotificationSubscriptions.ToList(); + } + catch (Exception ex) + { + _logger.LogError( + "An error occurred while retrieving the notification subscriptions: {ErrorMessage}", + ex.Message); + throw new BadRequestException("Failed to configure addon notification settings"); + } + + if (notification.SubscriptionType == SubscriptionType.Personal && + notificationSubscriptions.All(x => x.SubscriptionType != SubscriptionType.Personal) && + !await ConfigureAddonNotificationSettings(user, "EnablePersonalNotifications")) + { + _logger.LogError( + "Failed to enable addon personal notification settings for {JiraID}", + notification.JiraId); + throw new BadRequestException("Failed to configure addon notification settings"); + } + + if (notification.SubscriptionType == SubscriptionType.Channel && + notificationSubscriptions.All(x => x.SubscriptionType != SubscriptionType.Channel) && + !await ConfigureAddonNotificationSettings(user, "EnableChannelNotifications")) + { + _logger.LogError( + "Failed to enable addon channel notification settings for {JiraID}", + notification.JiraId); + throw new BadRequestException("Failed to configure addon notification settings"); + } + } + + private async Task TryToDisableAddonNotificationSettingsOnRemoval(IntegratedUser user, NotificationSubscription notification) + { + List notificationSubscriptions = new List(); + try + { + var jiraNotificationSubscriptions + = await _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByJiraId(notification.JiraId); + notificationSubscriptions = jiraNotificationSubscriptions.ToList(); + } + catch (Exception ex) + { + _logger.LogError( + "An error occurred while retrieving the notification subscriptions: {ErrorMessage}", + ex.Message); + } + + if (notificationSubscriptions.All(x => x.SubscriptionType != SubscriptionType.Personal) && + !await ConfigureAddonNotificationSettings(user, "DisablePersonalNotifications")) + { + _logger.LogError( + "Failed to disable addon personal notification settings for {JiraID}", + notification.JiraId); + } + + if (notificationSubscriptions.All(x => x.SubscriptionType != SubscriptionType.Channel) && + !await ConfigureAddonNotificationSettings(user, "DisableChannelNotifications")) + { + _logger.LogError( + "Failed to disable addon channel notification settings for {JiraID}", + notification.JiraId); + } + } + + private async Task ConfigureAddonNotificationSettings( + IntegratedUser user, + string command) + { + if (user == null) + { + _logger.LogError("User is null. Cannot configure addon notification settings."); + return false; + } + + var request = new JiraCommandRequest + { + TeamsId = user.MsTeamsUserId, + JiraId = user.JiraServerId, + AccessToken = user.AccessToken, + Command = command + }; + var message = JsonConvert.SerializeObject(request); + var response = await _signalRService.SendRequestAndWaitForResponse( + user.JiraServerId, + message, + CancellationToken.None); + if (response.Received) + { + var responseObj = + new JsonDeserializer(_logger).Deserialize>(response.Message); + if (JiraHelpers.IsResponseForTheUser(responseObj) && responseObj.ResponseCode == 200) + { + _logger.LogDebug( + "Configured addon notification settings for {JiraID}: {Response}", + user.JiraServerId, + response.Message); + return true; + } + } + + _logger.LogDebug( + "Cannot configure addon notification settings for {JiraID}: {Response}", + user.JiraServerId, + response.Message); + return false; + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/HubConnectionWrapper.cs b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/HubConnectionWrapper.cs new file mode 100644 index 0000000..d86fe05 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/HubConnectionWrapper.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; + +namespace MicrosoftTeamsIntegration.Jira.Services.SignalR; + +public class HubConnectionWrapper : IHubConnectionWrapper +{ + private readonly HubConnection _hubConnection; + + public HubConnectionWrapper(HubConnection hubConnection) + { + _hubConnection = hubConnection; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return _hubConnection.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return _hubConnection.StopAsync(cancellationToken); + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/Interfaces/ISignalRService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/Interfaces/ISignalRService.cs index 1930953..10108ce 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/Interfaces/ISignalRService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/Interfaces/ISignalRService.cs @@ -9,5 +9,7 @@ public interface ISignalRService { Task SendRequestAndWaitForResponse(string jiraServerId, string message, CancellationToken cancellationToken); Task Callback(Guid identifier, string response); + Task Broadcast(Guid identifier, string response); + Task Notification(Guid identifier, string response); } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRBroadcastClient.cs b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRBroadcastClient.cs new file mode 100644 index 0000000..9a3301c --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRBroadcastClient.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using Polly; +using Polly.Retry; + +namespace MicrosoftTeamsIntegration.Jira.Services.SignalR; + +public class SignalRBroadcastClient : IHostedService +{ + public static readonly string BroadcastGroupName = "BroadcastGroup"; + + private const int DefaultPoliceRetryCount = 3; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly AsyncRetryPolicy _startPolicy; + private IHubConnectionWrapper _hubConnectionWrapper; + + public SignalRBroadcastClient(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + _startPolicy = Policy.Handle() + .WaitAndRetryAsync( + DefaultPoliceRetryCount, + retryAttempts => TimeSpan.FromSeconds(Math.Pow(2, retryAttempts)), + (exception, retryCount, retryAttempt, context) => + { + if (retryAttempt == DefaultPoliceRetryCount) + { + _logger.LogError(exception, "Cannot start SignalRBroadcastClient. Error connecting to SignalR server."); + } + }); + } + + public SignalRBroadcastClient( + ILogger logger, + IConfiguration configuration, + IHubConnectionWrapper hubConnectionWrapper) + : this(logger, configuration) + { + _hubConnectionWrapper = hubConnectionWrapper; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + string appBaseUrl = _configuration.GetValue("AppBaseUrl"); + string baseUrl = _configuration.GetValue("BaseUrl"); + string hubBaseUrl = string.IsNullOrEmpty(appBaseUrl) ? baseUrl : appBaseUrl; + string hubUrl = $"{hubBaseUrl}/JiraGateway?groupName={BroadcastGroupName}"; + + await _startPolicy.ExecuteAsync(async () => + { + if (_hubConnectionWrapper == null) + { + var hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl) + .Build(); + + hubConnection.On( + "Broadcast", + async (identifier, response) => + { + await hubConnection.InvokeAsync( + "Broadcast", + identifier, + response, + cancellationToken: cancellationToken); + }); + + hubConnection.Closed += async (error) => + { + await Task.Delay(new Random().Next(0, 5) * 1000, cancellationToken); + await hubConnection.StartAsync(cancellationToken); + _logger.LogInformation("SignalR broadcast client reconnected due to error {ErrorMessage}.", error.Message); + }; + + _hubConnectionWrapper = new HubConnectionWrapper(hubConnection); + } + + await _hubConnectionWrapper.StartAsync(cancellationToken); + _logger.LogInformation("SignalR broadcast client connected."); + }); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_hubConnectionWrapper != null) + { + await _hubConnectionWrapper.StopAsync(cancellationToken); + _logger.LogInformation("SignalR broadcast client disconnected."); + } + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRService.cs b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRService.cs index 032c791..8a2fc93 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRService.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Services/SignalR/SignalRService.cs @@ -1,4 +1,5 @@ using System; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; @@ -7,9 +8,11 @@ using MicrosoftTeamsIntegration.Jira.Exceptions; using MicrosoftTeamsIntegration.Jira.Helpers; using MicrosoftTeamsIntegration.Jira.Models.Jira; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Services.SignalR.Interfaces; using MicrosoftTeamsIntegration.Jira.Settings; +using Newtonsoft.Json; using NonBlocking; namespace MicrosoftTeamsIntegration.Jira.Services.SignalR @@ -18,6 +21,8 @@ public class SignalRService : ISignalRService { private readonly IHubContext _hub; private readonly IDatabaseService _databaseService; + private readonly INotificationQueueService _notificationQueueService; + private readonly INotificationProcessorService _notificationProcessorService; private readonly ILogger _logger; private readonly AppSettings _appSettings; @@ -27,11 +32,15 @@ public SignalRService( IHubContext hub, IDatabaseService databaseService, ILogger logger, - IOptionsMonitor appSettings) + IOptionsMonitor appSettings, + INotificationQueueService notificationQueueService, + INotificationProcessorService notificationProcessorService) { _hub = hub; _databaseService = databaseService; _logger = logger; + _notificationQueueService = notificationQueueService; + _notificationProcessorService = notificationProcessorService; _appSettings = appSettings.CurrentValue; } @@ -57,19 +66,19 @@ public async Task SendRequestAndWaitForResponse(string jiraS "SignalRClient SendRequest started: {Identifier} | {Message} | {CurrentThreadId} | {ClientResponses.Keys}", identifier.ToString(), SanitizingHelpers.SanitizeMessage(message), - Thread.CurrentThread.ManagedThreadId.ToString(), + Environment.CurrentManagedThreadId.ToString(), ClientResponses.GetLog()); var connectionId = addonSettings.ConnectionId; // Call MakeRequest method on the client passing the identifier - await _hub.Clients.Client(connectionId).SendAsync("MakeRequest", identifier, message, cancellationToken); + await _hub.Clients.Client(connectionId).SendCoreAsync("MakeRequest", new object[] { identifier, message }, cancellationToken); _logger.LogTrace( "SignalRClient SendRequest request sent: {Identifier} | {Message} | {CurrentThreadId} | {ClientResponses.Keys}", identifier.ToString(), SanitizingHelpers.SanitizeMessage(message), - Thread.CurrentThread.ManagedThreadId.ToString(), + Environment.CurrentManagedThreadId.ToString(), ClientResponses.GetLog()); try @@ -102,7 +111,7 @@ public async Task SendRequestAndWaitForResponse(string jiraS "SignalRClient SendRequest request sent: {Identifier} | {Message} | {CurrentThreadId} | {RemoveResult} | {ClientResponses.Keys}", identifier.ToString(), SanitizingHelpers.SanitizeMessage(message), - Thread.CurrentThread.ManagedThreadId.ToString(), + Environment.CurrentManagedThreadId.ToString(), removeResult.ToString(), ClientResponses.GetLog()); } @@ -116,19 +125,19 @@ public async Task SendRequestAndWaitForResponse(string jiraS "{Identifier} | {Message} | {CurrentThreadId} | {ClientResponses.Keys}", identifier.ToString(), SanitizingHelpers.SanitizeMessage(message), - Thread.CurrentThread.ManagedThreadId.ToString(), + Environment.CurrentManagedThreadId.ToString(), ClientResponses.GetLog()); throw jiraServerGeneralException; } - public Task Callback(Guid identifier, string response) + public async Task Callback(Guid identifier, string response) { _logger.LogTrace( "SignalRClient Callback called: {Identifier} | {Response} | {CurrentThreadId} | {ClientResponses.Keys}", identifier.ToString(), response, - Thread.CurrentThread.ManagedThreadId.ToString(), + Environment.CurrentManagedThreadId.ToString(), ClientResponses.GetLog()); if (ClientResponses.TryGetValue(identifier, out var tcs)) @@ -137,7 +146,39 @@ public Task Callback(Guid identifier, string response) "SignalRClient Callback getting response from ClientResponses successful: {Identifier} | {Response} | {CurrentThreadId} | {ClientResponses.Keys}", identifier.ToString(), response, - Thread.CurrentThread.ManagedThreadId.ToString(), + Environment.CurrentManagedThreadId.ToString(), + ClientResponses.GetLog()); + + // Trigger the task continuation + tcs.TrySetResult(response); + } + else + { + // Send response to all Broadcast clients for processing on different server + _logger.LogTrace("SignalRClient Callback will be sent to broadcast clients"); + await _hub.Clients.Group(SignalRBroadcastClient.BroadcastGroupName).SendCoreAsync( + "Broadcast", + [identifier, response], + CancellationToken.None); + } + } + + public Task Broadcast(Guid identifier, string response) + { + _logger.LogTrace( + "SignalRClient Broadcast called: {Identifier} | {Response} | {CurrentThreadId} | {ClientResponses.Keys}", + identifier.ToString(), + response, + Environment.CurrentManagedThreadId.ToString(), + ClientResponses.GetLog()); + + if (ClientResponses.TryGetValue(identifier, out var tcs)) + { + _logger.LogTrace( + "SignalRClient Broadcast getting response from ClientResponses successful: {Identifier} | {Response} | {CurrentThreadId} | {ClientResponses.Keys}", + identifier.ToString(), + response, + Environment.CurrentManagedThreadId.ToString(), ClientResponses.GetLog()); // Trigger the task continuation @@ -151,5 +192,50 @@ public Task Callback(Guid identifier, string response) return Task.CompletedTask; } + + public async Task Notification(Guid identifier, string response) + { + _logger.LogTrace( + "SignalRClient Broadcast called: {Identifier} | {Response} | {CurrentThreadId} | {ClientResponses.Keys}", + identifier.ToString(), + response, + Environment.CurrentManagedThreadId.ToString(), + ClientResponses.GetLog()); + + if (string.IsNullOrEmpty(response)) + { + return; + } + + var messageSize = Encoding.UTF8.GetByteCount(response); + + // The maximum size of a Azure Queue message is 64KB + var maxQueueMessageSize = 64000; + if (messageSize > maxQueueMessageSize) + { + try + { + _logger.LogWarning( + "SignalRClient Notification message size is too large: {Identifier} | {MessageSize}. Try to process message directly.", + identifier.ToString(), + messageSize); + var notificationMessage = JsonConvert.DeserializeObject(response); + + await _notificationProcessorService.ProcessNotification(notificationMessage); + return; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "SignalRClient Notification message cannot be processed: {Identifier} | {MessageSize}", + identifier.ToString(), + messageSize); + } + } + + // Add message to the queue + await _notificationQueueService.QueueNotificationMessage(response); + } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Settings/AppSettings.cs b/src/MicrosoftTeamsIntegration.Jira/Settings/AppSettings.cs index 9dccef6..99ad746 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Settings/AppSettings.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Settings/AppSettings.cs @@ -1,4 +1,4 @@ -namespace MicrosoftTeamsIntegration.Jira.Settings +namespace MicrosoftTeamsIntegration.Jira.Settings { public class AppSettings { @@ -19,5 +19,6 @@ public class AppSettings // space separated list of uls that should be added to CSP list public string CspValidDomains { get; set; } public string AnalyticsEnvironment { get; set; } + public string NotificationJobSchedule { get; set; } } } diff --git a/src/MicrosoftTeamsIntegration.Jira/Startup.cs b/src/MicrosoftTeamsIntegration.Jira/Startup.cs index d16ba00..36753f8 100644 --- a/src/MicrosoftTeamsIntegration.Jira/Startup.cs +++ b/src/MicrosoftTeamsIntegration.Jira/Startup.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using AutoMapper; using JetBrains.Annotations; @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.SpaServices.AngularCli; +using Microsoft.Azure.SignalR; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Azure.Blobs; using Microsoft.Extensions.Configuration; @@ -22,6 +23,7 @@ using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Exceptions; using MicrosoftTeamsIntegration.Jira.Helpers; +using MicrosoftTeamsIntegration.Jira.Jobs; using MicrosoftTeamsIntegration.Jira.Services; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Services.SignalR; @@ -29,6 +31,7 @@ using MicrosoftTeamsIntegration.Jira.Settings; using Newtonsoft.Json; using Polly.Contrib.WaitAndRetry; +using Quartz; using Refit; namespace MicrosoftTeamsIntegration.Jira @@ -58,6 +61,7 @@ public void ConfigureServices(IServiceCollection services) Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 5); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); @@ -70,6 +74,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // This can be removed after https://github.com/aspnet/IISIntegration/issues/371 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -98,19 +104,49 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); services .AddSignalR(options => { options.EnableDetailedErrors = true; options.MaximumReceiveMessageSize = appSettings.JiraServerMaximumReceiveMessageSize; }) - .AddAzureSignalR(_configuration["Azure:SignalR:ConnectionString"]) + .AddAzureSignalR(options => + { + options.Endpoints = + [ + + // Add additional endpoints here if needed, i.e. + // new ServiceEndpoint(_configuration["Azure:SignalR:ConnectionString:EU"]), + new ServiceEndpoint(_configuration["Azure:SignalR:ConnectionString"]) + ]; + }) .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.NullValueHandling = NullValueHandling.Ignore; options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); + // Add Redis backplane for redis only for non-development environments + if (!_env.IsDevelopment()) + { + services.AddStackExchangeRedisCache(options => + { + options.Configuration = appSettings.CacheConnectionString; + }); + } + + services.AddQuartz(q => + { + var jobKey = new JobKey("NotificationJob"); + q.AddJob(options => options.WithIdentity(jobKey)); + q.AddTrigger(options => options + .ForJob(jobKey) + .WithIdentity("NotificationJob-trigger") + .WithCronSchedule(appSettings.NotificationJobSchedule)); + }); + services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + // Auto Mapper Configurations var mappingConfig = new MapperConfiguration(mc => { mc.AddProfile(new JiraMappingProfile(appSettings)); }); var mapper = mappingConfig.CreateMapper(); @@ -326,6 +362,7 @@ private static HeaderPolicyCollection BuildHeaderPolicyCollection(AppSettings ap .From("teams.microsoft.com") .From("*.teams.microsoft.com") .From("*.teams.microsoft.us") + .From("teams.cloud.microsoft") .From("*.skype.com") .From("*.msteams-atlassian.com") .From("*.office.com"); diff --git a/src/MicrosoftTeamsIntegration.Jira/TypeConverters/JiraIssueToAdaptiveCardTypeConverter.cs b/src/MicrosoftTeamsIntegration.Jira/TypeConverters/JiraIssueToAdaptiveCardTypeConverter.cs index 5a9309a..7b2f722 100644 --- a/src/MicrosoftTeamsIntegration.Jira/TypeConverters/JiraIssueToAdaptiveCardTypeConverter.cs +++ b/src/MicrosoftTeamsIntegration.Jira/TypeConverters/JiraIssueToAdaptiveCardTypeConverter.cs @@ -291,7 +291,7 @@ public AdaptiveCard Convert(BotAndMessagingExtensionJiraIssue model, AdaptiveCar return card; } - // Edit button is common fro personal and team scope + // Edit button is common for personal and team scope card.Actions.Add(new AdaptiveSubmitAction { Title = DialogTitles.EditTitle, diff --git a/src/MicrosoftTeamsIntegration.Jira/TypeConverters/NotificationMessageToAdaptiveCardConverter.cs b/src/MicrosoftTeamsIntegration.Jira/TypeConverters/NotificationMessageToAdaptiveCardConverter.cs new file mode 100644 index 0000000..dfd8715 --- /dev/null +++ b/src/MicrosoftTeamsIntegration.Jira/TypeConverters/NotificationMessageToAdaptiveCardConverter.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AdaptiveCards; +using AutoMapper; +using MicrosoftTeamsIntegration.Jira.Dialogs; +using MicrosoftTeamsIntegration.Jira.Models.Bot; +using MicrosoftTeamsIntegration.Jira.Models.FetchTask; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; + +namespace MicrosoftTeamsIntegration.Jira.TypeConverters; + +public class NotificationMessageToAdaptiveCardConverter : ITypeConverter +{ + private const string UnknownUserIconUrl = "https://product-integrations-cdn.atl-paas.net/icons/unknown-user.png"; + private const int AdaptiveCardTextTruncationLimit = 500; + + public AdaptiveCard Convert(NotificationMessageCardPayload source, AdaptiveCard destination, ResolutionContext context) + { + var adaptiveCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 4)); + + adaptiveCard.Body = new List + { + new AdaptiveContainer + { + Items = new List + { + new AdaptiveColumnSet + { + Columns = new List + { + new AdaptiveColumn + { + Width = "auto", + Items = new List + { + new AdaptiveImage + { + Url = new Uri(source.User.AvatarUrl?.ToString() ?? UnknownUserIconUrl), + Size = AdaptiveImageSize.Small, + Height = new AdaptiveHeight(32), + PixelWidth = 32, + HorizontalAlignment = AdaptiveHorizontalAlignment.Right, + Style = AdaptiveImageStyle.Person + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center + }, + new AdaptiveColumn + { + Width = "stretch", + Items = new List + { + new AdaptiveTextBlock + { + Text = BuildNotificationTitleMessage(source), + Size = AdaptiveTextSize.Large, + Wrap = true + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center + } + } + } + } + }, + new AdaptiveColumnSet + { + Columns = new List + { + new AdaptiveColumn + { + Width = "auto", + Items = new List + { + new AdaptiveImage + { + Url = + new Uri( + $"https://product-integrations-cdn.atl-paas.net/jira-issuetype/medium/{source.Issue.Type.ToLower()}.png"), + Size = AdaptiveImageSize.Small, + PixelWidth = 24, + HorizontalAlignment = AdaptiveHorizontalAlignment.Right + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center + }, + new AdaptiveColumn + { + Width = "80", + Items = new List + { + new AdaptiveTextBlock + { + Text = $"{source.Issue.Key} - {source.Issue.Summary}", + Size = AdaptiveTextSize.Medium, + Wrap = true + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center + }, + new AdaptiveColumn + { + Width = "20", + Items = new List + { + new AdaptiveRichTextBlock + { + Inlines = new List + { + new AdaptiveTextRun + { + Text = source.Issue.Status.ToUpper(), + Weight = AdaptiveTextWeight.Bolder, + Highlight = true, + IsSubtle = true, + Color = AdaptiveTextColor.Default + } + } + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center, + Rtl = true + }, + new AdaptiveColumn + { + Width = "auto", + Items = new List + { + new AdaptiveImage + { + Url = new Uri(source.Issue.Assignee?.AvatarUrl?.ToString() ?? UnknownUserIconUrl), + Size = AdaptiveImageSize.Small, + Style = AdaptiveImageStyle.Person, + PixelWidth = 24, + HorizontalAlignment = AdaptiveHorizontalAlignment.Right + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center + } + }, + Separator = true, + Spacing = AdaptiveSpacing.ExtraLarge + } + }; + + List actions = new List(); + + List changeLogColumns = new List(); + + if (source.Changelog != null) + { + foreach (var changelog in source.Changelog) + { + changeLogColumns.Add( + CreateTextComponent(BuildNotificationTransitionMessage(source, changelog))); + } + } + else + { + changeLogColumns.Add( + CreateTextComponent(BuildNotificationTransitionMessage(source, null))); + } + + actions.Add(new AdaptiveOpenUrlAction + { + Title = "Open in Jira", + Url = new Uri(source.Issue.Self.ToString()) + }); + + var commentIssueTaskModuleAction = new JiraBotTeamsDataWrapper + { + FetchTaskData = new FetchTaskBotCommand( + DialogMatchesAndCommands.CommentIssueTaskModuleCommand, + source.Issue.Id.ToString(), + source.Issue.Key), + TeamsData = new TeamsData + { + Type = "task/fetch" + } + }; + + actions.Add(new AdaptiveSubmitAction + { + Title = DialogTitles.CommentTitle, + Data = commentIssueTaskModuleAction + }); + + actions.Add(new AdaptiveSubmitAction + { + Title = DialogTitles.EditTitle, + Data = new JiraBotTeamsDataWrapper + { + FetchTaskData = new FetchTaskBotCommand( + DialogMatchesAndCommands.EditIssueTaskModuleCommand, + source.Issue.Id.ToString(), + source.Issue.Key), + TeamsData = new TeamsData + { + Type = "task/fetch" + } + } + }); + + if (source.IsPersonalNotification) + { + actions.Add(new AdaptiveSubmitAction + { + Title = DialogTitles.VoteTitle, + Data = new JiraBotTeamsDataWrapper + { + TeamsData = new TeamsData + { + Type = "imBack", + Value = $"{DialogMatchesAndCommands.VoteDialogCommand} {source.Issue.Key}" + } + } + }); + + actions.Add(new AdaptiveSubmitAction + { + Title = DialogTitles.LogTimeTitle, + Data = new JiraBotTeamsDataWrapper + { + TeamsData = new TeamsData + { + Type = "imBack", + Value = $"{DialogMatchesAndCommands.LogTimeDialogCommand} {source.Issue.Key}" + } + } + }); + } + + adaptiveCard.Body.AddRange(changeLogColumns); + + adaptiveCard.Body.Add(new AdaptiveActionSet + { + Actions = actions + }); + + adaptiveCard.AdditionalProperties = new SerializableDictionary + { + { "msTeams", new { width = "full" } } + }; + + return adaptiveCard; + } + + private static AdaptiveColumnSet CreateTextComponent(string text) + { + return new AdaptiveColumnSet + { + Columns = new List + { + new AdaptiveColumn + { + Width = "180", + Items = new List + { + new AdaptiveTextBlock + { + Text = text, + Wrap = true + } + }, + VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center + }, + } + }; + } + + private static string BuildNotificationTitleMessage(NotificationMessageCardPayload notificationMessage) + { + switch (notificationMessage.EventType.ToEventType()) + { + case NotificationEventType.IssueCreated: + return $"{notificationMessage.User.Name} **created** this issue:"; + case NotificationEventType.CommentUpdated: + return notificationMessage.IsMention ? $"{notificationMessage.User.Name} **mentioned** you in a comment:" : $"{notificationMessage.User.Name} **updated comment** on this issue:"; + case NotificationEventType.CommentDeleted: + return $"{notificationMessage.User.Name} **removed comment** from this issue:"; + case NotificationEventType.CommentCreated: + return notificationMessage.IsMention ? $"{notificationMessage.User.Name} **mentioned** you in a comment:" : $"{notificationMessage.User.Name} **commented** on this issue:"; + default: + { + if (notificationMessage.IsMention) + { + return $"{notificationMessage.User.Name} **mentioned** you in an issue:"; + } + + var updatedFields = string.Join( + ", ", + notificationMessage.Changelog?.Select(c => c.Field.ToLower()) ?? Array.Empty()); + return !string.IsNullOrWhiteSpace(updatedFields) ? + $"{notificationMessage.User.Name} updated the **{updatedFields}** on this issue:" : + $"{notificationMessage.User.Name} updated this issue:"; + } + } + } + + private static string BuildNotificationTransitionMessage( + NotificationMessageCardPayload notificationMessage, + NotificationChangelog changelog) + { + switch (notificationMessage.EventType.ToEventType()) + { + case NotificationEventType.CommentCreated: + case NotificationEventType.CommentUpdated: + return TruncateText(notificationMessage.Comment?.Content); + case NotificationEventType.IssueCreated: + case NotificationEventType.CommentDeleted: + return string.Empty; + default: + { + string fromText = string.IsNullOrEmpty(changelog?.From) ? "None" : TruncateText(changelog.From); + string toText = string.IsNullOrEmpty(changelog?.To) ? "None" : TruncateText(changelog.To); + string changelogText = $"{fromText} **\u2192** {toText}"; + string changelogTextWithNewLines = $"{fromText}\n\n**\u2192**\n\n{toText}"; + return changelogText.Length > 85 ? changelogTextWithNewLines : changelogText; + } + } + } + + private static string TruncateText(string sourceText, int limit = AdaptiveCardTextTruncationLimit) + { + if (!string.IsNullOrEmpty(sourceText) && sourceText.Length > limit) + { + return sourceText.Substring(0, limit); + } + + return sourceText; + } +} diff --git a/src/MicrosoftTeamsIntegration.Jira/appsettings.Development.json b/src/MicrosoftTeamsIntegration.Jira/appsettings.Development.json index b5d1ea5..4b7cd8f 100644 --- a/src/MicrosoftTeamsIntegration.Jira/appsettings.Development.json +++ b/src/MicrosoftTeamsIntegration.Jira/appsettings.Development.json @@ -1,5 +1,6 @@ { "BaseUrl": "", + "AppBaseUrl": "", "DatabaseUrl": "", "MicrosoftAppId": "", "MicrosoftAppPassword": "", @@ -7,6 +8,7 @@ "AddonKey": "microsoft-teams-jira-dev", "StorageConnectionString": "", "BotDataStoreContainer": "", + "NotificationQueueName": "", "CacheConnectionString": "", "IdentityServiceUrl": "", "SignalRConnectionString": "", diff --git a/src/MicrosoftTeamsIntegration.Jira/appsettings.json b/src/MicrosoftTeamsIntegration.Jira/appsettings.json index 949618d..99dd916 100644 --- a/src/MicrosoftTeamsIntegration.Jira/appsettings.json +++ b/src/MicrosoftTeamsIntegration.Jira/appsettings.json @@ -1,6 +1,7 @@ { "JiraServerResponseTimeoutInSeconds": 30, "JiraServerMaximumReceiveMessageSize": 8388608, + "NotificationJobSchedule": "*/10 * * * * ?", "ClientAppOptions": { "ResultItemsPerPage": 50 }, diff --git a/tests/MicrosoftTeamsIntegration.Artifacts.Tests/MicrosoftTeamsIntegration.Artifacts.Tests.csproj b/tests/MicrosoftTeamsIntegration.Artifacts.Tests/MicrosoftTeamsIntegration.Artifacts.Tests.csproj index a86e1b4..0911ba5 100644 --- a/tests/MicrosoftTeamsIntegration.Artifacts.Tests/MicrosoftTeamsIntegration.Artifacts.Tests.csproj +++ b/tests/MicrosoftTeamsIntegration.Artifacts.Tests/MicrosoftTeamsIntegration.Artifacts.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,11 +8,11 @@ - - + + - + diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/ClientAppControllerTest.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/ClientAppControllerTest.cs index 7375603..b886ac2 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/ClientAppControllerTest.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/ClientAppControllerTest.cs @@ -3,7 +3,9 @@ using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Controllers; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Settings; using Xunit; @@ -22,6 +24,13 @@ public class ClientAppControllerTest new List>(), new List>())); + private readonly INotificationSubscriptionService _notificationSubscriptionService = A.Fake(); + private readonly IDatabaseService _databaseService = A.Fake(); + private readonly IJiraAuthService _jiraAuthService = A.Fake(); + private readonly IBotMessagesService _botMessagesService = A.Fake(); + private readonly IDistributedCacheService _distributedCacheService = A.Fake(); + private readonly IProactiveMessagesService _proactiveMessagesService = A.Fake(); + [Fact] public void GetClientAppSettingsTest() { @@ -40,9 +49,16 @@ public void GetClientAppSettingsTest() private ClientAppController CreateClientAppController() { return A.Fake( - x => x.WithArgumentsForConstructor(new object[] { + x => x.WithArgumentsForConstructor(new object[] + { _appSettings, - _telemetryConfiguration + _telemetryConfiguration, + _notificationSubscriptionService, + _proactiveMessagesService, + _botMessagesService, + _distributedCacheService, + _databaseService, + _jiraAuthService })); } } diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/JiraApiControllerTest.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/JiraApiControllerTest.cs index 04185d5..c836543 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/JiraApiControllerTest.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Controllers/JiraApiControllerTest.cs @@ -823,7 +823,6 @@ private JiraApiController CreateJiraApiController() var jiraApiController = A.Fake( x => x.WithArgumentsForConstructor(new object[] { _fakeDatabaseService, - _appSettings, _fakeJiraService, _fakeMapper, _fakeJiraAuthService, diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/DisconnectJiraDialogTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/DisconnectJiraDialogTests.cs index e6e4bbe..7241202 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/DisconnectJiraDialogTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/DisconnectJiraDialogTests.cs @@ -1,9 +1,10 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using FakeItEasy; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Bot.Builder.Testing; using Microsoft.Bot.Builder.Testing.XUnit; using Microsoft.Bot.Connector; @@ -27,6 +28,7 @@ public class DisconnectJiraDialogTests private readonly IJiraAuthService _fakeJiraAuthService; private readonly AppSettings _appSettings; private readonly IAnalyticsService _analyticsService; + private readonly INotificationSubscriptionService _notificationSubscriptionService; public DisconnectJiraDialogTests(ITestOutputHelper output) { @@ -38,6 +40,7 @@ public DisconnectJiraDialogTests(ITestOutputHelper output) _appSettings = new AppSettings(); _telemetry = new TelemetryClient(TelemetryConfiguration.CreateDefault()); _analyticsService = A.Fake(); + _notificationSubscriptionService = A.Fake(); } [Fact] @@ -65,6 +68,7 @@ public async Task Disconnect_WhenUserConnectedAndConfirmLogout() { IsSuccess = true }); + A.CallTo(() => _notificationSubscriptionService.GetNotificationSubscription(A._)).Returns((NotificationSubscription)null); var reply = await testClient.SendActivityAsync("disconnect"); var confirmReply = await testClient.SendActivityAsync("Yes"); @@ -87,6 +91,7 @@ public async Task Disconnect_WhenUserConnectedButNotConfirmLogout() { IsSuccess = true }); + A.CallTo(() => _notificationSubscriptionService.GetNotificationSubscription(A._)).Returns((NotificationSubscription)null); var reply = await testClient.SendActivityAsync("disconnect"); var confirmReply = await testClient.SendActivityAsync("No"); @@ -99,7 +104,7 @@ public async Task Disconnect_WhenUserConnectedButNotConfirmLogout() private DisconnectJiraDialog GetDisconnectJiraDialog() { - return new DisconnectJiraDialog(_fakeAccessors, _fakeJiraAuthService, _appSettings, _telemetry, _analyticsService); + return new DisconnectJiraDialog(_fakeAccessors, _fakeJiraAuthService, _appSettings, _telemetry, _analyticsService, _notificationSubscriptionService); } } } diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/MainDispatcherTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/MainDispatcherTests.cs index 1dc63c6..6993e83 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/MainDispatcherTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/MainDispatcherTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using AdaptiveCards; @@ -33,6 +33,7 @@ public class MainDispatcherTests private readonly IBotMessagesService _fakeBotMessagesService; private readonly IJiraAuthService _fakeJiraAuthService; private readonly IDatabaseService _fakeDatabaseService; + private readonly INotificationSubscriptionService _fakeNotificationSubscriptionService; private readonly IJiraService _fakeJiraService; private readonly ILogger _fakeLogger; private readonly TelemetryClient _telemetry; @@ -50,6 +51,7 @@ public MainDispatcherTests(ITestOutputHelper output) _fakeBotMessagesService = A.Fake(); _fakeJiraService = A.Fake(); _fakeDatabaseService = A.Fake(); + _fakeNotificationSubscriptionService = A.Fake(); _appSettings = new AppSettings(); _telemetry = new TelemetryClient(TelemetryConfiguration.CreateDefault()); _fakeUserTokenService = A.Fake(); @@ -295,7 +297,8 @@ private MainDispatcher GetMainDispatcher() _fakeUserTokenService, new CommandDialogReferenceService(), _fakeBotFrameworkAdapterService, - _analyticsService); + _analyticsService, + _fakeNotificationSubscriptionService); return dispatcher; } diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/NotificationsDialogTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/NotificationsDialogTests.cs new file mode 100644 index 0000000..47eab39 --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Dialogs/NotificationsDialogTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Testing; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using MicrosoftTeamsIntegration.Artifacts.Extensions; +using MicrosoftTeamsIntegration.Jira.Dialogs; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Bot; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Settings; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests.Dialogs +{ + public class NotificationsDialogTests + { + private readonly JiraBotAccessors _fakeAccessors; + private readonly IBotMessagesService _fakeBotMessagesService; + private readonly AppSettings _appSettings; + private readonly TelemetryClient _telemetry; + private readonly INotificationSubscriptionService _fakeNotificationSubscriptionService; + + public NotificationsDialogTests() + { + _fakeAccessors = A.Fake(); + _fakeAccessors.User = A.Fake>(); + _fakeBotMessagesService = A.Fake(); + _appSettings = new AppSettings(); + _telemetry = new TelemetryClient(TelemetryConfiguration.CreateDefault()); + _fakeNotificationSubscriptionService = A.Fake(); + } + + [Fact] + public async Task NotificationsDialog_WhenUserIsNotConnected_ShouldEndDialog() + { + // Arrange + var sut = new NotificationsDialog(_fakeAccessors, _fakeBotMessagesService, _appSettings, _telemetry, _fakeNotificationSubscriptionService); + var testClient = new DialogTestClient(Channels.Test, sut); + + A.CallTo(() => _fakeAccessors.User.GetAsync(A._, A>._, CancellationToken.None)).Returns(null as IntegratedUser); + + // Act + await testClient.SendActivityAsync("start"); + + // Assert + Assert.Equal(DialogTurnStatus.Complete, testClient.DialogTurnResult.Status); + } + + [Fact] + public async Task NotificationsDialog_WhenInGroupConversation_ShouldSendConfigureNotificationsCard() + { + // Arrange + var sut = new NotificationsDialog(_fakeAccessors, _fakeBotMessagesService, _appSettings, _telemetry, _fakeNotificationSubscriptionService); + var testClient = new DialogTestClient(Channels.Msteams, sut); + + var fakeUser = new IntegratedUser(); + A.CallTo(() => _fakeAccessors.User.GetAsync(A._, A>._, A._)).Returns(fakeUser); + + // Act + await testClient.SendActivityAsync("notifications"); + + // Assert + A.CallTo(() => _fakeBotMessagesService.SendConfigureNotificationsCard(A._, A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task NotificationsDialog_WhenPersonalSubscriptionExists_ShouldSendSummaryCard() + { + // Arrange + var sut = new NotificationsDialog(_fakeAccessors, _fakeBotMessagesService, _appSettings, _telemetry, _fakeNotificationSubscriptionService); + var testClient = new DialogTestClient(Channels.Msteams, sut); + + var fakeUser = new IntegratedUser() + { + MsTeamsUserId = "test-user-id", + }; + var fakeSubscription = new NotificationSubscription + { + IsActive = true, + EventTypes = new[] { "event1", "event2" } + }; + + A.CallTo(() => _fakeAccessors.User.GetAsync(A._, A>._, A._)).Returns(fakeUser); + A.CallTo(() => _fakeNotificationSubscriptionService.GetNotificationSubscription(fakeUser)).Returns(fakeSubscription); + + // Act + await testClient.SendActivityAsync("notifications"); + + // Assert + A.CallTo(() => _fakeBotMessagesService.BuildNotificationConfigurationSummaryCard(fakeSubscription, false)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task NotificationsDialog_WhenNoActiveSubscription_ShouldSendConfigureNotificationsCard() + { + // Arrange + var sut = new NotificationsDialog(_fakeAccessors, _fakeBotMessagesService, _appSettings, _telemetry, _fakeNotificationSubscriptionService); + var testClient = new DialogTestClient(Channels.Msteams, sut); + + var fakeUser = new IntegratedUser(); + A.CallTo(() => _fakeAccessors.User.GetAsync(A._, A>._, A._)).Returns(fakeUser); + A.CallTo(() => _fakeNotificationSubscriptionService.GetNotificationSubscription(fakeUser)).Returns(null as NotificationSubscription); + + // Act + await testClient.SendActivityAsync("notifications"); + + // Assert + A.CallTo(() => _fakeBotMessagesService.SendConfigureNotificationsCard(A._, A._)) + .MustHaveHappenedOnceExactly(); + } + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/GatewayHubTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/GatewayHubTests.cs new file mode 100644 index 0000000..795661c --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/GatewayHubTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Services.SignalR.Interfaces; +using Moq; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests; + +public class GatewayHubTests : IDisposable +{ + private readonly Mock _databaseServiceMock; + private readonly Mock _signalRServiceMock; + private readonly Mock _contextMock; + private readonly Mock _groupsMock; + private readonly GatewayHub _gatewayHub; + + private bool _disposed; + + public GatewayHubTests() + { + _databaseServiceMock = new Mock(); + var loggerMock = new Mock>(); + _signalRServiceMock = new Mock(); + _contextMock = new Mock(); + _groupsMock = new Mock(); + + _gatewayHub = new GatewayHub( + _databaseServiceMock.Object, + loggerMock.Object, + _signalRServiceMock.Object) + { + Context = _contextMock.Object, + Groups = _groupsMock.Object + }; + } + + [Fact] + public async Task Callback_ShouldCallSignalRServiceCallback() + { + // Arrange + var identifier = Guid.NewGuid(); + var response = "response-message"; + + // Act + await _gatewayHub.Callback(identifier, response); + + // Assert + _signalRServiceMock.Verify(s => s.Callback(identifier, response), Times.Once); + } + + [Fact] + public async Task Broadcast_ShouldCallSignalRServiceBroadcast() + { + // Arrange + var identifier = Guid.NewGuid(); + var response = "response-message"; + + // Act + await _gatewayHub.Broadcast(identifier, response); + + // Assert + _signalRServiceMock.Verify(s => s.Broadcast(identifier, response), Times.Once); + } + + [Fact] + public async Task OnConnectedAsync_ShouldAddToGroupAndUpdateDatabase() + { + // Arrange + var connectionId = "test-connection-id"; + var jiraId = "test-jira-id"; + var jiraInstanceUrl = "http://jira-instance.com"; + var version = "1.0.0"; + var groupName = "test-group"; + + var httpContextMock = new Mock(); + httpContextMock.Setup(h => h.Request.QueryString) + .Returns(new QueryString($"?atlasId={jiraId}&atlasUrl={jiraInstanceUrl}&pluginVersion={version}&groupName={groupName}")); + var httpContextFeatureMock = new Mock(); + httpContextFeatureMock.Setup(f => f.HttpContext).Returns(httpContextMock.Object); + + var featuresMock = new Mock(); + featuresMock.Setup(f => f.Get()).Returns(httpContextFeatureMock.Object); + + _contextMock.Setup(c => c.ConnectionId).Returns(connectionId); + _contextMock.Setup(c => c.Features).Returns(featuresMock.Object); + + // Act + await _gatewayHub.OnConnectedAsync(); + + // Assert + _databaseServiceMock.Verify(d => d.CreateOrUpdateJiraServerAddonSettings(jiraId, jiraInstanceUrl, connectionId, version), Times.Once); + _groupsMock.Verify(g => g.AddToGroupAsync(connectionId, groupName, CancellationToken.None), Times.Once); + } + + [Fact] + public async Task OnDisconnectedAsync_ShouldDeleteDatabaseEntry() + { + // Arrange + var connectionId = "test-connection-id"; + _contextMock.Setup(c => c.ConnectionId).Returns(connectionId); + + // Act + await _gatewayHub.OnDisconnectedAsync(null); + + // Assert + _databaseServiceMock.Verify(d => d.DeleteJiraServerAddonSettingsByConnectionId(connectionId), Times.Once); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _gatewayHub.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/JiraBotTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/JiraBotTests.cs index 39bc511..8d9aeed 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/JiraBotTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/JiraBotTests.cs @@ -1,4 +1,4 @@ -using System.Threading; +using System.Threading; using System.Threading.Tasks; using FakeItEasy; using Microsoft.ApplicationInsights; @@ -30,6 +30,7 @@ public class JiraBotTests private readonly IAnalyticsService _analyticsService; private readonly IMessagingExtensionService _messagingExtensionService; private readonly IDatabaseService _databaseService; + private readonly INotificationSubscriptionService _notificationSubscriptionService; private readonly IBotMessagesService _botMessagesService; private readonly IJiraService _jiraService; private readonly IActionableMessageService _actionableMessageService; @@ -51,6 +52,7 @@ public JiraBotTests(ITestOutputHelper output) _telemetry = new TelemetryClient(TelemetryConfiguration.CreateDefault()); _messagingExtensionService = A.Fake(); _databaseService = A.Fake(); + _notificationSubscriptionService = A.Fake(); _botMessagesService = A.Fake(); _jiraService = A.Fake(); _actionableMessageService = A.Fake(); @@ -68,6 +70,7 @@ public JiraBotTests(ITestOutputHelper output) _jiraBot = new JiraBot( _messagingExtensionService, _databaseService, + _notificationSubscriptionService, _mockAccessors, _botMessagesService, _jiraService, @@ -86,24 +89,24 @@ public JiraBotTests(ITestOutputHelper output) [Fact] public async Task UserIsAllowedToStartHelpDialog() { - var sut = new HelpDialog(_mockAccessors, new AppSettings(), _telemetry, _analyticsService); + var sut = new HelpDialog(_mockAccessors, new AppSettings(), _telemetry, _analyticsService, _botMessagesService); var testClient = new DialogTestClient(Channels.Test, sut, middlewares: _middleware); // Execute the test case var reply = await testClient.SendActivityAsync("help"); - Assert.Contains("Here’s a list of the commands", reply.Text); + Assert.NotNull(reply.Attachments[0]); Assert.Equal(DialogTurnStatus.Complete, testClient.DialogTurnResult.Status); } [Fact] public async Task UserGetsProperHelpForJiraServer() { - var sut = new HelpDialog(_mockAccessors, new AppSettings(), _telemetry, _analyticsService); + var sut = new HelpDialog(_mockAccessors, new AppSettings(), _telemetry, _analyticsService, _botMessagesService); var testClient = new DialogTestClient(Channels.Test, sut, middlewares: _middleware); // Execute the test case var reply = await testClient.SendActivityAsync("help"); - Assert.Contains("Jira Data Center instance", reply.Text); + Assert.NotNull(reply.Attachments[0]); } [Fact] diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Jobs/NotificationJobTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Jobs/NotificationJobTests.cs new file mode 100644 index 0000000..86d5980 --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Jobs/NotificationJobTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Threading.Tasks; +using Azure.Storage.Queues.Models; +using FakeItEasy; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using MicrosoftTeamsIntegration.Jira.Jobs; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using Newtonsoft.Json; +using Quartz; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests.Jobs; + +public class NotificationJobTests +{ + private readonly INotificationQueueService _notificationQueueServiceFake; + private readonly INotificationProcessorService _notificationProcessorServiceFake; + private readonly FakeLogger _loggerFake; + private readonly NotificationJob _notificationJob; + + public NotificationJobTests() + { + _notificationQueueServiceFake = A.Fake(); + _notificationProcessorServiceFake = A.Fake(); + _loggerFake = new FakeLogger(); + _notificationJob = new NotificationJob( + _notificationProcessorServiceFake, + _notificationQueueServiceFake, + _loggerFake); + } + + [Fact] + public async Task Execute_ShouldProcessMessagesSuccessfully() + { + // Arrange + var notificationMessage = new NotificationMessage + { + JiraId = "test-jira-id", + EventType = "ISSUE_CREATED", + User = new NotificationUser + { + Name = "Test User", + Id = 1, + MicrosoftId = "microsoft-id-triggered", + AvatarUrl = new Uri("https://example.com/avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Issue = new NotificationIssue + { + Id = 10000, + Key = "ISSUE-123", + Summary = "Test issue summary", + Status = "Open", + Type = "Bug", + Assignee = new NotificationUser + { + Name = "Assignee User", + Id = 2, + MicrosoftId = "microsoft-id", + AvatarUrl = new Uri("https://example.com/assignee-avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Reporter = new NotificationUser + { + Name = "Reporter User", + Id = 3, + MicrosoftId = "reporter-microsoft-id", + AvatarUrl = new Uri("https://example.com/reporter-avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Priority = "High", + Self = new Uri("https://example.com/issue"), + ProjectId = 123 + }, + Changelog = new[] + { + new NotificationChangelog + { + Field = "status", + From = "Open", + To = "In Progress" + } + }, + Comment = new NotificationComment + { + Content = "This is a test comment.", + IsInternal = false + }, + Watchers = new[] + { + new NotificationUser + { + Name = "Watcher User", + Id = 4, + MicrosoftId = "watcher-microsoft-id", + AvatarUrl = new Uri("https://example.com/watcher-avatar.png"), + CanViewIssue = true, + CanViewComment = true + } + }, + Mentions = new[] + { + new NotificationUser + { + Name = "Mentioned User", + Id = 5, + MicrosoftId = "mention-microsoft-id", + AvatarUrl = new Uri("https://example.com/mention-avatar.png"), + CanViewIssue = true, + CanViewComment = true + } + } + }; + var notificationMessageJson = JsonConvert.SerializeObject(notificationMessage); + var message = QueuesModelFactory.QueueMessage("message-id", "pop-receipt", new BinaryData(notificationMessageJson), 1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + A.CallTo(() => _notificationQueueServiceFake.DequeueNotificationsMessages(A.Ignored)) + .Returns(new[] { message }); + + A.CallTo(() => _notificationProcessorServiceFake.ProcessNotification(A.Ignored)) + .Returns(Task.CompletedTask); + + A.CallTo(() => _notificationQueueServiceFake.DeleteNotificationMessage(A.Ignored, A.Ignored)) + .Returns(Task.CompletedTask); + + // Act + await _notificationJob.Execute(A.Fake()); + + // Assert + A.CallTo(() => _notificationQueueServiceFake.DequeueNotificationsMessages(A.Ignored)).MustHaveHappenedOnceExactly(); + A.CallTo(() => _notificationProcessorServiceFake.ProcessNotification(A.Ignored)).MustHaveHappenedOnceExactly(); + A.CallTo(() => _notificationQueueServiceFake.DeleteNotificationMessage(A.Ignored, A.Ignored)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Execute_ShouldLogError_WhenExceptionIsThrown() + { + // Arrange + A.CallTo(() => _notificationQueueServiceFake.DequeueNotificationsMessages(A.Ignored)) + .ThrowsAsync(new Exception("Test exception")); + + // Act + await _notificationJob.Execute(A.Fake()); + + // Assert + A.CallTo(() => _notificationProcessorServiceFake.ProcessNotification(A.Ignored)).MustNotHaveHappened(); + Assert.Equal(1, _loggerFake.Collector.Count); + Assert.Equal(LogLevel.Error, _loggerFake.LatestRecord.Level); + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/MicrosoftTeamsIntegration.Jira.Tests.csproj b/tests/MicrosoftTeamsIntegration.Jira.Tests/MicrosoftTeamsIntegration.Jira.Tests.csproj index 7da6518..63f89ac 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/MicrosoftTeamsIntegration.Jira.Tests.csproj +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/MicrosoftTeamsIntegration.Jira.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 false @@ -16,15 +16,13 @@ - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/BotMessagesServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/BotMessagesServiceTests.cs index 3575898..22d166b 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/BotMessagesServiceTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/BotMessagesServiceTests.cs @@ -1,7 +1,8 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using AdaptiveCards; using AutoMapper; using FakeItEasy; using Microsoft.Bot.Builder; @@ -9,7 +10,6 @@ using Microsoft.Bot.Connector; using Microsoft.Bot.Schema; using Microsoft.Extensions.Options; -using MicrosoftTeamsIntegration.Artifacts.Extensions; using MicrosoftTeamsIntegration.Jira.Models; using MicrosoftTeamsIntegration.Jira.Models.Jira.Issue; using MicrosoftTeamsIntegration.Jira.Services; @@ -45,67 +45,6 @@ private class AttachmentData public string HtmlContentWithTagA2GoogleLink { get; set; } } - private AttachmentData BuildTestData(string href = "") - { - var testData = new AttachmentData - { - MockActivity = A.Fake(), - ImgContent = "\"Italian", - - HtmlContentWithoutTagA = - "" + - "" + - " HTML Tutorial " + - "" + - "
" + - "

This is a Heading

" + - "

This is a paragraph.

" + - "
" + - "" + - "", - - HtmlContentWithTagA0CustomLink = - "" + - "" + - " HTML Tutorial " + - "" + - "
" + - "

This is a Heading

" + - "

This is a paragraph.

" + - $"

\\n\\n\\n\\n\\n\\n\\n\\n\\This part is for determine\\r\\r\\r\\r\\r\\r\\r\\r\\r\\ parse behavior with tag a \\n \\r This is title for tag a

" + - "
" + - "" + - "", - - HtmlContentWithTagA1YahooLink = - "" + - "" + - " HTML Tutorial " + - "" + - "
" + - "

This is a Heading

" + - "

This is a paragraph.

" + - $"

\\n\\n\\n\\n\\n\\n\\n\\n\\This part is for determine\\r\\r\\r\\r\\r\\r\\r\\r\\r\\ parse behavior with tag a \\n \\r This is title for tag a

" + - "
" + - "" + - "", - - HtmlContentWithTagA2GoogleLink = - "" + - "" + - " HTML Tutorial " + - "" + - "
" + - "

This is a Heading

" + - "

This is a paragraph.

" + - $"

\\n\\n\\n\\n\\n\\n\\n\\n\\This part is for determine\\r\\r\\r\\r\\r\\r\\r\\r\\r\\ parse behavior with tag a \\n \\r This is title for tag a

" + - "
" + - "" + - "" - }; - return testData; - } - // Verify that we get empty string whether html content contains no tag [Fact] public void HandleHtmlMessageFromUser_HtmlContentWithoutTagA_EmptyString_Test() @@ -280,8 +219,8 @@ public async Task SearchIssueAndBuildIssueCard() A.CallTo(() => _fakeJiraService.Search(A._, A._)) .Returns(new JiraIssueSearch() { - JiraIssues = new JiraIssue[1] - { + JiraIssues = + [ new JiraIssue() { Id = "id", @@ -294,7 +233,8 @@ public async Task SearchIssueAndBuildIssueCard() } } } - } + + ] }); A.CallTo(() => _fakeJiraService.GetUserNameOrAccountId(A._)).Returns("user"); @@ -346,8 +286,8 @@ public async Task BuildAndUpdateJiraIssueCard() A.CallTo(() => _fakeJiraService.Search(A._, A._)) .Returns(new JiraIssueSearch() { - JiraIssues = new JiraIssue[1] - { + JiraIssues = + [ new JiraIssue() { Id = "id", @@ -360,7 +300,8 @@ public async Task BuildAndUpdateJiraIssueCard() } } } - }, + + ], }); A.CallTo(() => _fakeJiraService.GetUserNameOrAccountId(A._)).Returns("user"); @@ -408,7 +349,6 @@ public async Task HandleConversationUpdates_BotAddedToConversation_ShouldSendWel }; var turnContext = A.Fake(); - var connectorClient = A.Fake(); A.CallTo(() => turnContext.Activity).Returns(activity); @@ -485,6 +425,195 @@ public async Task SendConnectCard_ShouldSendConnectCard() A.CallTo(() => turnContext.SendActivityAsync(A._, A._)).MustHaveHappened(); } + [Fact] + public void BuildConfigureNotificationsCard_GroupConversation_ShouldReturnCorrectCard() + { + // Arrange + var activity = new Activity + { + Conversation = new ConversationAccount { IsGroup = true } + }; + var turnContext = A.Fake(); + A.CallTo(() => turnContext.Activity).Returns(activity); + + var service = CreateBotMessagesService(); + + // Act + var card = service.BuildConfigureNotificationsCard(turnContext); + + // Assert + Assert.NotNull(card); + Assert.Contains("🔔 Channel notifications", card.Body.OfType().FirstOrDefault()?.Text); + } + + [Fact] + public void BuildConfigureNotificationsCard_PersonalConversation_ShouldReturnCorrectCard() + { + // Arrange + var activity = new Activity + { + Conversation = new ConversationAccount { IsGroup = false } + }; + var turnContext = A.Fake(); + A.CallTo(() => turnContext.Activity).Returns(activity); + + var service = CreateBotMessagesService(); + + // Act + var card = service.BuildConfigureNotificationsCard(turnContext); + + // Assert + Assert.NotNull(card); + Assert.Contains("🔔 Personal notifications", card.Body.OfType().FirstOrDefault()?.Text); + } + + [Fact] + public void BuildNotificationConfigurationSummaryCard_ShouldReturnCorrectCard() + { + // Arrange + var subscription = new NotificationSubscription + { + EventTypes = new string[] { "ActivityIssueAssignee", "MentionedOnIssue" } + }; + var service = CreateBotMessagesService(); + + // Act + var card = service.BuildNotificationConfigurationSummaryCard(subscription, showSuccessMessage: true); + + // Assert + Assert.NotNull(card); + Assert.Contains("You successfully subscribed to notifications", card.Body.OfType().FirstOrDefault()?.Items.OfType().FirstOrDefault()?.Text); + Assert.Contains("Updates on issues you **assigned** to", card.Body.OfType().FirstOrDefault()?.Items.OfType().Skip(2).FirstOrDefault()?.Text); + Assert.Contains("Someone **mentioned** you", card.Body.OfType().FirstOrDefault()?.Items.OfType().LastOrDefault()?.Text); + } + + [Fact] + public async Task SendConfigureNotificationsCard_GroupConversation_ShouldSendCard() + { + // Arrange + var activity = new Activity + { + Conversation = new ConversationAccount { IsGroup = true } + }; + var turnContext = A.Fake(); + A.CallTo(() => turnContext.Activity).Returns(activity); + + var service = CreateBotMessagesService(); + + // Act + await service.SendConfigureNotificationsCard(turnContext, CancellationToken.None); + + // Assert + A.CallTo(() => turnContext.SendActivityAsync(A._, A._)).MustHaveHappened(); + } + + [Fact] + public void BuildHelpCard_GroupConversation_ShouldReturnCorrectCard() + { + // Arrange + var activity = new Activity + { + Conversation = new ConversationAccount { IsGroup = true } + }; + var turnContext = A.Fake(); + A.CallTo(() => turnContext.Activity).Returns(activity); + + var service = CreateBotMessagesService(); + + // Act + var card = service.BuildHelpCard(turnContext); + + // Assert + Assert.NotNull(card); + Assert.Contains("How I can help you?", card.Body.OfType().FirstOrDefault()?.Items.OfType().FirstOrDefault()?.Text); + Assert.DoesNotContain("Connect", card.Body.OfType().SelectMany(ac => ac.Items).OfType().Where(x => x.IsVisible).SelectMany(cs => cs.Columns).SelectMany(c => c.Items.OfType()).SelectMany(a => a.Actions.OfType()).Select(a => a.Title)); + Assert.Equal(7, card.Body.OfType().SelectMany(ac => ac.Items).OfType().Where(x => x.IsVisible).SelectMany(cs => cs.Columns).SelectMany(c => c.Items.OfType()).SelectMany(a => a.Actions.OfType()).Count()); + } + + [Fact] + public void BuildHelpCard_PersonalConversation_ShouldReturnCorrectCard() + { + // Arrange + var activity = new Activity + { + Conversation = new ConversationAccount { IsGroup = false } + }; + var turnContext = A.Fake(); + A.CallTo(() => turnContext.Activity).Returns(activity); + + var service = CreateBotMessagesService(); + + // Act + var card = service.BuildHelpCard(turnContext); + + // Assert + Assert.NotNull(card); + Assert.Contains("How I can help you?", card.Body.OfType().FirstOrDefault()?.Items.OfType().FirstOrDefault()?.Text); + Assert.Contains("Connect", card.Body.OfType().SelectMany(ac => ac.Items).OfType().Where(x => x.IsVisible).SelectMany(cs => cs.Columns).SelectMany(c => c.Items.OfType()).SelectMany(a => a.Actions.OfType()).Select(a => a.Title)); + Assert.Equal(14, card.Body.OfType().SelectMany(ac => ac.Items).OfType().Where(x => x.IsVisible).SelectMany(cs => cs.Columns).SelectMany(c => c.Items.OfType()).SelectMany(a => a.Actions.OfType()).Count()); + } + + private AttachmentData BuildTestData(string href = "") + { + var testData = new AttachmentData + { + MockActivity = A.Fake(), + ImgContent = "\"Italian", + + HtmlContentWithoutTagA = + "" + + "" + + " HTML Tutorial " + + "" + + "
" + + "

This is a Heading

" + + "

This is a paragraph.

" + + "
" + + "" + + "", + + HtmlContentWithTagA0CustomLink = + "" + + "" + + " HTML Tutorial " + + "" + + "
" + + "" + + "", + + HtmlContentWithTagA1YahooLink = + "" + + "" + + " HTML Tutorial " + + "" + + "
" + + "

This is a Heading

" + + "

This is a paragraph.

" + + $"

\\n\\n\\n\\n\\n\\n\\n\\n\\This part is for determine\\r\\r\\r\\r\\r\\r\\r\\r\\r\\ parse behavior with tag a \\n \\r This is title for tag a

" + + "
" + + "" + + "", + + HtmlContentWithTagA2GoogleLink = + "" + + "" + + " HTML Tutorial " + + "" + + "
" + + "

This is a Heading

" + + "

This is a paragraph.

" + + $"

\\n\\n\\n\\n\\n\\n\\n\\n\\This part is for determine\\r\\r\\r\\r\\r\\r\\r\\r\\r\\ parse behavior with tag a \\n \\r This is title for tag a

" + + "
" + + "" + + "" + }; + return testData; + } + private IBotMessagesService CreateBotMessagesService() { return new BotMessagesService(_appSettings, Mapper, _fakeJiraService, _analyticsService); diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/JiraServerServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/JiraServerServiceTests.cs index fa59ac6..7c7030f 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/JiraServerServiceTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/JiraServerServiceTests.cs @@ -6,6 +6,7 @@ using FakeItEasy; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Models; using MicrosoftTeamsIntegration.Jira.Models.Jira; using MicrosoftTeamsIntegration.Jira.Models.Jira.Issue; @@ -25,6 +26,8 @@ public class JiraServerServiceTests { private readonly ISignalRService _signalRService = A.Fake(); private readonly IDatabaseService _databaseService = A.Fake(); + private readonly INotificationSubscriptionDatabaseService _notificationSubscriptionDatabaseService = A.Fake(); + private readonly IDistributedCacheService _distributedCacheService = A.Fake(); private readonly IJiraAuthService _jiraAuthService = A.Fake(); private readonly ILogger _logger = new NullLogger(); diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/MessagingExtensionServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/MessagingExtensionServiceTests.cs index 8cd4122..1188044 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/MessagingExtensionServiceTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/MessagingExtensionServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -15,7 +15,6 @@ using Microsoft.Bot.Schema.Teams; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Kiota.Abstractions; using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Dialogs; using MicrosoftTeamsIntegration.Jira.Models; @@ -48,12 +47,22 @@ public MessagingExtensionServiceTests() var distributedCacheService = A.Fake(); var telemetry = new TelemetryClient(TelemetryConfiguration.CreateDefault()); var analyticsService = A.Fake(); - - _target = new MessagingExtensionService(appSettings, logger, _jiraService, mapper, botMessagesService, distributedCacheService, telemetry, analyticsService); + var notificationSubscriptionService = A.Fake(); + + _target = new MessagingExtensionService( + appSettings, + logger, + _jiraService, + mapper, + botMessagesService, + distributedCacheService, + telemetry, + analyticsService, + notificationSubscriptionService); } [Fact] - public void HandleBotFetchTask_ReturnsCommandIsInvalid_WhenActivityValueNull() + public async Task HandleBotFetchTask_ReturnsCommandIsInvalid_WhenActivityValueNull() { var user = new IntegratedUser { @@ -68,7 +77,7 @@ public void HandleBotFetchTask_ReturnsCommandIsInvalid_WhenActivityValueNull() var testAdapter = new TestAdapter(Channels.Test); using var turnContext = new TurnContext(testAdapter, activity); - var result = _target.HandleBotFetchTask(turnContext, user); + var result = await _target.HandleBotFetchTask(turnContext, user); Assert.IsType(result); Assert.IsType(result.Task); @@ -82,7 +91,7 @@ public void HandleBotFetchTask_ReturnsCommandIsInvalid_WhenActivityValueNull() [InlineData(DialogMatchesAndCommands.EditIssueTaskModuleCommand)] [InlineData(DialogMatchesAndCommands.CreateNewIssueDialogCommand)] [InlineData("test")] - public void HandleBotFetchTask_ReturnsResponseEnvelope_WhenActivityValueNotNull(string commandName) + public async Task HandleBotFetchTask_ReturnsResponseEnvelope_WhenActivityValueNotNull(string commandName) { var user = new IntegratedUser { @@ -115,7 +124,7 @@ public void HandleBotFetchTask_ReturnsResponseEnvelope_WhenActivityValueNotNull( var testAdapter = new TestAdapter(Channels.Test); using var turnContext = new TurnContext(testAdapter, activity); - var result = _target.HandleBotFetchTask(turnContext, user); + var result = await _target.HandleBotFetchTask(turnContext, user); Assert.IsType(result); Assert.IsType(result.Task); @@ -929,6 +938,7 @@ public async Task HandleTaskSubmitActionAsync_FetchTaskNotNull(string commandNam [Theory] [InlineData("showMessageCard")] [InlineData("showIssueCard")] + [InlineData("showNotificationSettings")] public async Task HandleTaskSubmitActionAsync_SpecificTaskCommandName(string commandName) { var user = new IntegratedUser diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationProcessorServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationProcessorServiceTests.cs new file mode 100644 index 0000000..e1b777f --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationProcessorServiceTests.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdaptiveCards; +using AutoMapper; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Jira; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; +using MicrosoftTeamsIntegration.Jira.Services; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using Moq; +using Newtonsoft.Json; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests.Services; + +public class NotificationProcessorServiceTests +{ + private readonly Mock> _loggerMock; + private readonly Mock _analyticsServiceMock; + private readonly Mock _databaseServiceMock; + private readonly Mock _notificationsDatabaseServiceMock; + private readonly Mock _proactiveMessagesServiceMock; + private readonly Mock _mapperMock; + private readonly NotificationProcessorService _service; + private static readonly string[] PersonalSubscriptionEvents = new[] + { + "CommentIssueAssignee", + "ActivityIssueAssignee", + "CommentIssueCreator", + "ActivityIssueCreator", + "ActivityIssueCreator", + "MentionedOnIssue", + "CommentViewer" + }; + private static readonly string[] CHannelNotificationEvents = new[] { "CommentCreated", "IssueCreated", "IssueUpdated", "CommentUpdated" }; + + public NotificationProcessorServiceTests() + { + _loggerMock = new Mock>(); + _analyticsServiceMock = new Mock(); + _databaseServiceMock = new Mock(); + _notificationsDatabaseServiceMock = new Mock(); + _proactiveMessagesServiceMock = new Mock(); + _mapperMock = new Mock(); + + _service = new NotificationProcessorService( + _loggerMock.Object, + _analyticsServiceMock.Object, + _databaseServiceMock.Object, + _proactiveMessagesServiceMock.Object, + _mapperMock.Object, + _notificationsDatabaseServiceMock.Object); + } + + [Fact] + public async Task ProcessNotification_ShouldLogWarning_WhenJiraConnectionIsNull() + { + // Arrange + var notification = new NotificationMessage { JiraId = "test-jira-id" }; + _databaseServiceMock + .Setup(x => x.GetJiraServerAddonSettingsByJiraId(notification.JiraId)) + .ReturnsAsync((JiraAddonSettings)null); + + // Act + await _service.ProcessNotification(notification); + + // Assert + _loggerMock.Verify( + logger => logger.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ProcessNotification_ShouldProcessPersonalAndChannelNotifications_AndSendNotificationCard() + { + // Arrange + var notification = new NotificationMessage + { + JiraId = "test-jira-id", + EventType = "ISSUE_CREATED", + User = new NotificationUser + { + Name = "Test User", + Id = 1, + MicrosoftId = "microsoft-id-triggered", + AvatarUrl = new Uri("https://example.com/avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Issue = new NotificationIssue + { + Id = 10000, + Key = "ISSUE-123", + Summary = "Test issue summary", + Status = "Open", + Type = "Bug", + Assignee = new NotificationUser + { + Name = "Assignee User", + Id = 2, + MicrosoftId = "microsoft-id", + AvatarUrl = new Uri("https://example.com/assignee-avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Reporter = new NotificationUser + { + Name = "Reporter User", + Id = 3, + MicrosoftId = "reporter-microsoft-id", + AvatarUrl = new Uri("https://example.com/reporter-avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Priority = "High", + Self = new Uri("https://example.com/issue"), + ProjectId = 123 + }, + Changelog = new[] + { + new NotificationChangelog + { + Field = "status", + From = "Open", + To = "In Progress" + } + }, + Comment = new NotificationComment + { + Content = "This is a test comment.", + IsInternal = false + }, + Watchers = new[] + { + new NotificationUser + { + Name = "Watcher User", + Id = 4, + MicrosoftId = "watcher-microsoft-id", + AvatarUrl = new Uri("https://example.com/watcher-avatar.png"), + CanViewIssue = true, + CanViewComment = true + } + }, + Mentions = new[] + { + new NotificationUser + { + Name = "Mentioned User", + Id = 5, + MicrosoftId = "mention-microsoft-id", + AvatarUrl = new Uri("https://example.com/mention-avatar.png"), + CanViewIssue = true, + CanViewComment = true + } + } + }; + var personalSubscriptions = new List + { + new NotificationSubscription + { + IsActive = true, + SubscriptionType = SubscriptionType.Personal, + JiraId = "test-jira-id", + MicrosoftUserId = "microsoft-id", + EventTypes = PersonalSubscriptionEvents, + ConversationReference = "{\"activityId\":\"1234567890123\",\"user\":{\"id\":\"29:fakeUserId123\",\"name\":\"Fake User\",\"aadObjectId\":\"fake-aad-object-id-123\",\"role\":null},\"bot\":{\"id\":\"28:fakeBotId123\",\"name\":\"fakebot\",\"aadObjectId\":null,\"role\":null},\"conversation\":{\"isGroup\":null,\"conversationType\":\"personal\",\"id\":\"a:fakeConversationId123\",\"name\":null,\"aadObjectId\":null,\"role\":null,\"tenantId\":\"fake-tenant-id-123\"},\"channelId\":\"msteams\",\"locale\":\"en-US\",\"serviceUrl\":\"https://fake.service.url/\"}" + } + }; + var channelSubscriptions = new List + { + new NotificationSubscription + { + IsActive = true, + SubscriptionType = SubscriptionType.Channel, + JiraId = "test-jira-id", + ProjectId = "123", + EventTypes = CHannelNotificationEvents, + ConversationReference = "{\"activityId\":\"1234567890123\",\"user\":{\"id\":\"29:fakeUserId123\",\"name\":\"Fake User\",\"aadObjectId\":\"fake-aad-object-id-123\",\"role\":null},\"bot\":{\"id\":\"28:fakeBotId123\",\"name\":\"fakebot\",\"aadObjectId\":null,\"role\":null},\"conversation\":{\"isGroup\":null,\"conversationType\":\"personal\",\"id\":\"a:fakeConversationId123\",\"name\":null,\"aadObjectId\":null,\"role\":null,\"tenantId\":\"fake-tenant-id-123\"},\"channelId\":\"msteams\",\"locale\":\"en-US\",\"serviceUrl\":\"https://fake.service.url/\"}" + } + }; + + _databaseServiceMock + .Setup(x => x.GetJiraServerAddonSettingsByJiraId(notification.JiraId)) + .ReturnsAsync(new JiraAddonSettings() { JiraId = "test-jira-id" }); + _notificationsDatabaseServiceMock + .Setup(x => x.GetNotificationSubscriptionByJiraId(notification.JiraId)) + .ReturnsAsync(personalSubscriptions.Concat(channelSubscriptions)); + _proactiveMessagesServiceMock.Setup( + x => x.SendActivity( + It.IsAny(), + It.IsAny(), + CancellationToken.None)).Returns(Task.CompletedTask); + + // Act + await _service.ProcessNotification(notification); + + // Assert + _notificationsDatabaseServiceMock.Verify( + x => x.GetNotificationSubscriptionByJiraId(notification.JiraId), + Times.Exactly(2)); + _proactiveMessagesServiceMock.Verify( + x => x.SendActivity( + It.IsAny(), + It.IsAny(), + CancellationToken.None), + Times.Exactly(2)); + } + + [Fact] + public async Task ProcessPersonalNotifications_ShouldSkipNotification_WhenEventTypeNotAllowed() + { + // Arrange + var notification = new NotificationMessage + { + JiraId = "test-jira-id", + EventType = "SomeUnsupportedEventType" + }; + var subscriptions = new List + { + new NotificationSubscription { MicrosoftUserId = "user1" } + }; + _notificationsDatabaseServiceMock + .Setup(x => x.GetNotificationSubscriptionByJiraId(notification.JiraId)) + .ReturnsAsync(subscriptions); + + // Act + await _service.ProcessNotification(notification); + + // Assert + _proactiveMessagesServiceMock.Verify( + x => x.SendActivity( + It.IsAny(), + It.IsAny(), + CancellationToken.None), + Times.Never); + } + + [Fact] + public async Task ProcessChannelNotifications_ShouldSkip_WhenFiltersDoNotMatch() + { + // Arrange + var notification = new NotificationMessage + { + JiraId = "test-jira-id", + Issue = new NotificationIssue { ProjectId = 123, Type = "Bug", Status = "Open" } + }; + var subscriptions = new List + { + new NotificationSubscription + { + ProjectId = "456", + Filter = "type in ('Task')" + } + }; + + _notificationsDatabaseServiceMock + .Setup(x => x.GetNotificationSubscriptionByJiraId(notification.JiraId)) + .ReturnsAsync(subscriptions); + + // Act + await _service.ProcessNotification(notification); + + // Assert + _proactiveMessagesServiceMock.Verify( + x => x.SendActivity( + It.IsAny(), + It.IsAny(), + CancellationToken.None), + Times.Never); + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationQueueServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationQueueServiceTests.cs new file mode 100644 index 0000000..2822af7 --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationQueueServiceTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using FakeItEasy; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using MicrosoftTeamsIntegration.Jira.Services; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests.Services; + +public class NotificationQueueServiceTests +{ + private readonly FakeLogger _loggerFake; + private readonly QueueClient _queueClientFake; + private readonly NotificationQueueService _service; + + public NotificationQueueServiceTests() + { + _loggerFake = new FakeLogger(); + _queueClientFake = A.Fake(); + _service = new NotificationQueueService(_loggerFake, _queueClientFake); + } + + [Fact] + public async Task QueueNotificationMessage_ShouldSendMessage() + { + // Arrange + var message = "Test message"; + A.CallTo(() => _queueClientFake.SendMessageAsync(message)) + .Returns(A.Fake>()); + + // Act + await _service.QueueNotificationMessage(message); + + // Assert + A.CallTo(() => _queueClientFake.SendMessageAsync(message)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task QueueNotificationMessage_ShouldLogError_OnException() + { + // Arrange + var message = "Test message"; + A.CallTo(() => _queueClientFake.SendMessageAsync(message)) + .ThrowsAsync(new Exception("Test exception")); + + // Act + await _service.QueueNotificationMessage(message); + + // Assert + Assert.Equal(1, _loggerFake.Collector.Count); + Assert.Equal(LogLevel.Error, _loggerFake.LatestRecord.Level); + } + + [Fact] + public async Task DequeueNotificationsMessages_ShouldReturnMessages() + { + // Arrange + A.CallTo(() => _queueClientFake.ReceiveMessagesAsync(A._, A._, A._)) + .Returns(A.Fake>()); + + // Act + var result = await _service.DequeueNotificationsMessages(); + + // Assert + Assert.NotNull(result); + A.CallTo(() => _queueClientFake.ReceiveMessagesAsync(A._, A._, A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task DequeueNotificationsMessages_ShouldLogError_OnException() + { + // Arrange + A.CallTo(() => _queueClientFake.ReceiveMessagesAsync(A._, A._, A._)) + .ThrowsAsync(new Exception("Test exception")); + + // Act + var result = await _service.DequeueNotificationsMessages(); + + // Assert + Assert.Empty(result); + Assert.Equal(1, _loggerFake.Collector.Count); + Assert.Equal(LogLevel.Error, _loggerFake.LatestRecord.Level); + } + + [Fact] + public async Task DeleteNotificationMessage_ShouldDeleteMessage() + { + // Arrange + var messageId = "test-message-id"; + var popReceipt = "test-pop-receipt"; + A.CallTo(() => _queueClientFake.DeleteMessageAsync(messageId, popReceipt, A._)) + .Returns(A.Fake()); + + // Act + await _service.DeleteNotificationMessage(messageId, popReceipt); + + // Assert + A.CallTo(() => _queueClientFake.DeleteMessageAsync(messageId, popReceipt, A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task DeleteNotificationMessage_ShouldLogError_OnException() + { + // Arrange + var messageId = "test-message-id"; + var popReceipt = "test-pop-receipt"; + A.CallTo(() => _queueClientFake.DeleteMessageAsync(messageId, popReceipt, A._)) + .ThrowsAsync(new Exception("Test exception")); + + // Act + await _service.DeleteNotificationMessage(messageId, popReceipt); + + // Assert + Assert.Equal(1, _loggerFake.Collector.Count); + Assert.Equal(LogLevel.Error, _loggerFake.LatestRecord.Level); + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationSubscriptionServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationSubscriptionServiceTests.cs new file mode 100644 index 0000000..2844309 --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/NotificationSubscriptionServiceTests.cs @@ -0,0 +1,475 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Artifacts.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Exceptions; +using MicrosoftTeamsIntegration.Jira.Models; +using MicrosoftTeamsIntegration.Jira.Models.Jira; +using MicrosoftTeamsIntegration.Jira.Services; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Services.SignalR; +using MicrosoftTeamsIntegration.Jira.Services.SignalR.Interfaces; +using Newtonsoft.Json; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests.Services; + +public class NotificationSubscriptionServiceTests +{ + private readonly INotificationSubscriptionDatabaseService _notificationSubscriptionDatabaseService; + private readonly IDistributedCacheService _distributedCacheService; + private readonly ISignalRService _signalRService; + private readonly NotificationSubscriptionService _service; + private readonly IntegratedUser _integratedUser; + + public NotificationSubscriptionServiceTests() + { + ILogger logger = A.Fake>(); + _notificationSubscriptionDatabaseService = A.Fake(); + _distributedCacheService = A.Fake(); + _signalRService = A.Fake(); + _service = new NotificationSubscriptionService( + logger, + _notificationSubscriptionDatabaseService, + _distributedCacheService, + _signalRService); + _integratedUser = new IntegratedUser + { + MsTeamsUserId = "test-user-id", + JiraServerId = "test-jira-server-id", + AccessToken = "test-access-token" + }; + } + + [Theory] + [InlineData(SubscriptionType.Personal)] + [InlineData(SubscriptionType.Channel)] + public async Task CreateNotificationSubscription_ShouldAddNotification_WhenValidDataProvided(SubscriptionType subscriptionType) + { + var notification = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + MicrosoftUserId = "test-user-id", + JiraId = "test-jira-id", + SubscriptionType = subscriptionType + }; + var conversationReferenceId = "test-conversation-ref"; + var conversationReference = "test-conversation-reference"; + var response = new JiraResponse() + { + Response = new JiraAuthResponse() + { + IsSuccess = true + }, + ResponseCode = (int)HttpStatusCode.OK, + Message = "Test" + }; + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .Returns(conversationReference); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .Returns(new SignalRResponse() + { + Message = JsonConvert.SerializeObject(response), + Received = true + }); + + await _service.CreateNotificationSubscription(_integratedUser, notification, conversationReferenceId); + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .MustHaveHappenedTwiceExactly(); + A.CallTo(() => _notificationSubscriptionDatabaseService.AddNotificationSubscription(notification)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .MustHaveHappenedOnceExactly(); + Assert.Equal(conversationReference, notification.ConversationReference); + } + + [Theory] + [InlineData(SubscriptionType.Personal)] + [InlineData(SubscriptionType.Channel)] + public async Task CreateNotificationSubscription_ShouldNotTryToSetAddonSettings_WhenWeDoHavePersonalSubscriptionForJira(SubscriptionType subscriptionType) + { + var newNotificationSubscription = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + MicrosoftUserId = "test-user-id", + JiraId = "test-jira-id", + SubscriptionType = subscriptionType + }; + var conversationReferenceId = "test-conversation-ref"; + var conversationReference = "test-conversation-reference"; + var response = new JiraResponse() + { + Response = new JiraAuthResponse() + { + IsSuccess = true + }, + ResponseCode = (int)HttpStatusCode.OK, + Message = "Test" + }; + + A.CallTo(() => + _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByJiraId(newNotificationSubscription + .JiraId)).Returns([ + new NotificationSubscription + { + SubscriptionId = "test-subscription-id-2", + MicrosoftUserId = "test-user-id-2", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Personal + }, + new NotificationSubscription + { + SubscriptionId = "test-subscription-id-3", + MicrosoftUserId = "test-user-id-3", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Personal + }, + new NotificationSubscription + { + SubscriptionId = "test-subscription-id-4", + MicrosoftUserId = "test-user-id-4", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Channel + } + + ]); + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .Returns(conversationReference); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .Returns(new SignalRResponse() + { + Message = JsonConvert.SerializeObject(response), + Received = true + }); + + await _service.CreateNotificationSubscription(_integratedUser, newNotificationSubscription, conversationReferenceId); + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .MustHaveHappenedTwiceExactly(); + A.CallTo(() => _notificationSubscriptionDatabaseService.AddNotificationSubscription(newNotificationSubscription)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task CreateNotificationSubscription_ShouldNotAddNotification_WhenAddSettingsCanNotBeSet() + { + var notification = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + MicrosoftUserId = "test-user-id" + }; + var conversationReferenceId = "test-conversation-ref"; + var conversationReference = "test-conversation-reference"; + var response = new JiraResponse() + { + Response = new JiraAuthResponse() + { + IsSuccess = false + }, + ResponseCode = (int)HttpStatusCode.Forbidden, + Message = "Test" + }; + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .Returns(conversationReference); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .Returns(new SignalRResponse() + { + Message = JsonConvert.SerializeObject(response), + Received = true + }); + + await Assert.ThrowsAsync(() => _service.CreateNotificationSubscription(_integratedUser, notification, conversationReferenceId)); + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .MustNotHaveHappened(); + A.CallTo(() => _notificationSubscriptionDatabaseService.AddNotificationSubscription(notification)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task CreateNotificationSubscription_ShouldLogError_WhenExceptionOccurs() + { + var notification = new NotificationSubscription(); + var conversationReferenceId = "test-conversation-ref"; + var exception = new Exception("Test exception"); + var response = new JiraResponse() + { + Response = new JiraAuthResponse() + { + IsSuccess = true + }, + ResponseCode = (int)HttpStatusCode.OK, + Message = "Test" + }; + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .Throws(exception); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .Returns(new SignalRResponse() + { + Message = JsonConvert.SerializeObject(response), + Received = true + }); + + await _service.CreateNotificationSubscription(_integratedUser, notification, conversationReferenceId); + + A.CallTo(() => _notificationSubscriptionDatabaseService.AddNotificationSubscription(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetNotification_ShouldReturnFirstNotification_WhenNotificationsExist() + { + var microsoftUserId = _integratedUser.MsTeamsUserId; + var expectedNotification = new NotificationSubscription + { + MicrosoftUserId = microsoftUserId + }; + + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .Returns(new[] { expectedNotification }); + + var result = await _service.GetNotificationSubscription(_integratedUser); + + Assert.Equal(expectedNotification, result); + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetNotification_ShouldReturnNull_WhenExceptionOccurs() + { + var microsoftUserId = _integratedUser.MsTeamsUserId; + var exception = new Exception("Test exception"); + + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .Throws(exception); + + var result = await _service.GetNotificationSubscription(_integratedUser); + + Assert.Null(result); + } + + [Fact] + public async Task UpdateNotificationSubscription_ShouldNotUpdateConversationReference_WhenEmptyConversationReferenceId() + { + var notification = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + MicrosoftUserId = _integratedUser.MsTeamsUserId, + EventTypes = Array.Empty(), + IsActive = false + }; + + await _service.UpdateNotificationSubscription(_integratedUser, notification); + + A.CallTo(() => _distributedCacheService.Get(A._, CancellationToken.None)) + .MustNotHaveHappened(); + A.CallTo(() => _notificationSubscriptionDatabaseService.UpdateNotificationSubscription(notification.SubscriptionId, notification)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task UpdateNotificationSubscription_ShouldMuteSubscription_WhenThereAreNoEventTypesConfigured() + { + var notification = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + MicrosoftUserId = _integratedUser.MsTeamsUserId, + EventTypes = Array.Empty(), + IsActive = true + }; + + await _service.UpdateNotificationSubscription(_integratedUser, notification); + + A.CallTo(() => _distributedCacheService.Get(A._, CancellationToken.None)) + .MustNotHaveHappened(); + A.CallTo(() => _notificationSubscriptionDatabaseService.UpdateNotificationSubscription(notification.SubscriptionId, A.That.Matches(x => !x.IsActive))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task UpdateNotificationSubscription_ShouldLogError_WhenExceptionOccurs() + { + var notification = new NotificationSubscription(); + var conversationReferenceId = "test-conversation-ref"; + var exception = new Exception("Test exception"); + + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .Throws(exception); + + await _service.UpdateNotificationSubscription(_integratedUser, notification, conversationReferenceId); + + A.CallTo(() => _notificationSubscriptionDatabaseService.UpdateNotificationSubscription(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task DeleteNotificationSubscriptionByMicrosoftUserId_ShouldDeletePersonalNotification_WhenExists() + { + var microsoftUserId = _integratedUser.MsTeamsUserId; + var personalNotification = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + SubscriptionType = SubscriptionType.Personal, + MicrosoftUserId = microsoftUserId + }; + + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .Returns(new[] { personalNotification }); + + await _service.DeleteNotificationSubscriptionByMicrosoftUserId(_integratedUser); + + A.CallTo(() => _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionBySubscriptionId(personalNotification.SubscriptionId)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task DeleteNotificationSubscriptionByMicrosoftUserId_ShouldDeletePersonalNotification_WhenCannotUpdateAddonSettings() + { + var microsoftUserId = _integratedUser.MsTeamsUserId; + var personalNotification = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + SubscriptionType = SubscriptionType.Personal, + MicrosoftUserId = microsoftUserId + }; + var response = new JiraResponse() + { + Response = new JiraAuthResponse() + { + IsSuccess = false + }, + ResponseCode = (int)HttpStatusCode.Forbidden, + Message = "Test" + }; + + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .Returns(new[] { personalNotification }); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .Returns(new SignalRResponse() + { + Message = JsonConvert.SerializeObject(response), + Received = true + }); + + await _service.DeleteNotificationSubscriptionByMicrosoftUserId(_integratedUser); + + A.CallTo(() => _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionBySubscriptionId(personalNotification.SubscriptionId)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task DeleteNotificationSubscriptionByMicrosoftUserId_ShouldNotDelete_WhenNoPersonalNotificationExists() + { + var microsoftUserId = _integratedUser.MsTeamsUserId; + + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .Returns(Array.Empty()); + + await _service.DeleteNotificationSubscriptionByMicrosoftUserId(_integratedUser); + + A.CallTo(() => _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionBySubscriptionId(A._)) + .MustNotHaveHappened(); + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task DeleteNotificationSubscriptionByMicrosoftUserId_ShouldLogError_WhenExceptionOccurs() + { + var microsoftUserId = _integratedUser.MsTeamsUserId; + var exception = new Exception("Test exception"); + + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .Throws(exception); + + await _service.DeleteNotificationSubscriptionByMicrosoftUserId(_integratedUser); + + A.CallTo(() => _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionBySubscriptionId(A._)) + .MustNotHaveHappened(); + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(microsoftUserId)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task DeleteNotificationSubscriptionByMicrosoftUserId_ShouldNotTryToSetPersonalAddonSettings_WhenWeDoHavePersonalSubscriptionForJira() + { + var notificationSubscriptionToRemove = new NotificationSubscription + { + SubscriptionId = "test-subscription-id", + MicrosoftUserId = "test-user-id", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Personal + }; + var conversationReferenceId = "test-conversation-ref"; + var conversationReference = "test-conversation-reference"; + var response = new JiraResponse() + { + Response = new JiraAuthResponse() + { + IsSuccess = true + }, + ResponseCode = (int)HttpStatusCode.OK, + Message = "Test" + }; + + A.CallTo(() => + _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByJiraId(notificationSubscriptionToRemove + .JiraId)).Returns([ + new NotificationSubscription + { + SubscriptionId = "test-subscription-id-2", + MicrosoftUserId = "test-user-id-2", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Personal + }, + new NotificationSubscription + { + SubscriptionId = "test-subscription-id-3", + MicrosoftUserId = "test-user-id-3", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Personal + }, + new NotificationSubscription + { + SubscriptionId = "test-subscription-id-4", + MicrosoftUserId = "test-user-id-4", + JiraId = "test-jira-id", + SubscriptionType = SubscriptionType.Channel + } + + ]); + A.CallTo(() => _notificationSubscriptionDatabaseService.GetNotificationSubscriptionByMicrosoftUserId(A._)) + .Returns([notificationSubscriptionToRemove]); + A.CallTo(() => _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionByMicrosoftUserId(A._)) + .Returns(Task.CompletedTask); + A.CallTo(() => _distributedCacheService.Get(conversationReferenceId, CancellationToken.None)) + .Returns(conversationReference); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .Returns(new SignalRResponse() + { + Message = JsonConvert.SerializeObject(response), + Received = true + }); + + await _service.DeleteNotificationSubscriptionByMicrosoftUserId(_integratedUser); + + A.CallTo(() => _notificationSubscriptionDatabaseService.DeleteNotificationSubscriptionBySubscriptionId(notificationSubscriptionToRemove.SubscriptionId)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => _signalRService.SendRequestAndWaitForResponse(A._, A._, A._)) + .MustNotHaveHappened(); + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRBroadcastClientTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRBroadcastClientTests.cs new file mode 100644 index 0000000..dbf1ecc --- /dev/null +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRBroadcastClientTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MicrosoftTeamsIntegration.Jira.Services.Interfaces; +using MicrosoftTeamsIntegration.Jira.Services.SignalR; +using Moq; +using Xunit; + +namespace MicrosoftTeamsIntegration.Jira.Tests.Services.SignalR; + +public class SignalRBroadcastClientTests +{ + private readonly Mock> _loggerMock; + private readonly Mock _hubConnectionMock; + private readonly SignalRBroadcastClient _signalRBroadcastClient; + + public SignalRBroadcastClientTests() + { + _loggerMock = new Mock>(); + var configurationMock = new Mock(); + _hubConnectionMock = new Mock(); + + var appBaseUrlSection = new Mock(); + appBaseUrlSection.Setup(s => s.Value).Returns("http://localhost:3000"); + configurationMock.Setup(c => c.GetSection("AppBaseUrl")).Returns(appBaseUrlSection.Object); + var baseUrlSection = new Mock(); + baseUrlSection.Setup(s => s.Value).Returns("https://example.com"); + configurationMock.Setup(c => c.GetSection("BaseUrl")).Returns(baseUrlSection.Object); + + _signalRBroadcastClient = new SignalRBroadcastClient(_loggerMock.Object, configurationMock.Object, _hubConnectionMock.Object); + } + + [Fact] + public async Task StartAsync_ShouldStartHubConnection() + { + // Arrange + _hubConnectionMock.Setup(h => h.StartAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + await _signalRBroadcastClient.StartAsync(CancellationToken.None); + + // Assert + _hubConnectionMock.Verify(h => h.StartAsync(It.IsAny()), Times.Once); + _loggerMock.Verify( + logger => logger.Log( + LogLevel.Information, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + [Fact] + public async Task StartAsync_ShouldRetryOnFailure() + { + // Arrange + int retryCount = 0; + _hubConnectionMock.Setup(h => h.StartAsync(It.IsAny())) + .ThrowsAsync(new Exception("Connection failed")) + .Callback(() => retryCount++); + + // Act & Assert + await Assert.ThrowsAsync(() => _signalRBroadcastClient.StartAsync(CancellationToken.None)); + Assert.Equal(4, retryCount); + _loggerMock.Verify( + logger => logger.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + [Fact] + public async Task StopAsync_ShouldStopHubConnection() + { + // Arrange + _hubConnectionMock.Setup(h => h.StopAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + await _signalRBroadcastClient.StopAsync(CancellationToken.None); + + // Assert + _hubConnectionMock.Verify(h => h.StopAsync(It.IsAny()), Times.Once); + _loggerMock.Verify( + logger => logger.Log( + LogLevel.Information, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } +} diff --git a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRServiceTests.cs b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRServiceTests.cs index 9ac8cfb..28bfbe8 100644 --- a/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRServiceTests.cs +++ b/tests/MicrosoftTeamsIntegration.Jira.Tests/Services/SignalR/SignalRServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; @@ -6,11 +6,13 @@ using Microsoft.Extensions.Options; using MicrosoftTeamsIntegration.Jira.Exceptions; using MicrosoftTeamsIntegration.Jira.Models.Jira; +using MicrosoftTeamsIntegration.Jira.Models.Notifications; using MicrosoftTeamsIntegration.Jira.Services.Interfaces; using MicrosoftTeamsIntegration.Jira.Services.SignalR; using MicrosoftTeamsIntegration.Jira.Settings; using Moq; using Xunit; +using JsonConvert = Newtonsoft.Json.JsonConvert; namespace MicrosoftTeamsIntegration.Jira.Tests.Services.SignalR; @@ -19,12 +21,16 @@ public class SignalRServiceTests private readonly Mock> _hubMock; private readonly Mock _databaseServiceMock; private readonly Mock> _loggerMock; + private readonly Mock _notificationQueueServiceMock; + private readonly Mock _notificationProcessorServiceMock; private readonly SignalRService _signalRService; public SignalRServiceTests() { _hubMock = new Mock>(); _databaseServiceMock = new Mock(); + _notificationQueueServiceMock = new Mock(); + _notificationProcessorServiceMock = new Mock(); _loggerMock = new Mock>(); var appSettingsMock = new Mock>(); appSettingsMock.Setup(ap => ap.CurrentValue) @@ -40,7 +46,9 @@ public SignalRServiceTests() hubContextWrapper, _databaseServiceMock.Object, _loggerMock.Object, - appSettingsMock.Object); + appSettingsMock.Object, + _notificationQueueServiceMock.Object, + _notificationProcessorServiceMock.Object); } [Fact] @@ -64,15 +72,38 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task Callback_ShouldLogWarning_WhenIdentifierDoesNotExist() + public async Task Callback_ShouldBroadcastMessage_WhenIdentifierDoesNotExist() { // Arrange var identifier = Guid.NewGuid(); var response = "response-message"; + _hubMock.Setup(h => h.Clients.Group(It.IsAny()).SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + // Act await _signalRService.Callback(identifier, response); + // Assert + _hubMock.Verify( + h => h.Clients.Group(It.IsAny()) + .SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Broadcast_ShouldLogWarning_WhenIdentifierDoesNotExist() + { + // Arrange + var identifier = Guid.NewGuid(); + var response = "response-message"; + + _hubMock.Setup(h => + h.Clients.All.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _signalRService.Broadcast(identifier, response); + // Assert _loggerMock.Verify( logger => logger.Log( @@ -83,13 +114,127 @@ public async Task Callback_ShouldLogWarning_WhenIdentifierDoesNotExist() (Func)It.IsAny()), Times.Once); } + + [Fact] + public async Task Notification_ShouldAddMessageToQueue() + { + // Arrange + var identifier = Guid.NewGuid(); + var response = "notification-message"; + + _notificationQueueServiceMock.Setup(x => x.QueueNotificationMessage(It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _signalRService.Notification(identifier, response); + + // Assert + _notificationQueueServiceMock.Verify(x => x.QueueNotificationMessage(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Notification_ShouldProcessLargeMessageDirectly() + { + // Arrange + var identifier = Guid.NewGuid(); + var notification = new NotificationMessage + { + JiraId = "test-jira-id", + EventType = "ISSUE_CREATED", + User = new NotificationUser + { + Name = "Test User", + Id = 1, + MicrosoftId = "microsoft-id-triggered", + AvatarUrl = new Uri("https://example.com/avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Issue = new NotificationIssue + { + Id = 10000, + Key = "ISSUE-123", + Summary = new string('a', 65001), // Exceeding the limit + Status = "Open", + Type = "Bug", + Assignee = new NotificationUser + { + Name = "Assignee User", + Id = 2, + MicrosoftId = "microsoft-id", + AvatarUrl = new Uri("https://example.com/assignee-avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Reporter = new NotificationUser + { + Name = "Reporter User", + Id = 3, + MicrosoftId = "reporter-microsoft-id", + AvatarUrl = new Uri("https://example.com/reporter-avatar.png"), + CanViewIssue = true, + CanViewComment = true + }, + Priority = "High", + Self = new Uri("https://example.com/issue"), + ProjectId = 123 + }, + Changelog = new[] + { + new NotificationChangelog + { + Field = "status", + From = "Open", + To = "In Progress" + } + }, + Comment = new NotificationComment + { + Content = "This is a test comment.", + IsInternal = false + }, + Watchers = new[] + { + new NotificationUser + { + Name = "Watcher User", + Id = 4, + MicrosoftId = "watcher-microsoft-id", + AvatarUrl = new Uri("https://example.com/watcher-avatar.png"), + CanViewIssue = true, + CanViewComment = true + } + }, + Mentions = new[] + { + new NotificationUser + { + Name = "Mentioned User", + Id = 5, + MicrosoftId = "mention-microsoft-id", + AvatarUrl = new Uri("https://example.com/mention-avatar.png"), + CanViewIssue = true, + CanViewComment = true + } + } + }; + + _notificationProcessorServiceMock.Setup(x => x.ProcessNotification(It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _signalRService.Notification(identifier, JsonConvert.SerializeObject(notification)); + + // Assert + _notificationProcessorServiceMock.Verify(x => x.ProcessNotification(It.IsAny()), Times.Once); + } } public class TestHubContext : IHubContext where TGatewayHub : Hub { - private IHubClients _clients; - private IGroupManager _groups; + private readonly IHubClients _clients; + private readonly IGroupManager _groups; public TestHubContext(IHubClients clients, IGroupManager groups) {