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-api/.env.sample b/apps/recnet-api/.env.sample index 4b47b6e2..354aa67b 100644 --- a/apps/recnet-api/.env.sample +++ b/apps/recnet-api/.env.sample @@ -20,4 +20,7 @@ export SMTP_USER="lil.recnet@gmail.com" export SMTP_PASS="ask for password" # SLACK -export SLACK_TOKEN="ask for token" +export SLACK_TOKEN="ask for token" # to be deprecated +export SLACK_CLIENT_ID="ask for client id" +export SLACK_CLIENT_SECRET="ask for client secret" +export SLACK_TOKEN_ENCRYPTION_KEY="ask for token encryption key" \ 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 diff --git a/apps/recnet-api/package.json b/apps/recnet-api/package.json index 128486fb..482ea9b2 100644 --- a/apps/recnet-api/package.json +++ b/apps/recnet-api/package.json @@ -1,4 +1,4 @@ { "name": "recnet-api", - "version": "1.8.3" + "version": "1.8.4" } 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 diff --git a/apps/recnet-api/src/config/common.config.ts b/apps/recnet-api/src/config/common.config.ts index ee87c5c4..797f2a49 100644 --- a/apps/recnet-api/src/config/common.config.ts +++ b/apps/recnet-api/src/config/common.config.ts @@ -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, })); diff --git a/apps/recnet-api/src/config/env.schema.ts b/apps/recnet-api/src/config/env.schema.ts index 87b02dea..01db647d 100644 --- a/apps/recnet-api/src/config/env.schema.ts +++ b/apps/recnet-api/src/config/env.schema.ts @@ -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) => { diff --git a/apps/recnet-api/src/database/repository/user.repository.ts b/apps/recnet-api/src/database/repository/user.repository.ts index 60439d59..95bdaa44 100644 --- a/apps/recnet-api/src/database/repository/user.repository.ts +++ b/apps/recnet-api/src/database/repository/user.repository.ts @@ -182,6 +182,31 @@ 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 }, + 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..66313cd6 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: { @@ -50,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 886359c8..a2fb24c6 100644 --- a/apps/recnet-api/src/modules/slack/slack.service.ts +++ b/apps/recnet-api/src/modules/slack/slack.service.ts @@ -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, + @Inject(SlackConfig.KEY) + private readonly slackConfig: ConfigType, private readonly transporter: SlackTransporter ) {} + public async installApp( + userId: string, + redirectUri: string, + code: string + ): Promise { + 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, @@ -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 ); @@ -40,4 +75,58 @@ export class SlackService { return result; } + + public async accessOauthInfo( + userId: string, + redirectUri: string, + code: string + ): Promise { + 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 { + 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/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!`, 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..6b1c82f4 100644 --- a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts +++ b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts @@ -26,11 +26,12 @@ export class SlackTransporter { @Inject(AppConfig.KEY) private readonly appConfig: ConfigType ) { - this.client = new WebClient(this.slackConfig.token); + this.client = new WebClient(); } public async sendDirectMessage( user: DbUser, + accessToken: string, message: SlackMessageBlocks, notificationText?: string ): Promise { @@ -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 @@ -67,9 +83,13 @@ export class SlackTransporter { ); } + // Backward compatible private async getUserSlackId(user: DbUser): Promise { - 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( @@ -83,12 +103,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: accessToken, }); const conversationId = conversationResp?.channel?.id; if (!conversationId) { @@ -104,6 +126,7 @@ export class SlackTransporter { channel: conversationId, text: notificationText, blocks: message, + token: accessToken, }); } } 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..abd32479 --- /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: "[Dev only] 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], }) 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..ea4ecb56 --- /dev/null +++ b/apps/recnet-api/src/modules/user/dto/slack-oauth.user.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class SlackOauthDto { + @ApiProperty({ + description: "The code from Slack OAuth.", + example: + "7917990338740.8077326386099.68f91412920cf8158cbb632a208afe42b29b5740c16e7829425d8db824def004", + }) + code: string; + + @ApiProperty({ + description: "The redirect URI from Slack OAuth.", + example: "https://localhost:3000/api/slack/oauth/callback", + }) + redirectUri: string; +} diff --git a/apps/recnet-api/src/modules/user/user.controller.ts b/apps/recnet-api/src/modules/user/user.controller.ts index 3be22e52..3520429f 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"; @@ -43,6 +44,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, @@ -50,6 +52,7 @@ import { } from "./dto/validate.user.dto"; import { Subscription } from "./entities/user.subscription.entity"; import { + GetSlackOauthInfoResponse, GetSubscriptionsResponse, GetUserMeResponse, GetUsersResponse, @@ -258,4 +261,48 @@ 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() + @UsePipes( + new ZodValidationBodyPipe(postUsersSubscriptionsSlackOauthRequestSchema) + ) + @Auth() + public async slackOauth( + @User() authUser: AuthUser, + @Body() dto: SlackOauthDto + ): Promise { + const { userId } = authUser; + return this.userService.installSlack(userId, dto.redirectUri, 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..76d241aa 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,42 @@ export class UserService { }; } + public async getSlackOauthInfo( + userId: string + ): Promise { + const user = await this.userRepository.findUserById(userId); + return { + workspaceName: user.slackWorkspaceName, + }; + } + + public async installSlack( + userId: string, + redirectUri: string, + code: string + ): Promise { + const user = await this.userRepository.findUserById(userId); + if (user.slackUserId) { + throw new RecnetError( + ErrorCode.SLACK_ALREADY_INSTALLED, + HttpStatus.BAD_REQUEST + ); + } + const oauthInfo = await this.slackService.installApp( + userId, + redirectUri, + code + ); + await this.userRepository.updateUserSlackInfo(userId, oauthInfo); + 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/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", }; 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; +}; 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"); + }); +}); 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/CHANGELOG.md b/apps/recnet/CHANGELOG.md index 253cb639..466ed842 100644 --- a/apps/recnet/CHANGELOG.md +++ b/apps/recnet/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [1.17.0](https://github.com/lil-lab/recnet/compare/recnet-web-v1.16.2...recnet-web-v1.17.0) (2024-12-03) + + +### Features + +* add delete slack oauth api ([684b434](https://github.com/lil-lab/recnet/commit/684b434264260798cee53d46628ad073cdff4e41)) +* add new api model and trpc procedures ([c6aa4e4](https://github.com/lil-lab/recnet/commit/c6aa4e42b92ac3490fa8e350f02327a5a23b1132)) +* add slack oauth flow result dialog ([aead235](https://github.com/lil-lab/recnet/commit/aead2350138e9d33a1734db77cde464edcca84b7)) +* encrypt access token ([2e37bf4](https://github.com/lil-lab/recnet/commit/2e37bf4619f5ade5fb4a456fdc231cdb38ab10fb)) +* finish changes in subscription setting ([2451712](https://github.com/lil-lab/recnet/commit/245171205af7b42be481aacac819fd330beff295)) +* finish route handler ([71ddc42](https://github.com/lil-lab/recnet/commit/71ddc42edbeab3aa06ca32e97b1a82caf797f7f4)) +* forward error message from slack ([be75ad6](https://github.com/lil-lab/recnet/commit/be75ad61382ce7b0c31f97f22391241687fd8392)) +* integrate slack oauth access ([0811c6a](https://github.com/lil-lab/recnet/commit/0811c6a9d79745e7436faa18e916e5a6edad4ca3)) +* refactor ui ([2a0806e](https://github.com/lil-lab/recnet/commit/2a0806e762301920e85bc104759bb5c6143bdd1b)) +* refactor UI ([bc44d39](https://github.com/lil-lab/recnet/commit/bc44d3955c0e580c67659ac367d379aec52bcb51)) +* send message with new access token ([f75fa90](https://github.com/lil-lab/recnet/commit/f75fa90e49eb70fb00ea53a023df510a2be86d54)) +* slack fields db migration ([87e1f40](https://github.com/lil-lab/recnet/commit/87e1f40ecfa1d6d723827df6416b2d34948d660a)) +* update user db ([dfb61cd](https://github.com/lil-lab/recnet/commit/dfb61cdef757903a151178707ef8e0741f12236d)) +* use recnet-api endpoints ([3fa73bd](https://github.com/lil-lab/recnet/commit/3fa73bd9034d56c8e6efe9380151046a7ac4f35a)) + + +### Bug Fixes + +* bring redirect uri in req ([604b857](https://github.com/lil-lab/recnet/commit/604b857b77b25396482b3be162f784213b1d82f0)) +* env var ci ([8e6ab8d](https://github.com/lil-lab/recnet/commit/8e6ab8d1033cfae206282724904091bd83117145)) +* fix bug ([d28e288](https://github.com/lil-lab/recnet/commit/d28e2881e1bfc161ef9bd81397f1399f63e901b3)) +* fix bug ([47a2e71](https://github.com/lil-lab/recnet/commit/47a2e7136fd46608ca88d91fd2d79d258319975d)) +* pass redirect uri to slack oauth api ([0197f08](https://github.com/lil-lab/recnet/commit/0197f08a0142f637147e7dd2c243f2a2af86afe6)) +* send redirect uri to slac Oauth access API ([#369](https://github.com/lil-lab/recnet/issues/369)) ([7ee2539](https://github.com/lil-lab/recnet/commit/7ee2539255f7d059eccf1762f49158ea4843d5e0)) + ## [1.16.2](https://github.com/lil-lab/recnet/compare/recnet-web-v1.16.1...recnet-web-v1.16.2) (2024-11-18) diff --git a/apps/recnet/package.json b/apps/recnet/package.json index 2892f969..afb3fe33 100644 --- a/apps/recnet/package.json +++ b/apps/recnet/package.json @@ -1,6 +1,6 @@ { "name": "recnet", - "version": "1.16.2", + "version": "1.17.0", "commit-and-tag-version": { "skip": { "commit": true 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/app/api/slack/oauth/callback/route.ts b/apps/recnet/src/app/api/slack/oauth/callback/route.ts new file mode 100644 index 00000000..f5c40a3b --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/callback/route.ts @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import { type NextRequest } from "next/server"; + +import { serverClient } from "@recnet/recnet-web/app/_trpc/serverClient"; +import { serverEnv } from "@recnet/recnet-web/serverEnv"; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const code = searchParams.get("code"); + const errorDesc = searchParams.get("error_description"); + + if (!code) { + redirect( + `/feeds?slackOAuthStatus=error${errorDesc ? `&error_description=${errorDesc}` : ""}` + ); + } + let isSuccess = true; + let workspaceName = ""; + try { + const data = await serverClient.slackOAuth2FA({ + code: code, + redirectUri: serverEnv.SLACK_OAUTH_REDIRECT_URI, + }); + workspaceName = data.workspaceName; + } catch (e) { + console.error("e: ", e); + isSuccess = false; + } + redirect( + `/feeds?slackOAuthStatus=${isSuccess ? `success&workspace_name=${workspaceName}` : "error"}${errorDesc ? `&error_description=${errorDesc}` : ""}` + ); +} 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/app/feeds/SlackOAuthModal.tsx b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx new file mode 100644 index 00000000..4a955142 --- /dev/null +++ b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx @@ -0,0 +1,76 @@ +"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 workspace: ${searchParams.get("workspace_name")}.` + : searchParams.get("error_description") || + "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/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/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 6293d31b..0f3eb5d7 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -8,16 +8,17 @@ import { Flex, Text, CheckboxCards, - Badge, 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"; import { z } from "zod"; import { trpc } from "@recnet/recnet-web/app/_trpc/client"; +import { DoubleConfirmButton } from "@recnet/recnet-web/components/DoubleConfirmButton"; +import { RecNetLink } from "@recnet/recnet-web/components/Link"; import { LoadingBox } from "@recnet/recnet-web/components/LoadingBox"; import { cn } from "@recnet/recnet-web/utils/cn"; @@ -60,6 +61,7 @@ function SubscriptionTypeCard(props: { const { isDirty } = useFormState({ control }); const updateSubscriptionMutation = trpc.updateSubscription.useMutation(); + const { data: slackOAuthData } = trpc.getSlackOAuthStatus.useQuery(); return ( @@ -92,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", @@ -104,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, @@ -151,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={ +
+ {[ + "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 + + . +
+ } + > + +
+
+ )} ); } diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts index 6e7da4b6..88d647a2 100644 --- a/apps/recnet/src/server/routers/subscription.ts +++ b/apps/recnet/src/server/routers/subscription.ts @@ -1,7 +1,10 @@ import { getUsersSubscriptionsResponseSchema, + getUsersSubscriptionsSlackOauthResponseSchema, + postUsersSubscriptionsSlackOauthResponseSchema, postUsersSubscriptionsRequestSchema, postUsersSubscriptionsResponseSchema, + postUsersSubscriptionsSlackOauthRequestSchema, } from "@recnet/recnet-api-model"; import { checkRecnetJWTProcedure } from "./middleware"; @@ -27,4 +30,31 @@ export const subscriptionRouter = router({ }); return postUsersSubscriptionsResponseSchema.parse(data); }), + slackOAuth2FA: checkRecnetJWTProcedure + .input(postUsersSubscriptionsSlackOauthRequestSchema) + .output(postUsersSubscriptionsSlackOauthResponseSchema) + .mutation(async (opts) => { + const { code, redirectUri } = opts.input; + const { recnetApi } = opts.ctx; + + const { data } = await recnetApi.post( + "/users/subscriptions/slack/oauth", + { + code, + redirectUri, + } + ); + return postUsersSubscriptionsSlackOauthResponseSchema.parse(data); + }), + getSlackOAuthStatus: checkRecnetJWTProcedure + .output(getUsersSubscriptionsSlackOauthResponseSchema) + .query(async (opts) => { + const { recnetApi } = opts.ctx; + 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"); + }), }); 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; +} diff --git a/libs/recnet-api-model/src/lib/api/user.ts b/libs/recnet-api-model/src/lib/api/user.ts index 65059e86..5087696e 100644 --- a/libs/recnet-api-model/src/lib/api/user.ts +++ b/libs/recnet-api-model/src/lib/api/user.ts @@ -130,3 +130,27 @@ 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(), + redirectUri: z.string(), +}); +export type PostUsersSubscriptionsSlackOauthRequest = z.infer< + typeof postUsersSubscriptionsSlackOauthRequestSchema +>; + +export const postUsersSubscriptionsSlackOauthResponseSchema = z.object({ + workspaceName: z.string(), +}); +export type PostUsersSubscriptionsSlackOauthResponse = z.infer< + typeof postUsersSubscriptionsSlackOauthResponseSchema +>; + +// GET /users/subscriptions/slack/oauth +export const getUsersSubscriptionsSlackOauthResponseSchema = z.object({ + workspaceName: z.string().nullable(), +}); +export type GetUsersSubscriptionsSlackOauthResponse = z.infer< + typeof getUsersSubscriptionsSlackOauthResponseSchema +>; 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, } ); }