Skip to content

Commit

Permalink
feat(awards): add slack notifications (#31)
Browse files Browse the repository at this point in the history
* add notification gateway

* addf slack notification gateway

* load slack notification gateway from config

* pass config from demo app

* only notify if there are new recipients

* prettier

* test the Awards.update func

* change config spec a bit

- icon_emoji and username fields seem to not actually work for the
incoming webhook bot
- i imagine we may have a `slack.bot` configuration, so isolating
`webhook` to its own object

* link to award in backstage

* fetch user entities when sending notifications

* remove "Woohoo!"

* add description block

* notify recipients on create award

* remove comment

* update test for new impl

* add test for create notifying new recipients

* fix tsc

* add tests for slack notifications gateway

* add docs

* add copyrights

* add AwardsNotifier abstraction to not bloat Awards class

* make webhook an env var in examples as it contains secrets

* make config required

* don't pass around identityRef where it's not yet needed

* add copyrights
  • Loading branch information
zhammer committed Feb 26, 2024
1 parent 7b40487 commit 7aec768
Show file tree
Hide file tree
Showing 14 changed files with 603 additions and 10 deletions.
3 changes: 3 additions & 0 deletions packages/backend/src/plugins/awards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ export default async function createPlugin(
logger: env.logger,
database: env.database,
identity: env.identity,
config: env.config,
discovery: env.discovery,
tokenManager: env.tokenManager,
});
}
17 changes: 17 additions & 0 deletions plugins/awards-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ function makeCreateEnv(config: Config) {
}
```

## Configuration

### Slack notifications

To enable Slack notifications, add the following to your `app-config.yaml` file:

```yaml
awards:
notifications:
slack:
webhook:
# https://api.slack.com/messaging/webhooks
url: ${MY_SLACK_WEBHOOK_URL_ENV_VAR}
```
Users who have the `slack.com/user_id` annotation set (see [slack-catalog-backend](/plugins/slack-catalog-backend/README.md)) will be tagged in notifications that pertain to them.

## Developing this plugin

The plugin can be executed in isolation during development by running
Expand Down
3 changes: 3 additions & 0 deletions plugins/awards-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@
"dependencies": {
"@backstage/backend-common": "^0.20.1",
"@backstage/backend-plugin-api": "^0.6.9",
"@backstage/catalog-client": "^1.6.0",
"@backstage/catalog-model": "^1.4.4",
"@backstage/config": "^1.1.1",
"@backstage/errors": "^1.2.3",
"@backstage/plugin-auth-node": "^0.4.3",
"@seatgeek/backstage-plugin-awards-common": "link:*",
"@slack/webhook": "^7.0.2",
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
Expand Down
112 changes: 112 additions & 0 deletions plugins/awards-backend/src/awards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright SeatGeek
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
*/
import { Award } from '@seatgeek/backstage-plugin-awards-common';
import * as winston from 'winston';
import { Awards } from './awards';
import { AwardsStore } from './database/awards';
import { AwardsNotifier } from './notifier';

const frank = 'user:default/frank-ocean';

function makeAward(): Award {
return {
uid: '123456',
name: 'Test Award',
description: 'This is a test award',
image: 'image_data',
owners: [frank],
recipients: ['user:default/peyton-manning', 'user:default/serena-williams'],
};
}

describe('Awards', () => {
let db: jest.Mocked<AwardsStore>;
let notifier: jest.Mocked<AwardsNotifier>;
let awards: Awards;

beforeEach(() => {
db = {
search: jest.fn(),
add: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
notifier = {
notifyNewRecipients: jest.fn(),
};
const logger = winston.createLogger({
transports: [new winston.transports.Console({ silent: true })],
});
awards = new Awards(db, notifier, logger);
});

afterEach(() => {
jest.resetAllMocks();
});

describe('create', () => {
it('should notify new recipients', async () => {
const award = makeAward();
db.add = jest.fn().mockResolvedValue(award);
const result = await awards.create({
name: award.name,
description: award.description,
image: award.image,
owners: award.owners,
recipients: award.recipients,
});

// wait for the afterCreate promises to complete
await new Promise(process.nextTick);

expect(result).toEqual(award);
expect(db.add).toHaveBeenCalledWith(
award.name,
award.description,
award.image,
award.owners,
award.recipients,
);
expect(notifier.notifyNewRecipients).toHaveBeenCalledWith(award, [
'user:default/peyton-manning',
'user:default/serena-williams',
]);
});
});

describe('update', () => {
it('should notify new recipients', async () => {
const award = makeAward();
db.search = jest.fn().mockResolvedValue([award]);
const updated = {
...award,
recipients: [
...award.recipients,
'user:default/megan-rapinoe',
'user:default/adrianne-lenker',
],
};
db.update = jest.fn().mockResolvedValue(updated);
const result = await awards.update(frank, award.uid, updated);

// wait for the afterUpdate promises to complete
await new Promise(process.nextTick);

expect(result).toEqual(updated);
expect(db.update).toHaveBeenCalledWith(
updated.uid,
updated.name,
updated.description,
updated.image,
updated.owners,
updated.recipients,
);
expect(notifier.notifyNewRecipients).toHaveBeenCalledWith(updated, [
'user:default/megan-rapinoe',
'user:default/adrianne-lenker',
]);
});
});
});
33 changes: 31 additions & 2 deletions plugins/awards-backend/src/awards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { NotFoundError } from '@backstage/errors';
import { Award, AwardInput } from '@seatgeek/backstage-plugin-awards-common';
import { Logger } from 'winston';
import { AwardsStore } from './database/awards';
import { AwardsNotifier } from './notifier';

export class Awards {
private readonly db: AwardsStore;
private readonly logger: Logger;
private readonly notifier: AwardsNotifier;

constructor(db: AwardsStore, logger: Logger) {
constructor(db: AwardsStore, notifier: AwardsNotifier, logger: Logger) {
this.db = db;
this.notifier = notifier;
this.logger = logger.child({ class: 'Awards' });
this.logger.debug('Constructed');
}
Expand All @@ -21,14 +24,36 @@ export class Awards {
return await this.getAwardByUid(uid);
}

private async afterCreate(award: Award): Promise<void> {
if (award.recipients.length > 0) {
await this.notifier.notifyNewRecipients(award, award.recipients);
}
}

async create(input: AwardInput): Promise<Award> {
return await this.db.add(
const award = await this.db.add(
input.name,
input.description,
input.image,
input.owners,
input.recipients,
);

this.afterCreate(award).catch(e => {
this.logger.error('Error running afterCreate action', e);
});

return award;
}

private async afterUpdate(curr: Award, previous: Award): Promise<void> {
const newRecipients = curr.recipients.filter(
recipient => !previous.recipients.includes(recipient),
);

if (newRecipients.length > 0) {
await this.notifier.notifyNewRecipients(curr, newRecipients);
}
}

async update(
Expand All @@ -51,6 +76,10 @@ export class Awards {
input.recipients,
);

this.afterUpdate(updated, award).catch(e => {
this.logger.error('Error running afterUpdate action', e);
});

return updated;
}

Expand Down
95 changes: 95 additions & 0 deletions plugins/awards-backend/src/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright SeatGeek
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
*/
import { UserEntity } from '@backstage/catalog-model';
import { Award } from '@seatgeek/backstage-plugin-awards-common';
import { IncomingWebhook } from '@slack/webhook';
import { SlackNotificationsGateway } from './notifications';

describe('SlackNotificationsGateway', () => {
// @ts-ignore
const slack: jest.Mocked<IncomingWebhook> = {
send: jest.fn(),
};

beforeEach(() => {
jest.resetAllMocks();
});

it('should send a message to slack', async () => {
const gateway = new SlackNotificationsGateway(
slack,
'http://localhost:3000',
);
const award: Award = {
uid: '123',
name: 'Coolest Test',
description: 'For great tests',
image: 'image',
owners: [],
recipients: [],
};
const newRecipients: UserEntity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'taylor-swift',
annotations: {
'slack.com/user_id': '123',
},
},
spec: {
profile: {
displayName: 'Taylor Swift',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'lebron-james',
annotations: {
'slack.com/user_id': '456',
},
},
spec: {
profile: {
displayName: 'Lebron James',
},
},
},
];

await gateway.notifyNewRecipientsAdded(award, newRecipients);

expect(slack.send).toHaveBeenCalledWith({
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: ':trophy: The following users have received the Coolest Test Award :trophy:',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: '<http://localhost:3000/catalog/default/User/taylor-swift|Taylor Swift> (<@123>), <http://localhost:3000/catalog/default/User/lebron-james|Lebron James> (<@456>)',
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: '> For great tests (<http://localhost:3000/awards/view/123|More info>)',
},
},
],
});
});
});
Loading

0 comments on commit 7aec768

Please sign in to comment.