Skip to content

Commit

Permalink
[recnet-api] Slack oauth API (#364)
Browse files Browse the repository at this point in the history
## Description

This PR is the backend part of migrating slack app to the oath access
token, which will resolve the limitation of single workspace.

Doc:
https://www.notion.so/Slack-OAuth-flow-14551bd6607c80d08a13f56c04a81abd

What's included in this PR:

1. Add following API endpoints:

- `GET /users/subscriptions/slack/oauth`: ger user's slack workspace
name they installed
- `POST /users/subscriptions/slack/oauth`: Post code from FE to slack
API to exchange the access token, store information in DB
- `DELETE /users/subscriptions/slack/oauth`: delete the user's slack
information

2. Add back slack test API `POST /subscriptions/slack/test`
3. modify SlackTransporter logic to use the user's accessToken when
sending messages
4. Add new slack fields and delete slackEmail fields in user table


## Related Issue

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an
issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps
to reproduce -->
<!--- Please link to the issue here: -->

## Notes

<!-- Other thing to say -->
1. AccessToken is encrypted in the storage and is decrypted before it
needs to be used. Encryption key is stored as an environment variable.
2. Backward compatible. If the user has subscribed Slack channel without
a user access token, we will use the one in env var.

## Test

<!--- Please describe in detail how you tested your changes locally. -->

1. Run local server
2. Pasting this URL in your browser (fill in the scope and client_id)

`https://slack.com/oauth/v2/authorize?scope={scope}&client_id={client_id}`
<img width="1464" alt="Screenshot 2024-11-24 at 9 24 53 PM"
src="https://github.com/user-attachments/assets/c771efc8-4e8c-490c-a984-dc81d20a4ae3">

3. Choose a workspace and click allow button, pull the code in the
callback request from devtools
<img width="736" alt="Screenshot 2024-11-24 at 9 26 05 PM"
src="https://github.com/user-attachments/assets/b5a9e8fa-c22b-45f9-9d3b-3cf490bd3e53">
4. Use the code to hit `POST /users/subscriptions/slack/oauth`
5. Hit `POST /subscriptions/slack/test` to see if you got the slack
message
6. Hit `GET /users/subscriptions/slack/oauth` and `DELETE
/users/subscriptions/slack/oauth` to see if it works correctly

## Screenshots (if appropriate):

<!--- Add screenshots of your changes here -->
N/A

## TODO

- [x] Clear `console.log` or `console.error` for debug usage
- [ ] Update the documentation `recnet-docs` if needed
- [ ] Update environment variables in staging and production env
- [ ] Add unit tests
- [ ] Add migration announcement with specific deadline
  • Loading branch information
joannechen1223 authored Nov 25, 2024
2 parents 61c9576 + 4bce5b8 commit 2252601
Show file tree
Hide file tree
Showing 23 changed files with 430 additions and 17 deletions.
5 changes: 4 additions & 1 deletion apps/recnet-api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ export SMTP_USER="lil.recnet@gmail.com"
export SMTP_PASS="ask for password"

# SLACK
export SLACK_TOKEN="ask for token"
export SLACK_TOKEN="ask for token" # to be deprecated
export SLACK_CLIENT_ID="ask for client id"
export SLACK_CLIENT_SECRET="ask for client secret"
export SLACK_TOKEN_ENCRYPTION_KEY="ask for token encryption key"
3 changes: 3 additions & 0 deletions apps/recnet-api/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ RDS_USERNAME=test_user
RDS_PASSWORD=test_password
SMTP_USER=test_user
SMTP_PASS=test_password
SLACK_CLIENT_ID=test_client_id
SLACK_CLIENT_SECRET=test_client_secret
SLACK_TOKEN_ENCRYPTION_KEY=test_token_encryption_key
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "User" DROP COLUMN "slackAccessToken",
DROP COLUMN "slackUserId",
DROP COLUMN "slackWorkspaceName",
ADD COLUMN "slackEmail" VARCHAR(128);

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `slackEmail` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "slackEmail",
ADD COLUMN "slackAccessToken" VARCHAR(128),
ADD COLUMN "slackUserId" VARCHAR(64),
ADD COLUMN "slackWorkspaceName" VARCHAR(64);
4 changes: 3 additions & 1 deletion apps/recnet-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ model User {
lastLoginAt DateTime
role Role @default(USER) // Enum type
isActivated Boolean @default(true)
slackEmail String? @db.VarChar(128)
slackUserId String? @db.VarChar(64)
slackAccessToken String? @db.VarChar(128)
slackWorkspaceName String? @db.VarChar(64)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
Expand Down
5 changes: 4 additions & 1 deletion apps/recnet-api/src/config/common.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ export const NodemailerConfig = registerAs("nodemailer", () => ({
}));

export const SlackConfig = registerAs("slack", () => ({
token: parsedEnv.SLACK_TOKEN,
token: parsedEnv.SLACK_TOKEN, // to be deprecated
clientId: parsedEnv.SLACK_CLIENT_ID,
clientSecret: parsedEnv.SLACK_CLIENT_SECRET,
tokenEncryptionKey: parsedEnv.SLACK_TOKEN_ENCRYPTION_KEY,
}));
5 changes: 5 additions & 0 deletions apps/recnet-api/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const EnvSchema = z.object({
SMTP_PASS: z.string(),
// slack config
SLACK_TOKEN: z.string().optional(),
SLACK_CLIENT_ID: z.string(),
SLACK_CLIENT_SECRET: z.string(),
SLACK_TOKEN_ENCRYPTION_KEY: z
.string()
.transform((val) => Buffer.from(val, "base64")),
});

export const parseEnv = (env: Record<string, string | undefined>) => {
Expand Down
25 changes: 25 additions & 0 deletions apps/recnet-api/src/database/repository/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,31 @@ export default class UserRepository {
});
}

public async updateUserSlackInfo(
userId: string,
slackOauthInfo: {
slackUserId: string;
slackWorkspaceName: string;
slackAccessToken: string;
}
): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: slackOauthInfo,
});
}

public async deleteSlackInfo(userId: string): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: {
slackUserId: null,
slackWorkspaceName: null,
slackAccessToken: null,
},
});
}

private transformUserFilterByToPrismaWhere(
filter: UserFilterBy
): Prisma.UserWhereInput {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export const user = Prisma.validator<Prisma.UserDefaultArgs>()({
},
},
email: true,
slackEmail: true,
role: true,
isActivated: true,
following: {
Expand All @@ -50,6 +49,9 @@ export const user = Prisma.validator<Prisma.UserDefaultArgs>()({
},
recommendations: true,
subscriptions: true,
slackUserId: true,
slackWorkspaceName: true,
slackAccessToken: true,
},
});

Expand Down
88 changes: 85 additions & 3 deletions apps/recnet-api/src/modules/slack/slack.service.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
import { Inject, Injectable } from "@nestjs/common";
import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import axios from "axios";
import get from "lodash.get";

import { AppConfig } from "@recnet-api/config/common.config";
import { AppConfig, SlackConfig } from "@recnet-api/config/common.config";
import { User as DbUser } from "@recnet-api/database/repository/user.repository.type";
import { WeeklyDigestContent } from "@recnet-api/modules/subscription/subscription.type";
import { decrypt, encrypt } from "@recnet-api/utils";
import { RecnetError } from "@recnet-api/utils/error/recnet.error";
import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const";

import { SendSlackResult } from "./slack.type";
import { SendSlackResult, SlackOauthInfo } from "./slack.type";
import { weeklyDigestSlackTemplate } from "./templates/weekly-digest.template";
import { SlackTransporter } from "./transporters/slack.transporter";

const SLACK_OAUTH_ACCESS_API = "https://slack.com/api/oauth.v2.access";

@Injectable()
export class SlackService {
private logger: Logger = new Logger(SlackService.name);

constructor(
@Inject(AppConfig.KEY)
private readonly appConfig: ConfigType<typeof AppConfig>,
@Inject(SlackConfig.KEY)
private readonly slackConfig: ConfigType<typeof SlackConfig>,
private readonly transporter: SlackTransporter
) {}

public async installApp(
userId: string,
code: string
): Promise<SlackOauthInfo> {
const slackOauthInfo = await this.accessOauthInfo(userId, code);
await this.validateSlackOauthInfo(userId, slackOauthInfo);

// encrypt access token
slackOauthInfo.slackAccessToken = encrypt(
slackOauthInfo.slackAccessToken,
this.slackConfig.tokenEncryptionKey
);
return slackOauthInfo;
}

public async sendWeeklyDigest(
user: DbUser,
content: WeeklyDigestContent,
Expand All @@ -29,8 +55,12 @@ export class SlackService {
content,
this.appConfig.nodeEnv
);
const decryptedAccessToken = user.slackAccessToken
? decrypt(user.slackAccessToken, this.slackConfig.tokenEncryptionKey)
: "";
result = await this.transporter.sendDirectMessage(
user,
decryptedAccessToken,
weeklyDigest.messageBlocks,
weeklyDigest.notificationText
);
Expand All @@ -40,4 +70,56 @@ export class SlackService {

return result;
}

public async accessOauthInfo(
userId: string,
code: string
): Promise<SlackOauthInfo> {
const formData = new FormData();
formData.append("client_id", this.slackConfig.clientId);
formData.append("client_secret", this.slackConfig.clientSecret);
formData.append("code", code);

try {
const { data } = await axios.post(SLACK_OAUTH_ACCESS_API, formData);
if (!data.ok) {
throw new RecnetError(
ErrorCode.SLACK_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR,
`Failed to access oauth info: ${data.error}`
);
}
return {
slackAccessToken: get(data, "access_token", ""),
slackUserId: get(data, "authed_user.id", ""),
slackWorkspaceName: get(data, "team.name", ""),
};
} catch (error) {
this.logger.error(
`Failed to access oauth info, userId: ${userId}, error: ${error}`
);
throw error;
}
}

private async validateSlackOauthInfo(
userId: string,
slackOauthInfo: SlackOauthInfo
): Promise<void> {
let errorMsg = "";
if (slackOauthInfo.slackAccessToken === "") {
errorMsg = "Failed to get access token, userId: " + userId;
} else if (slackOauthInfo.slackUserId === "") {
errorMsg = "Failed to get user id, userId: " + userId;
} else if (slackOauthInfo.slackWorkspaceName === "") {
errorMsg = "Failed to get workspace name, userId: " + userId;
}
if (errorMsg !== "") {
throw new RecnetError(
ErrorCode.SLACK_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR,
`Failed to get workspace name`
);
}
}
}
6 changes: 6 additions & 0 deletions apps/recnet-api/src/modules/slack/slack.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export type SendSlackResult = {
};

export type SlackMessageBlocks = Readonly<SlackBlockDto>[];

export type SlackOauthInfo = {
slackAccessToken: string;
slackUserId: string;
slackWorkspaceName: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ export class SlackTransporter {
@Inject(AppConfig.KEY)
private readonly appConfig: ConfigType<typeof AppConfig>
) {
this.client = new WebClient(this.slackConfig.token);
this.client = new WebClient();
}

public async sendDirectMessage(
user: DbUser,
accessToken: string,
message: SlackMessageBlocks,
notificationText?: string
): Promise<SendSlackResult> {
Expand All @@ -45,13 +46,28 @@ export class SlackTransporter {
let retryCount = 0;
while (retryCount < SLACK_RETRY_LIMIT) {
try {
const slackId = await this.getUserSlackId(user);
await this.postDirectMessage(slackId, message, notificationText);
let userSlackId = user.slackUserId;

// Backward compatible
if (!userSlackId) {
userSlackId = await this.getUserSlackId(user);
}

if (!accessToken) {
accessToken = this.slackConfig.token || "";
}

await this.postDirectMessage(
userSlackId,
accessToken,
message,
notificationText
);
return { success: true };
} catch (error) {
retryCount++;
this.logger.error(
`[Attempt ${retryCount}] Failed to send email ${user.id}: ${error}`
`[Attempt ${retryCount}] Failed to send slack message to ${user.id}: ${error}`
);

// avoid rate limit
Expand All @@ -67,9 +83,13 @@ export class SlackTransporter {
);
}

// Backward compatible
private async getUserSlackId(user: DbUser): Promise<string> {
const email = user.slackEmail || user.email;
const userResp = await this.client.users.lookupByEmail({ email });
const email = user.email;
const userResp = await this.client.users.lookupByEmail({
email,
token: this.slackConfig.token,
});
const slackId = userResp?.user?.id;
if (!slackId) {
throw new RecnetError(
Expand All @@ -83,12 +103,14 @@ export class SlackTransporter {

private async postDirectMessage(
userSlackId: string,
accessToken: string,
message: SlackMessageBlocks,
notificationText?: string
): Promise<void> {
// Open a direct message conversation
const conversationResp = await this.client.conversations.open({
users: userSlackId,
token: accessToken,
});
const conversationId = conversationResp?.channel?.id;
if (!conversationId) {
Expand All @@ -104,6 +126,7 @@ export class SlackTransporter {
channel: conversationId,
text: notificationText,
blocks: message,
token: accessToken,
});
}
}
Loading

0 comments on commit 2252601

Please sign in to comment.