From 47a2e7136fd46608ca88d91fd2d79d258319975d Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 18:31:55 -0500 Subject: [PATCH 01/35] fix: fix bug --- .../src/lib/github.spec.ts | 82 ++++++++++++++++++- libs/recnet-release-action/src/lib/github.ts | 24 +++++- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/libs/recnet-release-action/src/lib/github.spec.ts b/libs/recnet-release-action/src/lib/github.spec.ts index 9219b389..45ff90be 100644 --- a/libs/recnet-release-action/src/lib/github.spec.ts +++ b/libs/recnet-release-action/src/lib/github.spec.ts @@ -348,11 +348,85 @@ describe("GitHubAPI", () => { }); describe("requestReviewers", () => { - it("should request reviewers for a PR", async () => { + it("should request only new reviewers for a PR", async () => { const prNumber = 1; - const reviewers = ["reviewer1", "reviewer2"]; + const newReviewers = ["reviewer1", "reviewer2", "reviewer3"]; + const existingReviewers = { + users: [{ login: "reviewer1" }], + }; - await github.requestReviewers(prNumber, reviewers); + // Mock the GET request for current reviewers + mockOctokit.request.mockResolvedValueOnce({ data: existingReviewers }); + + await github.requestReviewers(prNumber, newReviewers); + + // Verify GET request was called + expect(mockOctokit.request).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", + { + owner: env.inputs.owner, + repo: env.inputs.repo, + pull_number: prNumber, + } + ); + + // Verify POST request was called with only new reviewers + expect(mockOctokit.request).toHaveBeenCalledWith( + "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", + { + owner: env.inputs.owner, + repo: env.inputs.repo, + pull_number: prNumber, + reviewers: ["reviewer2", "reviewer3"], // only new reviewers + } + ); + }); + + it("should not make POST request if all reviewers are already requested", async () => { + const prNumber = 1; + const newReviewers = ["reviewer1", "reviewer2"]; + const existingReviewers = { + users: [{ login: "reviewer1" }, { login: "reviewer2" }], + }; + + // Mock the GET request for current reviewers + mockOctokit.request.mockResolvedValueOnce({ data: existingReviewers }); + + await github.requestReviewers(prNumber, newReviewers); + + // Verify only GET request was called + expect(mockOctokit.request).toHaveBeenCalledTimes(1); + expect(mockOctokit.request).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", + { + owner: env.inputs.owner, + repo: env.inputs.repo, + pull_number: prNumber, + } + ); + }); + + it("should request all reviewers if none are currently requested", async () => { + const prNumber = 1; + const newReviewers = ["reviewer1", "reviewer2"]; + const existingReviewers = { + users: [], + }; + + // Mock the GET request for current reviewers + mockOctokit.request.mockResolvedValueOnce({ data: existingReviewers }); + + await github.requestReviewers(prNumber, newReviewers); + + // Verify both requests were made + expect(mockOctokit.request).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", + { + owner: env.inputs.owner, + repo: env.inputs.repo, + pull_number: prNumber, + } + ); expect(mockOctokit.request).toHaveBeenCalledWith( "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", @@ -360,7 +434,7 @@ describe("GitHubAPI", () => { owner: env.inputs.owner, repo: env.inputs.repo, pull_number: prNumber, - reviewers, + reviewers: newReviewers, } ); }); diff --git a/libs/recnet-release-action/src/lib/github.ts b/libs/recnet-release-action/src/lib/github.ts index 35ffea85..53612c6a 100644 --- a/libs/recnet-release-action/src/lib/github.ts +++ b/libs/recnet-release-action/src/lib/github.ts @@ -190,13 +190,35 @@ export class GitHubAPI { } async requestReviewers(prNumber: number, reviewers: string[]) { + // Get current reviewers + const { data: currentReviewers } = await this.octokit.request( + "GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", + { + owner: this.owner, + repo: this.repo, + pull_number: prNumber, + } + ); + // Get usernames of users who are currently requested + const currentReviewerLogins = new Set( + currentReviewers.users.map((user) => user.login) + ); + // Filter out reviewers who have already reviewed or are already requested + const reviewersToAdd = reviewers.filter( + (reviewer) => !currentReviewerLogins.has(reviewer) + ); + + if (reviewersToAdd.length === 0) { + return; + } + await this.octokit.request( "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", { owner: this.owner, repo: this.repo, pull_number: prNumber, - reviewers, + reviewers: reviewersToAdd, } ); } From 87e1f40ecfa1d6d723827df6416b2d34948d660a Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 18:21:47 -0500 Subject: [PATCH 02/35] feat: slack fields db migration --- .../20241124232108_add_slack_oauth/down.sql | 6 ++++++ .../20241124232108_add_slack_oauth/migration.sql | 11 +++++++++++ apps/recnet-api/prisma/schema.prisma | 4 +++- 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/down.sql create mode 100644 apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/migration.sql diff --git a/apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/down.sql b/apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/down.sql new file mode 100644 index 00000000..8404753f --- /dev/null +++ b/apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/down.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "slackAccessToken", +DROP COLUMN "slackUserId", +DROP COLUMN "slackWorkspaceName", +ADD COLUMN "slackEmail" VARCHAR(128); + diff --git a/apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/migration.sql b/apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/migration.sql new file mode 100644 index 00000000..33edeb1d --- /dev/null +++ b/apps/recnet-api/prisma/migrations/20241124232108_add_slack_oauth/migration.sql @@ -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); diff --git a/apps/recnet-api/prisma/schema.prisma b/apps/recnet-api/prisma/schema.prisma index 0d115395..6f60fa7a 100644 --- a/apps/recnet-api/prisma/schema.prisma +++ b/apps/recnet-api/prisma/schema.prisma @@ -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 From 0811c6a9d79745e7436faa18e916e5a6edad4ca3 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 19:56:25 -0500 Subject: [PATCH 03/35] feat: integrate slack oauth access --- apps/recnet-api/src/config/common.config.ts | 3 +- apps/recnet-api/src/config/env.schema.ts | 2 + .../database/repository/user.repository.ts | 26 ++++++++++ .../repository/user.repository.type.ts | 1 - .../src/modules/slack/slack.service.ts | 37 ++++++++++++++- .../src/modules/slack/slack.type.ts | 6 +++ .../slack/transporters/slack.transporter.ts | 47 +++++++++++++++++-- .../src/modules/user/user.controller.ts | 43 +++++++++++++++++ .../src/modules/user/user.module.ts | 3 +- .../src/modules/user/user.response.ts | 5 ++ .../src/modules/user/user.service.ts | 30 +++++++++++- libs/recnet-api-model/src/lib/api/user.ts | 16 +++++++ 12 files changed, 209 insertions(+), 10 deletions(-) diff --git a/apps/recnet-api/src/config/common.config.ts b/apps/recnet-api/src/config/common.config.ts index ee87c5c4..c686787d 100644 --- a/apps/recnet-api/src/config/common.config.ts +++ b/apps/recnet-api/src/config/common.config.ts @@ -33,5 +33,6 @@ export const NodemailerConfig = registerAs("nodemailer", () => ({ })); export const SlackConfig = registerAs("slack", () => ({ - token: parsedEnv.SLACK_TOKEN, + clientId: parsedEnv.SLACK_CLIENT_ID, + clientSecret: parsedEnv.SLACK_CLIENT_SECRET, })); diff --git a/apps/recnet-api/src/config/env.schema.ts b/apps/recnet-api/src/config/env.schema.ts index 87b02dea..f5fe6207 100644 --- a/apps/recnet-api/src/config/env.schema.ts +++ b/apps/recnet-api/src/config/env.schema.ts @@ -25,6 +25,8 @@ 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(), }); export const parseEnv = (env: Record) => { diff --git a/apps/recnet-api/src/database/repository/user.repository.ts b/apps/recnet-api/src/database/repository/user.repository.ts index 60439d59..22400262 100644 --- a/apps/recnet-api/src/database/repository/user.repository.ts +++ b/apps/recnet-api/src/database/repository/user.repository.ts @@ -182,6 +182,32 @@ export default class UserRepository { }); } + public async findUserSlackInfo(userId: string): Promise<{ + slackUserId: string | null; + slackWorkspaceName: string | null; + slackAccessToken: string | null; + }> { + return this.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + slackUserId: true, + slackWorkspaceName: true, + slackAccessToken: true, + }, + }); + } + + public async deleteSlackInfo(userId: string): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { + slackUserId: null, + slackWorkspaceName: null, + slackAccessToken: null, + }, + }); + } + private transformUserFilterByToPrismaWhere( filter: UserFilterBy ): Prisma.UserWhereInput { diff --git a/apps/recnet-api/src/database/repository/user.repository.type.ts b/apps/recnet-api/src/database/repository/user.repository.type.ts index a5ef755c..dd23dbb9 100644 --- a/apps/recnet-api/src/database/repository/user.repository.type.ts +++ b/apps/recnet-api/src/database/repository/user.repository.type.ts @@ -40,7 +40,6 @@ export const user = Prisma.validator()({ }, }, email: true, - slackEmail: true, role: true, isActivated: true, following: { diff --git a/apps/recnet-api/src/modules/slack/slack.service.ts b/apps/recnet-api/src/modules/slack/slack.service.ts index 886359c8..137dd662 100644 --- a/apps/recnet-api/src/modules/slack/slack.service.ts +++ b/apps/recnet-api/src/modules/slack/slack.service.ts @@ -1,11 +1,13 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { HttpStatus, Inject, Injectable } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { AppConfig } 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 { 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"; @@ -17,6 +19,16 @@ export class SlackService { private readonly transporter: SlackTransporter ) {} + public async installApp( + userId: string, + code: string + ): Promise { + const slackOauthInfo = await this.transporter.accessOauthInfo(userId, code); + await this.validateSlackOauthInfo(userId, slackOauthInfo); + // Todo: encrypt access token + return slackOauthInfo; + } + public async sendWeeklyDigest( user: DbUser, content: WeeklyDigestContent, @@ -40,4 +52,25 @@ export class SlackService { return result; } + + private async validateSlackOauthInfo( + userId: string, + slackOauthInfo: SlackOauthInfo + ): Promise { + 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` + ); + } + } } diff --git a/apps/recnet-api/src/modules/slack/slack.type.ts b/apps/recnet-api/src/modules/slack/slack.type.ts index 9db7a02e..46d461c8 100644 --- a/apps/recnet-api/src/modules/slack/slack.type.ts +++ b/apps/recnet-api/src/modules/slack/slack.type.ts @@ -7,3 +7,9 @@ export type SendSlackResult = { }; export type SlackMessageBlocks = Readonly[]; + +export type SlackOauthInfo = { + slackAccessToken: string; + slackUserId: string; + slackWorkspaceName: string; +}; diff --git a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts index 647ccb3e..2f658267 100644 --- a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts +++ b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts @@ -1,6 +1,8 @@ import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { WebClient } from "@slack/web-api"; +import axios from "axios"; +import get from "lodash.get"; import { AppConfig, SlackConfig } from "@recnet-api/config/common.config"; import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; @@ -13,7 +15,13 @@ import { SLACK_RETRY_DURATION_MS, SLACK_RETRY_LIMIT, } from "../slack.const"; -import { SendSlackResult, SlackMessageBlocks } from "../slack.type"; +import { + SendSlackResult, + SlackMessageBlocks, + SlackOauthInfo, +} from "../slack.type"; + +const SLACK_OAUTH_ACCESS_API = "https://slack.com/api/oauth.v2.access"; @Injectable() export class SlackTransporter { @@ -26,7 +34,38 @@ export class SlackTransporter { @Inject(AppConfig.KEY) private readonly appConfig: ConfigType ) { - this.client = new WebClient(this.slackConfig.token); + this.client = new WebClient(); + } + + public async accessOauthInfo( + userId: string, + code: string + ): Promise { + 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; + } } public async sendDirectMessage( @@ -51,7 +90,7 @@ export class SlackTransporter { } 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 @@ -68,7 +107,7 @@ export class SlackTransporter { } private async getUserSlackId(user: DbUser): Promise { - const email = user.slackEmail || user.email; + const email = user.email; const userResp = await this.client.users.lookupByEmail({ email }); const slackId = userResp?.user?.id; if (!slackId) { diff --git a/apps/recnet-api/src/modules/user/user.controller.ts b/apps/recnet-api/src/modules/user/user.controller.ts index 3be22e52..33bf7e03 100644 --- a/apps/recnet-api/src/modules/user/user.controller.ts +++ b/apps/recnet-api/src/modules/user/user.controller.ts @@ -36,6 +36,7 @@ import { postUserFollowRequestSchema, postUserMeRequestSchema, postUsersSubscriptionsRequestSchema, + PostUsersSubscriptionsSlackOauthRequest, postUserValidateHandleRequestSchema, postUserValidateInviteCodeRequestSchema, } from "@recnet/recnet-api-model"; @@ -50,6 +51,7 @@ import { } from "./dto/validate.user.dto"; import { Subscription } from "./entities/user.subscription.entity"; import { + GetSlackOauthInfoResponse, GetSubscriptionsResponse, GetUserMeResponse, GetUsersResponse, @@ -258,4 +260,45 @@ export class UserController { const { type, channels } = dto; return this.userService.createOrUpdateSubscription(userId, type, channels); } + + @ApiOperation({ + summary: "Get Slack OAuth info", + description: "Get the current user's Slack OAuth info.", + }) + @Get("subscriptions/slack/oauth") + @ApiBearerAuth() + @Auth() + public async getSlackOauthInfo( + @User() authUser: AuthUser + ): Promise { + const { userId } = authUser; + return this.userService.getSlackOauthInfo(userId); + } + + @ApiOperation({ + summary: "Slack OAuth", + description: "Slack OAuth", + }) + @Post("subscriptions/slack/oauth") + @ApiBearerAuth() + @Auth() + public async slackOauth( + @User() authUser: AuthUser, + @Body() dto: PostUsersSubscriptionsSlackOauthRequest + ): Promise { + const { userId } = authUser; + return this.userService.installSlack(userId, dto.code); + } + + @ApiOperation({ + summary: "Delete Slack OAuth", + description: "Delete Slack OAuth", + }) + @Delete("subscriptions/slack/oauth") + @ApiBearerAuth() + @Auth() + public async deleteSlackOauth(@User() authUser: AuthUser): Promise { + const { userId } = authUser; + return this.userService.deleteSlack(userId); + } } diff --git a/apps/recnet-api/src/modules/user/user.module.ts b/apps/recnet-api/src/modules/user/user.module.ts index 0a7f237c..f64f2156 100644 --- a/apps/recnet-api/src/modules/user/user.module.ts +++ b/apps/recnet-api/src/modules/user/user.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { DbRepositoryModule } from "@recnet-api/database/repository/db.repository.module"; +import { SlackModule } from "@recnet-api/modules/slack/slack.module"; import { UserController } from "./user.controller"; import { UserService } from "./user.service"; @@ -8,6 +9,6 @@ import { UserService } from "./user.service"; @Module({ controllers: [UserController], providers: [UserService], - imports: [DbRepositoryModule], + imports: [DbRepositoryModule, SlackModule], }) export class UserModule {} diff --git a/apps/recnet-api/src/modules/user/user.response.ts b/apps/recnet-api/src/modules/user/user.response.ts index 627499d7..59da75fd 100644 --- a/apps/recnet-api/src/modules/user/user.response.ts +++ b/apps/recnet-api/src/modules/user/user.response.ts @@ -26,3 +26,8 @@ export class PostSubscriptionsResponse { @ApiProperty() subscription: Subscription; } + +export class GetSlackOauthInfoResponse { + @ApiProperty() + workspaceName: string | null; +} diff --git a/apps/recnet-api/src/modules/user/user.service.ts b/apps/recnet-api/src/modules/user/user.service.ts index 81f0a34e..7ef4207a 100644 --- a/apps/recnet-api/src/modules/user/user.service.ts +++ b/apps/recnet-api/src/modules/user/user.service.ts @@ -12,6 +12,7 @@ import { UpdateUserInput, } from "@recnet-api/database/repository/user.repository.type"; import { UserFilterBy } from "@recnet-api/database/repository/user.repository.type"; +import { SlackService } from "@recnet-api/modules/slack/slack.service"; import { getOffset } from "@recnet-api/utils"; import { RecnetError } from "@recnet-api/utils/error/recnet.error"; import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; @@ -24,6 +25,7 @@ import { User } from "./entities/user.entity"; import { UserPreview } from "./entities/user.preview.entity"; import { Subscription } from "./entities/user.subscription.entity"; import { + GetSlackOauthInfoResponse, GetSubscriptionsResponse, GetUsersResponse, PostSubscriptionsResponse, @@ -38,7 +40,9 @@ export class UserService { @Inject(InviteCodeRepository) private readonly inviteCodeRepository: InviteCodeRepository, @Inject(FollowingRecordRepository) - private readonly followingRecordRepository: FollowingRecordRepository + private readonly followingRecordRepository: FollowingRecordRepository, + @Inject(SlackService) + private readonly slackService: SlackService ) {} public async getUsers( @@ -206,6 +210,30 @@ export class UserService { }; } + public async getSlackOauthInfo( + userId: string + ): Promise { + const user = await this.userRepository.findUserSlackInfo(userId); + return { + workspaceName: user.slackWorkspaceName, + }; + } + + public async installSlack( + userId: string, + code: string + ): Promise { + // TODO: installSlackOauth + const oauthInfo = await this.slackService.installApp(userId, code); + return { + workspaceName: oauthInfo.slackWorkspaceName, + }; + } + + public async deleteSlack(userId: string): Promise { + await this.userRepository.deleteSlackInfo(userId); + } + private async transformUser(user: DbUser): Promise { const followingUserIds: string[] = user.following.map( (followingUser) => followingUser.followingId diff --git a/libs/recnet-api-model/src/lib/api/user.ts b/libs/recnet-api-model/src/lib/api/user.ts index 65059e86..e0cb3941 100644 --- a/libs/recnet-api-model/src/lib/api/user.ts +++ b/libs/recnet-api-model/src/lib/api/user.ts @@ -130,3 +130,19 @@ export const postUsersSubscriptionsResponseSchema = z.object({ export type PostUsersSubscriptionsResponse = z.infer< typeof postUsersSubscriptionsResponseSchema >; + +// POST /users/subscriptions/slack/oauth +export const postUsersSubscriptionsSlackOauthRequestSchema = z.object({ + code: z.string(), +}); +export type PostUsersSubscriptionsSlackOauthRequest = z.infer< + typeof postUsersSubscriptionsSlackOauthRequestSchema +>; + +// GET /users/subscriptions/slack/oauth +export const getUsersSubscriptionsSlackOauthResponseSchema = z.object({ + workspaceName: z.string().nullable(), +}); +export type GetUsersSubscriptionsSlackOauthResponse = z.infer< + typeof getUsersSubscriptionsSlackOauthResponseSchema +>; From dfb61cdef757903a151178707ef8e0741f12236d Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 20:09:54 -0500 Subject: [PATCH 04/35] feat: update user db --- .../src/database/repository/user.repository.ts | 14 ++++++++++++++ apps/recnet-api/src/modules/user/user.service.ts | 9 ++++++++- .../src/utils/error/recnet.error.const.ts | 3 +++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/recnet-api/src/database/repository/user.repository.ts b/apps/recnet-api/src/database/repository/user.repository.ts index 22400262..910d2772 100644 --- a/apps/recnet-api/src/database/repository/user.repository.ts +++ b/apps/recnet-api/src/database/repository/user.repository.ts @@ -197,6 +197,20 @@ export default class UserRepository { }); } + public async updateUserSlackInfo( + userId: string, + slackOauthInfo: { + slackUserId: string; + slackWorkspaceName: string; + slackAccessToken: string; + } + ): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: slackOauthInfo, + }); + } + public async deleteSlackInfo(userId: string): Promise { await this.prisma.user.update({ where: { id: userId }, diff --git a/apps/recnet-api/src/modules/user/user.service.ts b/apps/recnet-api/src/modules/user/user.service.ts index 7ef4207a..f0e83896 100644 --- a/apps/recnet-api/src/modules/user/user.service.ts +++ b/apps/recnet-api/src/modules/user/user.service.ts @@ -223,8 +223,15 @@ export class UserService { userId: string, code: string ): Promise { - // TODO: installSlackOauth + const user = await this.userRepository.findUserSlackInfo(userId); + if (user.slackUserId) { + throw new RecnetError( + ErrorCode.SLACK_ALREADY_INSTALLED, + HttpStatus.BAD_REQUEST + ); + } const oauthInfo = await this.slackService.installApp(userId, code); + await this.userRepository.updateUserSlackInfo(userId, oauthInfo); return { workspaceName: oauthInfo.slackWorkspaceName, }; diff --git a/apps/recnet-api/src/utils/error/recnet.error.const.ts b/apps/recnet-api/src/utils/error/recnet.error.const.ts index 0cec6b90..f38c2949 100644 --- a/apps/recnet-api/src/utils/error/recnet.error.const.ts +++ b/apps/recnet-api/src/utils/error/recnet.error.const.ts @@ -11,6 +11,7 @@ export const ErrorCode = { DIGITAL_LIBRARY_RANK_CONFLICT: 1009, INVALID_REACTION_TYPE: 1010, INVALID_SUBSCRIPTION: 1011, + SLACK_ALREADY_INSTALLED: 1012, // DB error codes DB_UNKNOWN_ERROR: 2000, @@ -39,6 +40,7 @@ export const errorMessages = { "Digital library rank must be unique", [ErrorCode.INVALID_REACTION_TYPE]: "Invalid reaction type", [ErrorCode.INVALID_SUBSCRIPTION]: "Invalid subscription", + [ErrorCode.SLACK_ALREADY_INSTALLED]: "Slack already installed", [ErrorCode.DB_UNKNOWN_ERROR]: "Database error", [ErrorCode.DB_USER_NOT_FOUND]: "User not found", [ErrorCode.DB_UNIQUE_CONSTRAINT]: "Unique constraint violation", @@ -46,4 +48,5 @@ export const errorMessages = { [ErrorCode.EMAIL_SEND_ERROR]: "Email send error", [ErrorCode.FETCH_DIGITAL_LIBRARY_ERROR]: "Fetch digital library error", [ErrorCode.SLACK_ERROR]: "Slack error", + [ErrorCode.SLACK_ALREADY_INSTALLED]: "Slack already installed", }; From 47b966d38e754806215022f074e8aa2244c05b10 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 20:25:18 -0500 Subject: [PATCH 05/35] chore: add back slack test api --- apps/recnet-api/src/config/common.config.ts | 1 + .../slack/transporters/slack.transporter.ts | 7 +- .../subscription/subscription.controller.ts | 90 +++++++++++++++++++ .../subscription/subscription.module.ts | 5 +- 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 apps/recnet-api/src/modules/subscription/subscription.controller.ts diff --git a/apps/recnet-api/src/config/common.config.ts b/apps/recnet-api/src/config/common.config.ts index c686787d..ebd0fe93 100644 --- a/apps/recnet-api/src/config/common.config.ts +++ b/apps/recnet-api/src/config/common.config.ts @@ -33,6 +33,7 @@ export const NodemailerConfig = registerAs("nodemailer", () => ({ })); export const SlackConfig = registerAs("slack", () => ({ + token: parsedEnv.SLACK_TOKEN, // to be deprecated clientId: parsedEnv.SLACK_CLIENT_ID, clientSecret: parsedEnv.SLACK_CLIENT_SECRET, })); diff --git a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts index 2f658267..52aa681d 100644 --- a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts +++ b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts @@ -108,7 +108,10 @@ export class SlackTransporter { private async getUserSlackId(user: DbUser): Promise { const email = user.email; - const userResp = await this.client.users.lookupByEmail({ email }); + const userResp = await this.client.users.lookupByEmail({ + email, + token: this.slackConfig.token, + }); const slackId = userResp?.user?.id; if (!slackId) { throw new RecnetError( @@ -128,6 +131,7 @@ export class SlackTransporter { // Open a direct message conversation const conversationResp = await this.client.conversations.open({ users: userSlackId, + token: this.slackConfig.token, }); const conversationId = conversationResp?.channel?.id; if (!conversationId) { @@ -143,6 +147,7 @@ export class SlackTransporter { channel: conversationId, text: notificationText, blocks: message, + token: this.slackConfig.token, }); } } diff --git a/apps/recnet-api/src/modules/subscription/subscription.controller.ts b/apps/recnet-api/src/modules/subscription/subscription.controller.ts new file mode 100644 index 00000000..5e4dc68c --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/subscription.controller.ts @@ -0,0 +1,90 @@ +import { generateMock } from "@anatine/zod-mock"; +import { Body, Controller, HttpStatus, Inject, Post } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { + ApiBody, + ApiCreatedResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; + +import { AppConfig } from "@recnet-api/config/common.config"; +import UserRepository from "@recnet-api/database/repository/user.repository"; +import { RecnetError } from "@recnet-api/utils/error/recnet.error"; +import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; + +import { getLatestCutOff } from "@recnet/recnet-date-fns"; + +import { announcementSchema, recSchema } from "@recnet/recnet-api-model"; + +import { WeeklyDigestContent } from "./subscription.type"; + +import { SlackService } from "../slack/slack.service"; + +@ApiTags("subscriptions") +@Controller("subscriptions") +export class SubscriptionController { + constructor( + @Inject(AppConfig.KEY) + private readonly appConfig: ConfigType, + private readonly slackService: SlackService, + private readonly userRepository: UserRepository + ) {} + + /* Development only */ + @ApiOperation({ + summary: "Send weekly digest slack to the designated user.", + description: "This endpoint is for development only.", + }) + @ApiCreatedResponse() + @ApiBody({ + schema: { + properties: { + userId: { type: "string" }, + }, + required: ["userId"], + }, + }) + @Post("slack/test") + public async testSendingWeeklyDigest( + @Body("userId") userId: string + ): Promise { + if (this.appConfig.nodeEnv === "production") { + throw new RecnetError( + ErrorCode.INTERNAL_SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR, + "This endpoint is only for development" + ); + } + + function getMockWeeklyDigestData(): WeeklyDigestContent { + const getMockRec = (title = 1) => + generateMock(recSchema, { + stringMap: { + photoUrl: () => "https://avatar.iran.liara.run/public", + title: () => `Paper Title ${title}`, + }, + }); + const announcement = generateMock(announcementSchema, { + stringMap: { + content: () => "This is a test announcement!", + }, + }); + return { + recs: [getMockRec(), getMockRec(2), getMockRec(3), getMockRec()], + numUnusedInviteCodes: 3, + latestAnnouncement: { + ...announcement, + startAt: new Date(announcement.startAt), + endAt: new Date(announcement.endAt), + }, + }; + } + + const cutoff = getLatestCutOff(); + const user = await this.userRepository.findUserById(userId); + const content = getMockWeeklyDigestData(); + + this.slackService.sendWeeklyDigest(user, content, cutoff); + } +} diff --git a/apps/recnet-api/src/modules/subscription/subscription.module.ts b/apps/recnet-api/src/modules/subscription/subscription.module.ts index b94a1323..1d661676 100644 --- a/apps/recnet-api/src/modules/subscription/subscription.module.ts +++ b/apps/recnet-api/src/modules/subscription/subscription.module.ts @@ -2,12 +2,13 @@ import { Module } from "@nestjs/common"; import { DbRepositoryModule } from "@recnet-api/database/repository/db.repository.module"; import { EmailModule } from "@recnet-api/modules/email/email.module"; +import { SlackModule } from "@recnet-api/modules/slack/slack.module"; +import { SubscriptionController } from "./subscription.controller"; import { WeeklyDigestWorker } from "./weekly-digest.worker"; -import { SlackModule } from "../slack/slack.module"; - @Module({ + controllers: [SubscriptionController], providers: [WeeklyDigestWorker], imports: [DbRepositoryModule, EmailModule, SlackModule], }) From f75fa90e49eb70fb00ea53a023df510a2be86d54 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 20:40:26 -0500 Subject: [PATCH 06/35] feat: send message with new access token --- .../database/repository/user.repository.ts | 15 ----------- .../repository/user.repository.type.ts | 3 +++ .../src/modules/slack/slack.service.ts | 1 + .../slack/transporters/slack.transporter.ts | 26 ++++++++++++++++--- .../src/modules/user/user.service.ts | 4 +-- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/apps/recnet-api/src/database/repository/user.repository.ts b/apps/recnet-api/src/database/repository/user.repository.ts index 910d2772..95bdaa44 100644 --- a/apps/recnet-api/src/database/repository/user.repository.ts +++ b/apps/recnet-api/src/database/repository/user.repository.ts @@ -182,21 +182,6 @@ export default class UserRepository { }); } - public async findUserSlackInfo(userId: string): Promise<{ - slackUserId: string | null; - slackWorkspaceName: string | null; - slackAccessToken: string | null; - }> { - return this.prisma.user.findUniqueOrThrow({ - where: { id: userId }, - select: { - slackUserId: true, - slackWorkspaceName: true, - slackAccessToken: true, - }, - }); - } - public async updateUserSlackInfo( userId: string, slackOauthInfo: { diff --git a/apps/recnet-api/src/database/repository/user.repository.type.ts b/apps/recnet-api/src/database/repository/user.repository.type.ts index dd23dbb9..66313cd6 100644 --- a/apps/recnet-api/src/database/repository/user.repository.type.ts +++ b/apps/recnet-api/src/database/repository/user.repository.type.ts @@ -49,6 +49,9 @@ export const user = Prisma.validator()({ }, recommendations: true, subscriptions: true, + slackUserId: true, + slackWorkspaceName: true, + slackAccessToken: true, }, }); diff --git a/apps/recnet-api/src/modules/slack/slack.service.ts b/apps/recnet-api/src/modules/slack/slack.service.ts index 137dd662..0c65c825 100644 --- a/apps/recnet-api/src/modules/slack/slack.service.ts +++ b/apps/recnet-api/src/modules/slack/slack.service.ts @@ -43,6 +43,7 @@ export class SlackService { ); result = await this.transporter.sendDirectMessage( user, + user.slackAccessToken || "", // TODO: pass decrypted access token weeklyDigest.messageBlocks, weeklyDigest.notificationText ); diff --git a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts index 52aa681d..f3e26b2c 100644 --- a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts +++ b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts @@ -70,6 +70,7 @@ export class SlackTransporter { public async sendDirectMessage( user: DbUser, + accessToken: string, message: SlackMessageBlocks, notificationText?: string ): Promise { @@ -84,8 +85,23 @@ 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++; @@ -106,6 +122,7 @@ export class SlackTransporter { ); } + // Backward compatible private async getUserSlackId(user: DbUser): Promise { const email = user.email; const userResp = await this.client.users.lookupByEmail({ @@ -125,13 +142,14 @@ export class SlackTransporter { private async postDirectMessage( userSlackId: string, + accessToken: string, message: SlackMessageBlocks, notificationText?: string ): Promise { // Open a direct message conversation const conversationResp = await this.client.conversations.open({ users: userSlackId, - token: this.slackConfig.token, + token: accessToken, }); const conversationId = conversationResp?.channel?.id; if (!conversationId) { @@ -147,7 +165,7 @@ export class SlackTransporter { channel: conversationId, text: notificationText, blocks: message, - token: this.slackConfig.token, + token: accessToken, }); } } diff --git a/apps/recnet-api/src/modules/user/user.service.ts b/apps/recnet-api/src/modules/user/user.service.ts index f0e83896..75891d81 100644 --- a/apps/recnet-api/src/modules/user/user.service.ts +++ b/apps/recnet-api/src/modules/user/user.service.ts @@ -213,7 +213,7 @@ export class UserService { public async getSlackOauthInfo( userId: string ): Promise { - const user = await this.userRepository.findUserSlackInfo(userId); + const user = await this.userRepository.findUserById(userId); return { workspaceName: user.slackWorkspaceName, }; @@ -223,7 +223,7 @@ export class UserService { userId: string, code: string ): Promise { - const user = await this.userRepository.findUserSlackInfo(userId); + const user = await this.userRepository.findUserById(userId); if (user.slackUserId) { throw new RecnetError( ErrorCode.SLACK_ALREADY_INSTALLED, From 2e37bf4619f5ade5fb4a456fdc231cdb38ab10fb Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 21:01:53 -0500 Subject: [PATCH 07/35] feat: encrypt access token --- apps/recnet-api/src/config/common.config.ts | 3 ++- apps/recnet-api/src/config/env.schema.ts | 3 +++ .../src/modules/slack/slack.service.ts | 17 +++++++++++--- apps/recnet-api/src/utils/index.ts | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/recnet-api/src/config/common.config.ts b/apps/recnet-api/src/config/common.config.ts index ebd0fe93..f4df0a39 100644 --- a/apps/recnet-api/src/config/common.config.ts +++ b/apps/recnet-api/src/config/common.config.ts @@ -33,7 +33,8 @@ export const NodemailerConfig = registerAs("nodemailer", () => ({ })); export const SlackConfig = registerAs("slack", () => ({ - token: parsedEnv.SLACK_TOKEN, // to be deprecated + token: parsedEnv.SLACK_TOKEN, // to be de clientId: parsedEnv.SLACK_CLIENT_ID, clientSecret: parsedEnv.SLACK_CLIENT_SECRET, + tokenEncryptionKey: parsedEnv.SLACK_TOKEN_ENCRYPTION_KEY, })); diff --git a/apps/recnet-api/src/config/env.schema.ts b/apps/recnet-api/src/config/env.schema.ts index f5fe6207..01db647d 100644 --- a/apps/recnet-api/src/config/env.schema.ts +++ b/apps/recnet-api/src/config/env.schema.ts @@ -27,6 +27,9 @@ export const EnvSchema = z.object({ 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) => { diff --git a/apps/recnet-api/src/modules/slack/slack.service.ts b/apps/recnet-api/src/modules/slack/slack.service.ts index 0c65c825..646c0183 100644 --- a/apps/recnet-api/src/modules/slack/slack.service.ts +++ b/apps/recnet-api/src/modules/slack/slack.service.ts @@ -1,9 +1,10 @@ import { HttpStatus, Inject, Injectable } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; -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"; @@ -16,6 +17,8 @@ export class SlackService { constructor( @Inject(AppConfig.KEY) private readonly appConfig: ConfigType, + @Inject(SlackConfig.KEY) + private readonly slackConfig: ConfigType, private readonly transporter: SlackTransporter ) {} @@ -25,7 +28,12 @@ export class SlackService { ): Promise { const slackOauthInfo = await this.transporter.accessOauthInfo(userId, code); await this.validateSlackOauthInfo(userId, slackOauthInfo); - // Todo: encrypt access token + + // encrypt access token + slackOauthInfo.slackAccessToken = encrypt( + slackOauthInfo.slackAccessToken, + this.slackConfig.tokenEncryptionKey + ); return slackOauthInfo; } @@ -41,9 +49,12 @@ export class SlackService { content, this.appConfig.nodeEnv ); + const decryptedAccessToken = user.slackAccessToken + ? decrypt(user.slackAccessToken, this.slackConfig.tokenEncryptionKey) + : ""; result = await this.transporter.sendDirectMessage( user, - user.slackAccessToken || "", // TODO: pass decrypted access token + decryptedAccessToken, weeklyDigest.messageBlocks, weeklyDigest.notificationText ); diff --git a/apps/recnet-api/src/utils/index.ts b/apps/recnet-api/src/utils/index.ts index 1faf65db..e68f8b92 100644 --- a/apps/recnet-api/src/utils/index.ts +++ b/apps/recnet-api/src/utils/index.ts @@ -1,5 +1,27 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; + export const getOffset = (page: number, pageSize: number): number => (page - 1) * pageSize; export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export const encrypt = (data: string, key: Buffer): string => { + const iv = randomBytes(16); + const cipher = createCipheriv("aes-256-cbc", key, iv); + let encrypted = cipher.update(data, "utf8", "base64"); + encrypted += cipher.final("base64"); + return `${iv.toString("base64")}:${encrypted}`; +}; + +export const decrypt = (data: string, key: Buffer): string => { + const [iv, encrypted] = data.split(":"); + const decipher = createDecipheriv( + "aes-256-cbc", + key, + Buffer.from(iv, "base64") + ); + let decrypted = decipher.update(encrypted, "base64", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +}; From e3dc0784c89d373b1588c8623939a5639c97dcdd Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sun, 24 Nov 2024 21:08:25 -0500 Subject: [PATCH 08/35] chore: patch swagger --- .../modules/subscription/subscription.controller.ts | 2 +- .../src/modules/user/dto/slack-oauth.user.dto.ts | 10 ++++++++++ apps/recnet-api/src/modules/user/user.controller.ts | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 apps/recnet-api/src/modules/user/dto/slack-oauth.user.dto.ts diff --git a/apps/recnet-api/src/modules/subscription/subscription.controller.ts b/apps/recnet-api/src/modules/subscription/subscription.controller.ts index 5e4dc68c..abd32479 100644 --- a/apps/recnet-api/src/modules/subscription/subscription.controller.ts +++ b/apps/recnet-api/src/modules/subscription/subscription.controller.ts @@ -33,7 +33,7 @@ export class SubscriptionController { /* Development only */ @ApiOperation({ - summary: "Send weekly digest slack to the designated user.", + summary: "[Dev only] Send weekly digest slack to the designated user.", description: "This endpoint is for development only.", }) @ApiCreatedResponse() diff --git a/apps/recnet-api/src/modules/user/dto/slack-oauth.user.dto.ts b/apps/recnet-api/src/modules/user/dto/slack-oauth.user.dto.ts new file mode 100644 index 00000000..a2c848d1 --- /dev/null +++ b/apps/recnet-api/src/modules/user/dto/slack-oauth.user.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class SlackOauthDto { + @ApiProperty({ + description: "The code from Slack OAuth.", + example: + "7917990338740.8077326386099.68f91412920cf8158cbb632a208afe42b29b5740c16e7829425d8db824def004", + }) + code: string; +} diff --git a/apps/recnet-api/src/modules/user/user.controller.ts b/apps/recnet-api/src/modules/user/user.controller.ts index 33bf7e03..e1ee7c78 100644 --- a/apps/recnet-api/src/modules/user/user.controller.ts +++ b/apps/recnet-api/src/modules/user/user.controller.ts @@ -36,7 +36,6 @@ import { postUserFollowRequestSchema, postUserMeRequestSchema, postUsersSubscriptionsRequestSchema, - PostUsersSubscriptionsSlackOauthRequest, postUserValidateHandleRequestSchema, postUserValidateInviteCodeRequestSchema, } from "@recnet/recnet-api-model"; @@ -44,6 +43,7 @@ import { import { CreateUserDto } from "./dto/create.user.dto"; import { FollowUserDto, UnfollowUserDto } from "./dto/follow.user.dto"; import { QueryUsersDto } from "./dto/query.users.dto"; +import { SlackOauthDto } from "./dto/slack-oauth.user.dto"; import { UpdateUserActivateDto, UpdateUserDto } from "./dto/update.user.dto"; import { ValidateUserHandleDto, @@ -284,7 +284,7 @@ export class UserController { @Auth() public async slackOauth( @User() authUser: AuthUser, - @Body() dto: PostUsersSubscriptionsSlackOauthRequest + @Body() dto: SlackOauthDto ): Promise { const { userId } = authUser; return this.userService.installSlack(userId, dto.code); From 8e6ab8d1033cfae206282724904091bd83117145 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Mon, 25 Nov 2024 15:38:35 -0500 Subject: [PATCH 09/35] fix: env var ci --- apps/recnet-api/.env.sample | 3 +++ apps/recnet-api/.env.test | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/recnet-api/.env.sample b/apps/recnet-api/.env.sample index 4b47b6e2..a9033f8b 100644 --- a/apps/recnet-api/.env.sample +++ b/apps/recnet-api/.env.sample @@ -21,3 +21,6 @@ export SMTP_PASS="ask for password" # SLACK export SLACK_TOKEN="ask for token" +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" \ No newline at end of file diff --git a/apps/recnet-api/.env.test b/apps/recnet-api/.env.test index 6097a3d5..6312ab45 100644 --- a/apps/recnet-api/.env.test +++ b/apps/recnet-api/.env.test @@ -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 \ No newline at end of file From faf1faa45b4172708095d26e82ba6cba368dab26 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Mon, 25 Nov 2024 15:45:53 -0500 Subject: [PATCH 10/35] chore: typo --- apps/recnet-api/.env.sample | 2 +- apps/recnet-api/src/config/common.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/recnet-api/.env.sample b/apps/recnet-api/.env.sample index a9033f8b..354aa67b 100644 --- a/apps/recnet-api/.env.sample +++ b/apps/recnet-api/.env.sample @@ -20,7 +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" \ No newline at end of file diff --git a/apps/recnet-api/src/config/common.config.ts b/apps/recnet-api/src/config/common.config.ts index f4df0a39..797f2a49 100644 --- a/apps/recnet-api/src/config/common.config.ts +++ b/apps/recnet-api/src/config/common.config.ts @@ -33,7 +33,7 @@ export const NodemailerConfig = registerAs("nodemailer", () => ({ })); export const SlackConfig = registerAs("slack", () => ({ - token: parsedEnv.SLACK_TOKEN, // to be de + token: parsedEnv.SLACK_TOKEN, // to be deprecated clientId: parsedEnv.SLACK_CLIENT_ID, clientSecret: parsedEnv.SLACK_CLIENT_SECRET, tokenEncryptionKey: parsedEnv.SLACK_TOKEN_ENCRYPTION_KEY, From 45695592e45dadad24ba83d866fabd08922364dd Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Mon, 25 Nov 2024 17:43:20 -0500 Subject: [PATCH 11/35] chore: add zod pipeline validation --- apps/recnet-api/src/modules/user/user.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/recnet-api/src/modules/user/user.controller.ts b/apps/recnet-api/src/modules/user/user.controller.ts index e1ee7c78..e896c5a9 100644 --- a/apps/recnet-api/src/modules/user/user.controller.ts +++ b/apps/recnet-api/src/modules/user/user.controller.ts @@ -36,6 +36,7 @@ import { postUserFollowRequestSchema, postUserMeRequestSchema, postUsersSubscriptionsRequestSchema, + postUsersSubscriptionsSlackOauthRequestSchema, postUserValidateHandleRequestSchema, postUserValidateInviteCodeRequestSchema, } from "@recnet/recnet-api-model"; @@ -281,6 +282,9 @@ export class UserController { }) @Post("subscriptions/slack/oauth") @ApiBearerAuth() + @UsePipes( + new ZodValidationBodyPipe(postUsersSubscriptionsSlackOauthRequestSchema) + ) @Auth() public async slackOauth( @User() authUser: AuthUser, From 906c4ec846a81d3f2d7117b8900e7dad9bd821af Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Mon, 25 Nov 2024 17:55:38 -0500 Subject: [PATCH 12/35] refactor: move hitting slack api logic to slack service --- .../src/modules/slack/slack.service.ts | 41 ++++++++++++++++++- .../slack/transporters/slack.transporter.ts | 41 +------------------ 2 files changed, 40 insertions(+), 42 deletions(-) diff --git a/apps/recnet-api/src/modules/slack/slack.service.ts b/apps/recnet-api/src/modules/slack/slack.service.ts index 646c0183..5bc8ebd5 100644 --- a/apps/recnet-api/src/modules/slack/slack.service.ts +++ b/apps/recnet-api/src/modules/slack/slack.service.ts @@ -1,5 +1,7 @@ -import { HttpStatus, 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, SlackConfig } from "@recnet-api/config/common.config"; import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; @@ -12,8 +14,12 @@ 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, @@ -26,7 +32,7 @@ export class SlackService { userId: string, code: string ): Promise { - const slackOauthInfo = await this.transporter.accessOauthInfo(userId, code); + const slackOauthInfo = await this.accessOauthInfo(userId, code); await this.validateSlackOauthInfo(userId, slackOauthInfo); // encrypt access token @@ -65,6 +71,37 @@ export class SlackService { return result; } + public async accessOauthInfo( + userId: string, + code: string + ): Promise { + 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 diff --git a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts index f3e26b2c..6b1c82f4 100644 --- a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts +++ b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts @@ -1,8 +1,6 @@ import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { WebClient } from "@slack/web-api"; -import axios from "axios"; -import get from "lodash.get"; import { AppConfig, SlackConfig } from "@recnet-api/config/common.config"; import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; @@ -15,13 +13,7 @@ import { SLACK_RETRY_DURATION_MS, SLACK_RETRY_LIMIT, } from "../slack.const"; -import { - SendSlackResult, - SlackMessageBlocks, - SlackOauthInfo, -} from "../slack.type"; - -const SLACK_OAUTH_ACCESS_API = "https://slack.com/api/oauth.v2.access"; +import { SendSlackResult, SlackMessageBlocks } from "../slack.type"; @Injectable() export class SlackTransporter { @@ -37,37 +29,6 @@ export class SlackTransporter { this.client = new WebClient(); } - public async accessOauthInfo( - userId: string, - code: string - ): Promise { - 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; - } - } - public async sendDirectMessage( user: DbUser, accessToken: string, From 4bce5b8559454f57fcec82e8685b1f0d8a8dd8b0 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Mon, 25 Nov 2024 18:03:29 -0500 Subject: [PATCH 13/35] test: add unit test for encrypt and decrypt function --- apps/recnet-api/src/utils/specs/index.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/recnet-api/src/utils/specs/index.spec.ts diff --git a/apps/recnet-api/src/utils/specs/index.spec.ts b/apps/recnet-api/src/utils/specs/index.spec.ts new file mode 100644 index 00000000..b2733aab --- /dev/null +++ b/apps/recnet-api/src/utils/specs/index.spec.ts @@ -0,0 +1,12 @@ +import { randomBytes } from "crypto"; + +import { decrypt, encrypt } from ".."; + +describe("encrypt and decrypt", () => { + it("should encrypt and decrypt successfully", () => { + const key = randomBytes(32); + const encrypted = encrypt("test", key); + const decrypted = decrypt(encrypted, key); + expect(decrypted).toBe("test"); + }); +}); From c26ad756996cefc8230fe308b63ab72a7196a75a Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 14:37:06 -0500 Subject: [PATCH 14/35] refactor: add new env vars, new nx target --- .gitignore | 1 + apps/recnet/project.json | 12 ++++++++++++ apps/recnet/src/clientEnv.ts | 12 +----------- apps/recnet/src/serverEnv.ts | 18 ++++++++++++++++++ apps/recnet/src/utils/resolveBaseUrl.ts | 11 +++++++++++ 5 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 apps/recnet/src/utils/resolveBaseUrl.ts diff --git a/.gitignore b/.gitignore index ad63f2e6..6ecca4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ Thumbs.db # Next.js .next +certificates # env .env diff --git a/apps/recnet/project.json b/apps/recnet/project.json index efb6fa04..32fdac87 100644 --- a/apps/recnet/project.json +++ b/apps/recnet/project.json @@ -39,6 +39,18 @@ ], "cwd": "apps/recnet" } + }, + "dev:ssl": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "next dev --experimental-https", + "forwardAllArgs": true + } + ], + "cwd": "apps/recnet" + } } }, "tags": ["type:app"] diff --git a/apps/recnet/src/clientEnv.ts b/apps/recnet/src/clientEnv.ts index 6f5986cf..68721d01 100644 --- a/apps/recnet/src/clientEnv.ts +++ b/apps/recnet/src/clientEnv.ts @@ -1,16 +1,6 @@ import { z } from "zod"; -function resolveBaseUrl(env: string | undefined) { - /** - * If the environment is preview, we need to use the Vercel branch URL. - * Otherwise, we use the base URL. - * Ref: https://vercel.com/docs/projects/environment-variables/framework-environment-variables#NEXT_PUBLIC_VERCEL_BRANCH_URL - */ - if (env === "preview") { - return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`; - } - return process.env.NEXT_PUBLIC_BASE_URL; -} +import { resolveBaseUrl } from "./utils/resolveBaseUrl"; export const clientEnvSchema = z.object({ NEXT_PUBLIC_FIREBASE_API_KEY: z.string(), diff --git a/apps/recnet/src/serverEnv.ts b/apps/recnet/src/serverEnv.ts index d2d64511..e7d28f06 100644 --- a/apps/recnet/src/serverEnv.ts +++ b/apps/recnet/src/serverEnv.ts @@ -1,5 +1,15 @@ import { z } from "zod"; +import { resolveBaseUrl } from "./utils/resolveBaseUrl"; + +function resolveSlackRedirectUri(env: string | undefined) { + const baseUrl = resolveBaseUrl(env); + if (!baseUrl) { + return undefined; + } + return baseUrl + process.env.SLACK_OAUTH_REDIRECT_URI; +} + const serverConfigSchema = z.object({ USE_SECURE_COOKIES: z.coerce.boolean(), COOKIE_SIGNATURE_KEY: z.string(), @@ -9,6 +19,9 @@ const serverConfigSchema = z.object({ NEXT_PUBLIC_FIREBASE_API_KEY: z.string(), NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string(), RECNET_API_ENDPOINT: z.string(), + SLACK_APP_CLIENT_ID: z.string(), + SLACK_OAUTH_APP_SCOPES: z.string(), + SLACK_OAUTH_REDIRECT_URI: z.string(), }); const serverConfigRes = serverConfigSchema.safeParse({ @@ -20,6 +33,11 @@ const serverConfigRes = serverConfigSchema.safeParse({ NEXT_PUBLIC_FIREBASE_API_KEY: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, RECNET_API_ENDPOINT: process.env.RECNET_API_ENDPOINT, + SLACK_APP_CLIENT_ID: process.env.SLACK_APP_CLIENT_ID, + SLACK_OAUTH_APP_SCOPES: process.env.SLACK_OAUTH_APP_SCOPES, + SLACK_OAUTH_REDIRECT_URI: resolveSlackRedirectUri( + process.env.NEXT_PUBLIC_VERCEL_ENV + ), }); if (!serverConfigRes.success) { diff --git a/apps/recnet/src/utils/resolveBaseUrl.ts b/apps/recnet/src/utils/resolveBaseUrl.ts new file mode 100644 index 00000000..f262d23f --- /dev/null +++ b/apps/recnet/src/utils/resolveBaseUrl.ts @@ -0,0 +1,11 @@ +export function resolveBaseUrl(env: string | undefined) { + /** + * If the environment is preview, we need to use the Vercel branch URL. + * Otherwise, we use the base URL. + * Ref: https://vercel.com/docs/projects/environment-variables/framework-environment-variables#NEXT_PUBLIC_VERCEL_BRANCH_URL + */ + if (env === "preview") { + return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`; + } + return process.env.NEXT_PUBLIC_BASE_URL; +} From a73f68fae2d2a5297c6add197c6790e93aac87f6 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 16:56:24 -0500 Subject: [PATCH 15/35] chore: finish route handlers for slack oauth --- apps/recnet/.env.local.sample | 3 +++ .../src/app/api/slack/oauth/callback/route.ts | 19 +++++++++++++++++++ .../src/app/api/slack/oauth/install/route.ts | 7 +++++++ .../api/slack/oauth/slackAppInstallHelper.ts | 5 +++++ .../subscription/SubscriptionSetting.tsx | 8 ++++++++ 5 files changed, 42 insertions(+) create mode 100644 apps/recnet/src/app/api/slack/oauth/callback/route.ts create mode 100644 apps/recnet/src/app/api/slack/oauth/install/route.ts create mode 100644 apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts diff --git a/apps/recnet/.env.local.sample b/apps/recnet/.env.local.sample index 1a534a55..f55eda99 100644 --- a/apps/recnet/.env.local.sample +++ b/apps/recnet/.env.local.sample @@ -14,3 +14,6 @@ FIREBASE_PRIVATE_KEY= FIREBASE_CLIENT_EMAIL= CRON_SECRET= RECNET_API_ENDPOINT="http://localhost:4000" +SLACK_APP_CLIENT_ID="" +SLACK_OAUTH_APP_SCOPES="" +SLACK_OAUTH_REDIRECT_URI="" diff --git a/apps/recnet/src/app/api/slack/oauth/callback/route.ts b/apps/recnet/src/app/api/slack/oauth/callback/route.ts new file mode 100644 index 00000000..d23e76a9 --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/callback/route.ts @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { type NextRequest } from "next/server"; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const code = searchParams.get("code"); + console.log("req: ", req); + console.log("code", code); + + if (!code) { + redirect("/feeds?slackOAuthStatus=error"); + } + try { + // hit trpc api to forward the code to api server + redirect("/feeds?slackOAuthStatus=success"); + } catch (e) { + redirect("/feeds?slackOAuthStatus=error"); + } +} diff --git a/apps/recnet/src/app/api/slack/oauth/install/route.ts b/apps/recnet/src/app/api/slack/oauth/install/route.ts new file mode 100644 index 00000000..16b9e7e4 --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/install/route.ts @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +import { generateOAuthLink } from "../slackAppInstallHelper"; + +export async function GET(req: Request) { + redirect(generateOAuthLink()); +} diff --git a/apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts b/apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts new file mode 100644 index 00000000..5ebeaab6 --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts @@ -0,0 +1,5 @@ +import { serverEnv } from "@recnet/recnet-web/serverEnv"; + +export function generateOAuthLink(): string { + return `https://slack.com/oauth/v2/authorize?scope=${serverEnv.SLACK_OAUTH_APP_SCOPES}&client_id=${serverEnv.SLACK_APP_CLIENT_ID}&redirect_uri=${serverEnv.SLACK_OAUTH_REDIRECT_URI}`; +} diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index 6293d31b..3aa66ffd 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -18,6 +18,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { trpc } from "@recnet/recnet-web/app/_trpc/client"; +import { RecNetLink } from "../../Link"; import { LoadingBox } from "@recnet/recnet-web/components/LoadingBox"; import { cn } from "@recnet/recnet-web/utils/cn"; @@ -232,6 +233,13 @@ export function SubscriptionSetting() { })} )} + + + Slack Integration + + + + ); } From 87a94133202e9c256246b1c2a8626b2e2666889a Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 16:56:58 -0500 Subject: [PATCH 16/35] chore: fix import --- .../src/components/setting/subscription/SubscriptionSetting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index 3aa66ffd..e135f036 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -18,7 +18,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { trpc } from "@recnet/recnet-web/app/_trpc/client"; -import { RecNetLink } from "../../Link"; +import { RecNetLink } from "@recnet/recnet-web/components/Link"; import { LoadingBox } from "@recnet/recnet-web/components/LoadingBox"; import { cn } from "@recnet/recnet-web/utils/cn"; From 71ddc42edbeab3aa06ca32e97b1a82caf797f7f4 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 17:09:18 -0500 Subject: [PATCH 17/35] feat: finish route handler --- .../recnet/src/app/api/slack/oauth/callback/route.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/recnet/src/app/api/slack/oauth/callback/route.ts b/apps/recnet/src/app/api/slack/oauth/callback/route.ts index d23e76a9..64007310 100644 --- a/apps/recnet/src/app/api/slack/oauth/callback/route.ts +++ b/apps/recnet/src/app/api/slack/oauth/callback/route.ts @@ -1,19 +1,21 @@ import { redirect } from "next/navigation"; import { type NextRequest } from "next/server"; +import { serverClient } from "@recnet/recnet-web/app/_trpc/serverClient"; + export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const code = searchParams.get("code"); - console.log("req: ", req); - console.log("code", code); if (!code) { redirect("/feeds?slackOAuthStatus=error"); } + // hit trpc api to forward the code to api server + let isSuccess = true; try { - // hit trpc api to forward the code to api server - redirect("/feeds?slackOAuthStatus=success"); + await serverClient.slackOAuth2FA({ code }); } catch (e) { - redirect("/feeds?slackOAuthStatus=error"); + isSuccess = false; } + redirect(`/feeds?slackOAuthStatus=${isSuccess ? "success" : "error"}`); } From c6aa4e42b92ac3490fa8e350f02327a5a23b1132 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 17:20:28 -0500 Subject: [PATCH 18/35] feat: add new api model and trpc procedures --- .../recnet/src/server/routers/subscription.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts index 6e7da4b6..d0b5abc3 100644 --- a/apps/recnet/src/server/routers/subscription.ts +++ b/apps/recnet/src/server/routers/subscription.ts @@ -1,7 +1,9 @@ import { getUsersSubscriptionsResponseSchema, + getUsersSubscriptionsSlackOAuthResponseSchema, postUsersSubscriptionsRequestSchema, postUsersSubscriptionsResponseSchema, + postUsersSubscriptionsSlackOAuthRequestSchema, } from "@recnet/recnet-api-model"; import { checkRecnetJWTProcedure } from "./middleware"; @@ -27,4 +29,27 @@ export const subscriptionRouter = router({ }); return postUsersSubscriptionsResponseSchema.parse(data); }), + slackOAuth2FA: checkRecnetJWTProcedure + .input(postUsersSubscriptionsSlackOAuthRequestSchema) + .mutation(async (opts) => { + // const { code } = opts.input; + // const { recnetApi } = opts.ctx; + + // TODO: uncomment this when the API is ready + // const { data } = await recnetApi.post("/users/subscriptions/slack/oauth"); + // return data; + + console.log("Slack OAuth 2FA success"); + return; + }), + getSlackOAuthStatus: checkRecnetJWTProcedure + .output(getUsersSubscriptionsSlackOAuthResponseSchema) + .query(async (opts) => { + // const { recnetApi } = opts.ctx; + // const { data } = await recnetApi.get("/users/subscriptions/slack/oauth"); + // return getUsersSubscriptionsSlackOAuthResponseSchema.parse(data); + return getUsersSubscriptionsSlackOAuthResponseSchema.parse({ + workspaceName: "Slack Mock Workspace", + }); + }), }); From aead2350138e9d33a1734db77cde464edcca84b7 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 17:41:07 -0500 Subject: [PATCH 19/35] feat: add slack oauth flow result dialog --- apps/recnet/src/app/feeds/SlackOAuthModal.tsx | 75 +++++++++++++++++++ apps/recnet/src/app/feeds/page.tsx | 3 + .../recnet/src/server/routers/subscription.ts | 5 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 apps/recnet/src/app/feeds/SlackOAuthModal.tsx diff --git a/apps/recnet/src/app/feeds/SlackOAuthModal.tsx b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx new file mode 100644 index 00000000..c0ec97f3 --- /dev/null +++ b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx @@ -0,0 +1,75 @@ +"use client"; +import { Button, Dialog } from "@radix-ui/themes"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +/** + * Modal to display the result of slack OAuth flow + */ +export function SlackOAuthModal() { + const [shouldShow, setShouldShow] = useState(false); + const [oauthStatus, setOAuthStatus] = useState<"success" | "error" | null>( + null + ); + const pathname = usePathname(); + const router = useRouter(); + + const searchParams = useSearchParams(); + + useEffect(() => { + const status = searchParams.get("slackOAuthStatus"); + if (status) { + setShouldShow(true); + setOAuthStatus(status as "success" | "error"); + } + }, [searchParams]); + + if (!shouldShow || !oauthStatus) { + return null; + } + + return ( + { + // when closed, remove the search param + if (!open) { + router.replace(pathname); + } + setShouldShow(open); + }} + > + +
+ + {oauthStatus === "success" + ? "✅ You are all set!" + : "❌ Slack OAuth flow failed"} + + + {oauthStatus === "success" + ? "Successfully installed the Slack app! You can now receive message from us in your workspace." + : "Slack OAuth flow failed. Please try again or contact us."} + +
+ +
+
+
+
+ ); +} diff --git a/apps/recnet/src/app/feeds/page.tsx b/apps/recnet/src/app/feeds/page.tsx index 755d3bb8..1f7748c5 100644 --- a/apps/recnet/src/app/feeds/page.tsx +++ b/apps/recnet/src/app/feeds/page.tsx @@ -19,6 +19,8 @@ import { formatDate, } from "@recnet/recnet-date-fns"; +import { SlackOAuthModal } from "./SlackOAuthModal"; + import { trpc } from "../_trpc/client"; import { OnboardingDialog } from "../onboard/OnboardingDialog"; @@ -124,6 +126,7 @@ export default function FeedPage({ "md:py-12" )} > + {Object.keys(recsGroupByTitle).length > 0 ? ( <> diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts index d0b5abc3..1b21fb01 100644 --- a/apps/recnet/src/server/routers/subscription.ts +++ b/apps/recnet/src/server/routers/subscription.ts @@ -10,6 +10,8 @@ import { checkRecnetJWTProcedure } from "./middleware"; import { router } from "../trpc"; +let workspaceName: string | undefined = undefined; + export const subscriptionRouter = router({ getSubscriptions: checkRecnetJWTProcedure .output(getUsersSubscriptionsResponseSchema) @@ -40,6 +42,7 @@ export const subscriptionRouter = router({ // return data; console.log("Slack OAuth 2FA success"); + workspaceName = "Slack Mock Workspace"; return; }), getSlackOAuthStatus: checkRecnetJWTProcedure @@ -49,7 +52,7 @@ export const subscriptionRouter = router({ // const { data } = await recnetApi.get("/users/subscriptions/slack/oauth"); // return getUsersSubscriptionsSlackOAuthResponseSchema.parse(data); return getUsersSubscriptionsSlackOAuthResponseSchema.parse({ - workspaceName: "Slack Mock Workspace", + workspaceName, }); }), }); From 245171205af7b42be481aacac819fd330beff295 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 17:54:25 -0500 Subject: [PATCH 20/35] feat: finish changes in subscription setting --- .../subscription/SubscriptionSetting.tsx | 21 ++++++++++++++++--- .../recnet/src/server/routers/subscription.ts | 6 ++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index e135f036..49534b05 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -195,6 +195,11 @@ function SubscriptionTypeCard(props: { export function SubscriptionSetting() { const { data, isFetching } = trpc.getSubscriptions.useQuery(); + const { data: slackOAuthData, isFetching: isFetchingSlackOAuthData } = + trpc.getSlackOAuthStatus.useQuery(); + + console.log(slackOAuthData); + const [openedType, setOpenType] = useState( undefined ); @@ -237,9 +242,19 @@ export function SubscriptionSetting() { Slack Integration - - - + {isFetchingSlackOAuthData ? ( + + ) : slackOAuthData?.workspaceName === null ? ( + + + + ) : ( + + Currently integrated with workspace: {slackOAuthData?.workspaceName} + + )} ); } diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts index 1b21fb01..5bbeb920 100644 --- a/apps/recnet/src/server/routers/subscription.ts +++ b/apps/recnet/src/server/routers/subscription.ts @@ -10,8 +10,6 @@ import { checkRecnetJWTProcedure } from "./middleware"; import { router } from "../trpc"; -let workspaceName: string | undefined = undefined; - export const subscriptionRouter = router({ getSubscriptions: checkRecnetJWTProcedure .output(getUsersSubscriptionsResponseSchema) @@ -42,7 +40,6 @@ export const subscriptionRouter = router({ // return data; console.log("Slack OAuth 2FA success"); - workspaceName = "Slack Mock Workspace"; return; }), getSlackOAuthStatus: checkRecnetJWTProcedure @@ -51,8 +48,9 @@ export const subscriptionRouter = router({ // const { recnetApi } = opts.ctx; // const { data } = await recnetApi.get("/users/subscriptions/slack/oauth"); // return getUsersSubscriptionsSlackOAuthResponseSchema.parse(data); + return getUsersSubscriptionsSlackOAuthResponseSchema.parse({ - workspaceName, + workspaceName: null, }); }), }); From be75ad61382ce7b0c31f97f22391241687fd8392 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 21 Nov 2024 18:06:57 -0500 Subject: [PATCH 21/35] feat: forward error message from slack --- apps/recnet/src/app/api/slack/oauth/callback/route.ts | 10 ++++++++-- apps/recnet/src/app/feeds/SlackOAuthModal.tsx | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/recnet/src/app/api/slack/oauth/callback/route.ts b/apps/recnet/src/app/api/slack/oauth/callback/route.ts index 64007310..4c3734c8 100644 --- a/apps/recnet/src/app/api/slack/oauth/callback/route.ts +++ b/apps/recnet/src/app/api/slack/oauth/callback/route.ts @@ -6,9 +6,13 @@ import { serverClient } from "@recnet/recnet-web/app/_trpc/serverClient"; export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const code = searchParams.get("code"); + const errorDesc = searchParams.get("error_description"); + console.log("errorDesc: ", errorDesc); if (!code) { - redirect("/feeds?slackOAuthStatus=error"); + redirect( + `/feeds?slackOAuthStatus=error${errorDesc ? `&error_description=${errorDesc}` : ""}` + ); } // hit trpc api to forward the code to api server let isSuccess = true; @@ -17,5 +21,7 @@ export async function GET(req: NextRequest) { } catch (e) { isSuccess = false; } - redirect(`/feeds?slackOAuthStatus=${isSuccess ? "success" : "error"}`); + redirect( + `/feeds?slackOAuthStatus=${isSuccess ? "success" : "error"}${errorDesc ? `&error_description=${errorDesc}` : ""}` + ); } diff --git a/apps/recnet/src/app/feeds/SlackOAuthModal.tsx b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx index c0ec97f3..97204603 100644 --- a/apps/recnet/src/app/feeds/SlackOAuthModal.tsx +++ b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx @@ -55,7 +55,8 @@ export function SlackOAuthModal() { {oauthStatus === "success" ? "Successfully installed the Slack app! You can now receive message from us in your workspace." - : "Slack OAuth flow failed. Please try again or contact us."} + : searchParams.get("error_description") || + "Slack OAuth flow failed. Please try again or contact us."}
+ ) : ( - - Currently integrated with workspace: {slackOAuthData?.workspaceName} - +
+ + ✅ Currently installed in workspace:{" "} + {workspaceName} + +
)}
); From 2a0806e762301920e85bc104759bb5c6143bdd1b Mon Sep 17 00:00:00 2001 From: swh00tw Date: Fri, 22 Nov 2024 21:55:01 -0500 Subject: [PATCH 24/35] feat: refactor ui --- .../subscription/SubscriptionSetting.tsx | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index 6e51e58b..7a8691a3 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -61,6 +61,7 @@ function SubscriptionTypeCard(props: { const { isDirty } = useFormState({ control }); const updateSubscriptionMutation = trpc.updateSubscription.useMutation(); + const { data: slackOAuthData } = trpc.getSlackOAuthStatus.useQuery(); return ( @@ -93,9 +94,11 @@ function SubscriptionTypeCard(props: { onSubmit={handleSubmit( async (data, e) => { setIsSubmitting(true); - // handle special case for WEEKLY DIGEST - // for weekly digest, at least one channel must be selected - // if no, then show error message + /** + * Special case 1: WEEKLY_DIGEST + * For weekly digest, at least one channel must be selected + * if no, then show error message + */ if (type === "WEEKLY_DIGEST" && data.channels.length === 0) { setError("channels", { type: "manual", @@ -105,6 +108,24 @@ function SubscriptionTypeCard(props: { setIsSubmitting(false); return; } + /* + * Special case 2: SLACK distribution channel + * When user selects slack channel, we need to check if the user has completed slack integration oauth flow or not + * If not, then show error message and ask user to complete slack integration + */ + if ( + slackOAuthData?.workspaceName === null && + data.channels.includes(subscriptionChannelSchema.enum.SLACK) + ) { + setError("channels", { + type: "manual", + message: + "To enable slack distribution channel, you need to complete slack integration first. See 'Slack Integration' below to learn more", + }); + setIsSubmitting(false); + return; + } + await updateSubscriptionMutation.mutateAsync({ type, channels: data.channels, @@ -152,16 +173,6 @@ function SubscriptionTypeCard(props: { }} /> - - - BETA - - - Distribute by Slack is currently in beta version. Only people in - Cornell-NLP slack workspace can use this feature. And the email - account of the slack account must match the RecNet account. - - ) : ( -
+
✅ Currently installed in{" "} {workspaceName} + { + await deleteSlackOAuthInfoMutation.mutateAsync(); + utils.getSlackOAuthStatus.invalidate(); + }} + title="Are you sure?" + description="This will uninstall the slack integration and you will not be able to distribute subscription through slack anymore. You can go through the OAuth process to install again." + > + +
)}
diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts index e8ea554f..4b7d2077 100644 --- a/apps/recnet/src/server/routers/subscription.ts +++ b/apps/recnet/src/server/routers/subscription.ts @@ -52,4 +52,8 @@ export const subscriptionRouter = router({ const { data } = await recnetApi.get("/users/subscriptions/slack/oauth"); return getUsersSubscriptionsSlackOauthResponseSchema.parse(data); }), + deleteSlackOAuthInfo: checkRecnetJWTProcedure.mutation(async (opts) => { + const { recnetApi } = opts.ctx; + await recnetApi.delete("/users/subscriptions/slack/oauth"); + }), }); From 4fcf84d25ea030a11d3c59807e395156147e96be Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 25 Nov 2024 19:45:58 -0500 Subject: [PATCH 28/35] chore: change slack install button color --- .../setting/subscription/SubscriptionSetting.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index b6c4ab0c..4f1c3cb3 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -10,7 +10,7 @@ import { CheckboxCards, Button, } from "@radix-ui/themes"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Slack as SlackIcon } from "lucide-react"; import { useState } from "react"; import { Controller, useForm, useFormState } from "react-hook-form"; import { toast } from "sonner"; @@ -262,7 +262,14 @@ export function SubscriptionSetting() { ) : workspaceName === null ? ( - + ) : (
From d28e2881e1bfc161ef9bd81397f1399f63e901b3 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 26 Nov 2024 22:15:19 -0500 Subject: [PATCH 29/35] fix: fix bug --- .../src/modules/slack/templates/weekly-digest.template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/recnet-api/src/modules/slack/templates/weekly-digest.template.ts b/apps/recnet-api/src/modules/slack/templates/weekly-digest.template.ts index 3b539eda..85751d13 100644 --- a/apps/recnet-api/src/modules/slack/templates/weekly-digest.template.ts +++ b/apps/recnet-api/src/modules/slack/templates/weekly-digest.template.ts @@ -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!`, From 87a1e673a481460f5f8ae09cf4086f579443bc04 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 2 Dec 2024 14:16:17 -0800 Subject: [PATCH 30/35] chore: modify wording for removing slack integration --- .../src/components/DoubleConfirmButton.tsx | 2 +- .../subscription/SubscriptionSetting.tsx | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/recnet/src/components/DoubleConfirmButton.tsx b/apps/recnet/src/components/DoubleConfirmButton.tsx index b3989b05..cb65fd3f 100644 --- a/apps/recnet/src/components/DoubleConfirmButton.tsx +++ b/apps/recnet/src/components/DoubleConfirmButton.tsx @@ -9,7 +9,7 @@ interface DoubleConfirmButtonProps { onConfirm: () => Promise; children: React.ReactNode; title: string; - description: string; + description: string | React.ReactNode; cancelButtonProps?: React.ComponentProps; confirmButtonProps?: React.ComponentProps; } diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index 4f1c3cb3..0f3eb5d7 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -283,7 +283,32 @@ export function SubscriptionSetting() { utils.getSlackOAuthStatus.invalidate(); }} title="Are you sure?" - description="This will uninstall the slack integration and you will not be able to distribute subscription through slack anymore. You can go through the OAuth process to install again." + description={ +
+ {[ + "We will disconnect and will not be able to distribute subscription through slack.", + "But the slack app will still be installed in your workspace.", + "To remove it from your workspace, follow the instructions ", + ].map((text, index) => ( + + {text} + + ))} + + here + + . +
+ } >