Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[recnet-api] Slack oauth API #364

Merged
merged 12 commits into from
Nov 25, 2024
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
Loading