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

Release dev to master #363

Merged
merged 40 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
47a2e71
fix: fix bug
swh00tw Nov 21, 2024
61c9576
[release-action] Fix bug: request status being overwritten (#362)
swh00tw Nov 23, 2024
87e1f40
feat: slack fields db migration
joannechen1223 Nov 24, 2024
0811c6a
feat: integrate slack oauth access
joannechen1223 Nov 25, 2024
dfb61cd
feat: update user db
joannechen1223 Nov 25, 2024
47b966d
chore: add back slack test api
joannechen1223 Nov 25, 2024
f75fa90
feat: send message with new access token
joannechen1223 Nov 25, 2024
2e37bf4
feat: encrypt access token
joannechen1223 Nov 25, 2024
e3dc078
chore: patch swagger
joannechen1223 Nov 25, 2024
8e6ab8d
fix: env var ci
joannechen1223 Nov 25, 2024
faf1faa
chore: typo
joannechen1223 Nov 25, 2024
4569559
chore: add zod pipeline validation
joannechen1223 Nov 25, 2024
906c4ec
refactor: move hitting slack api logic to slack service
joannechen1223 Nov 25, 2024
4bce5b8
test: add unit test for encrypt and decrypt function
joannechen1223 Nov 25, 2024
2252601
[recnet-api] Slack oauth API (#364)
joannechen1223 Nov 25, 2024
c26ad75
refactor: add new env vars, new nx target
swh00tw Nov 21, 2024
a73f68f
chore: finish route handlers for slack oauth
swh00tw Nov 21, 2024
87a9413
chore: fix import
swh00tw Nov 21, 2024
71ddc42
feat: finish route handler
swh00tw Nov 21, 2024
c6aa4e4
feat: add new api model and trpc procedures
swh00tw Nov 21, 2024
aead235
feat: add slack oauth flow result dialog
swh00tw Nov 21, 2024
2451712
feat: finish changes in subscription setting
swh00tw Nov 21, 2024
be75ad6
feat: forward error message from slack
swh00tw Nov 21, 2024
894daa0
chore: remove comment and log
swh00tw Nov 21, 2024
bc44d39
feat: refactor UI
swh00tw Nov 23, 2024
2a0806e
feat: refactor ui
swh00tw Nov 23, 2024
49dda3d
chore: fix conflict
swh00tw Nov 26, 2024
3fa73bd
feat: use recnet-api endpoints
swh00tw Nov 26, 2024
684b434
feat: add delete slack oauth api
swh00tw Nov 26, 2024
4fcf84d
chore: change slack install button color
swh00tw Nov 26, 2024
d28e288
fix: fix bug
swh00tw Nov 27, 2024
fa96085
[recnet-api] Fix slack message bug (#365)
swh00tw Dec 2, 2024
87a1e67
chore: modify wording for removing slack integration
swh00tw Dec 2, 2024
461a3ba
[recnet-web] RecNet Slack OAuth Flow (#361)
swh00tw Dec 2, 2024
3a20ac1
chore: debug
swh00tw Dec 2, 2024
0197f08
fix: pass redirect uri to slack oauth api
joannechen1223 Dec 2, 2024
604b857
fix: bring redirect uri in req
swh00tw Dec 2, 2024
7ee2539
fix: send redirect uri to slac Oauth access API (#369)
swh00tw Dec 2, 2024
c24099c
chore: version bump
swh00tw Dec 3, 2024
5e29bac
chore: update recnet-api version to v1.8.4
joannechen1223 Dec 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading