Skip to content

Commit

Permalink
Release dev to master (#363)
Browse files Browse the repository at this point in the history
## RecNet auto-release action
This is an auto-generated PR by recnet-release-action 🤖
Please make sure to test your changes in staging before merging. 
## Related Issues
- #261
- #64
## Related PRs
- #369
- #361
- #365
- #364
- #362
## Staging links
recnet-web:
[https://vercel.live/link/recnet-git-dev-recnet-542617e7.vercel.app](https://vercel.live/link/recnet-git-dev-recnet-542617e7.vercel.app)
recnet-api:
[https://dev-api.recnet.io/api](https://dev-api.recnet.io/api)
  • Loading branch information
joannechen1223 authored Dec 3, 2024
2 parents 9965a1f + 5e29bac commit 8dca510
Show file tree
Hide file tree
Showing 43 changed files with 894 additions and 52 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Thumbs.db

# Next.js
.next
certificates

# env
.env
Expand Down
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="[email protected]"
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
2 changes: 1 addition & 1 deletion apps/recnet-api/package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"name": "recnet-api",
"version": "1.8.3"
"version": "1.8.4"
}
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
95 changes: 92 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,53 @@
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,
redirectUri: string,
code: string
): Promise<SlackOauthInfo> {
const slackOauthInfo = await this.accessOauthInfo(
userId,
redirectUri,
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 +60,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 +75,58 @@ export class SlackService {

return result;
}

public async accessOauthInfo(
userId: string,
redirectUri: 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("redirect_uri", redirectUri);
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 @@ -57,7 +57,7 @@ export const weeklyDigestSlackTemplate = (

const messageBlocks = BlockCollection(
Blocks.Header({
text: `${nodeEnv !== "production" && "[DEV] "}📬 Your Weekly Digest for ${formatDate(cutoff)}`,
text: `${nodeEnv !== "production" ? "[DEV] " : ""}📬 Your Weekly Digest for ${formatDate(cutoff)}`,
}),
Blocks.Section({
text: `You have ${Md.bold(`${recs.length}`)} recommendations this week!`,
Expand Down
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 8dca510

Please sign in to comment.