From 9aac34f2f1d2b5aae0b5a40b9db9f9acf6d80a93 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Wed, 6 Nov 2024 01:33:30 -0500 Subject: [PATCH 01/24] feat: migrate email digest --- .../database/repository/user.repository.ts | 18 ++- .../repository/user.repository.type.ts | 8 +- .../weekly-digest-cron-log.repository.type.ts | 6 +- .../src/modules/email/email.const.ts | 6 +- .../src/modules/email/email.controller.ts | 54 ------- .../src/modules/email/email.module.ts | 3 +- .../src/modules/email/email.service.ts | 141 ++-------------- .../transporters/email.dev.transporters.ts | 4 +- .../email/transporters/email.transporters.ts | 4 +- .../subscription/subscription.const.ts | 5 + .../subscription/subscription.module.ts | 6 +- .../modules/subscription/subscription.type.ts | 8 + .../subscription/weekly-digest.worker.ts | 152 ++++++++++++++++++ 13 files changed, 212 insertions(+), 203 deletions(-) delete mode 100644 apps/recnet-api/src/modules/email/email.controller.ts create mode 100644 apps/recnet-api/src/modules/subscription/subscription.const.ts create mode 100644 apps/recnet-api/src/modules/subscription/subscription.type.ts create mode 100644 apps/recnet-api/src/modules/subscription/weekly-digest.worker.ts diff --git a/apps/recnet-api/src/database/repository/user.repository.ts b/apps/recnet-api/src/database/repository/user.repository.ts index d9aa694e..71b797cf 100644 --- a/apps/recnet-api/src/database/repository/user.repository.ts +++ b/apps/recnet-api/src/database/repository/user.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { Prisma, Provider } from "@prisma/client"; +import { Prisma, Provider, SubscriptionType } from "@prisma/client"; import PrismaConnectionProvider from "@recnet-api/database/prisma/prisma.connection.provider"; import { getOffset } from "@recnet-api/utils"; @@ -14,6 +14,7 @@ import { UserFilterBy, CreateUserInput, UpdateUserInput, + SubscriptionFilterBy, } from "./user.repository.type"; @Injectable() @@ -72,6 +73,21 @@ export default class UserRepository { }); } + public async findUsersBySubscription(filter: SubscriptionFilterBy) { + const where: Prisma.UserWhereInput = { + subscriptions: { + some: { + type: filter.type, + channel: filter.channel, + }, + }, + }; + return this.prisma.user.findMany({ + select: user.select, + where, + }); + } + public async login( provider: AuthProvider, providerId: string 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 1e9a65ce..e36847fd 100644 --- a/apps/recnet-api/src/database/repository/user.repository.type.ts +++ b/apps/recnet-api/src/database/repository/user.repository.type.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@prisma/client"; +import { Channel, Prisma, SubscriptionType } from "@prisma/client"; import { AuthProvider } from "@recnet/recnet-jwt"; @@ -49,6 +49,7 @@ export const user = Prisma.validator()({ }, }, recommendations: true, + subscriptions: true, }, }); @@ -60,6 +61,11 @@ export type UserFilterBy = { id?: string; }; +export type SubscriptionFilterBy = { + type?: SubscriptionType; + channel?: Channel; +}; + export type CreateUserInput = { provider: AuthProvider; providerId: string; diff --git a/apps/recnet-api/src/database/repository/weekly-digest-cron-log.repository.type.ts b/apps/recnet-api/src/database/repository/weekly-digest-cron-log.repository.type.ts index bc66c42d..8539fa6e 100644 --- a/apps/recnet-api/src/database/repository/weekly-digest-cron-log.repository.type.ts +++ b/apps/recnet-api/src/database/repository/weekly-digest-cron-log.repository.type.ts @@ -1,4 +1,6 @@ export type WeeklyDigestCronResult = { - successCount: number; - errorUserIds: string[]; + email: { + successCount: number; + errorUserIds: string[]; + }; }; diff --git a/apps/recnet-api/src/modules/email/email.const.ts b/apps/recnet-api/src/modules/email/email.const.ts index 99dba0c3..906b2674 100644 --- a/apps/recnet-api/src/modules/email/email.const.ts +++ b/apps/recnet-api/src/modules/email/email.const.ts @@ -1,10 +1,6 @@ -export const MAX_REC_PER_MAIL = 5; - -export const WEEKLY_DIGEST_CRON = "0 0 0 * * 3"; +export const RETRY_DURATION_MS = 1000; export const RETRY_LIMIT = 3; -export const SLEEP_DURATION_MS = 1000; - // providers export const MAIL_TRANSPORTER = "MAIL_TRANSPORTER"; diff --git a/apps/recnet-api/src/modules/email/email.controller.ts b/apps/recnet-api/src/modules/email/email.controller.ts deleted file mode 100644 index 166f85d9..00000000 --- a/apps/recnet-api/src/modules/email/email.controller.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 { RecnetError } from "@recnet-api/utils/error/recnet.error"; -import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; - -import { EmailService } from "./email.service"; - -@ApiTags("mail") -@Controller("mail") -export class EmailController { - constructor( - @Inject(AppConfig.KEY) - private readonly appConfig: ConfigType, - private readonly emailService: EmailService - ) {} - - /* Development only */ - @ApiOperation({ - summary: "Send weekly digest email to the designated user.", - description: "This endpoint is for development only.", - }) - @ApiCreatedResponse() - @ApiBody({ - schema: { - properties: { - userId: { type: "string" }, - }, - required: ["userId"], - }, - }) - @Post("test") - public async testSendingWeeklyDigest( - @Body("userId") userId: string - ): Promise<{ - success: boolean; - }> { - if (this.appConfig.nodeEnv === "production") { - throw new RecnetError( - ErrorCode.INTERNAL_SERVER_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR, - "This endpoint is only for development" - ); - } - return this.emailService.sendTestEmail(userId); - } -} diff --git a/apps/recnet-api/src/modules/email/email.module.ts b/apps/recnet-api/src/modules/email/email.module.ts index 0e10d647..b7ec1e90 100644 --- a/apps/recnet-api/src/modules/email/email.module.ts +++ b/apps/recnet-api/src/modules/email/email.module.ts @@ -4,7 +4,6 @@ import { ConfigService } from "@nestjs/config"; import { DbRepositoryModule } from "@recnet-api/database/repository/db.repository.module"; import { MAIL_TRANSPORTER } from "./email.const"; -import { EmailController } from "./email.controller"; import { EmailService } from "./email.service"; import { Transporter } from "./email.type"; import EmailDevTransporter from "./transporters/email.dev.transporters"; @@ -20,7 +19,6 @@ const transporterFactory = (configService: ConfigService): Transporter => { }; @Module({ - controllers: [EmailController], providers: [ EmailService, { @@ -30,5 +28,6 @@ const transporterFactory = (configService: ConfigService): Transporter => { }, ], imports: [DbRepositoryModule], + exports: [EmailService], }) export class EmailModule {} diff --git a/apps/recnet-api/src/modules/email/email.service.ts b/apps/recnet-api/src/modules/email/email.service.ts index 1a085120..3ea1dd3a 100644 --- a/apps/recnet-api/src/modules/email/email.service.ts +++ b/apps/recnet-api/src/modules/email/email.service.ts @@ -1,31 +1,13 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; -import { Cron } from "@nestjs/schedule"; -import { CronStatus } from "@prisma/client"; import { render } from "@react-email/render"; import groupBy from "lodash.groupby"; import { AppConfig, NodemailerConfig } from "@recnet-api/config/common.config"; -import AnnouncementRepository from "@recnet-api/database/repository/announcement.repository"; -import InviteCodeRepository from "@recnet-api/database/repository/invite-code.repository"; -import RecRepository from "@recnet-api/database/repository/rec.repository"; -import { RecFilterBy } from "@recnet-api/database/repository/rec.repository.type"; -import UserRepository from "@recnet-api/database/repository/user.repository"; import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; -import WeeklyDigestCronLogRepository from "@recnet-api/database/repository/weekly-digest-cron-log.repository"; -import { transformAnnouncement } from "@recnet-api/modules/announcement/announcement.transform"; -import { Rec } from "@recnet-api/modules/rec/entities/rec.entity"; -import { transformRec } from "@recnet-api/modules/rec/rec.transformer"; -import { sleep } from "@recnet-api/utils"; +import { WeeklyDigestContent } from "@recnet-api/modules/subscription/subscription.type"; -import { getLatestCutOff } from "@recnet/recnet-date-fns"; - -import { - MAIL_TRANSPORTER, - MAX_REC_PER_MAIL, - SLEEP_DURATION_MS, - WEEKLY_DIGEST_CRON, -} from "./email.const"; +import { MAIL_TRANSPORTER } from "./email.const"; import { SendMailResult, Transporter } from "./email.type"; import WeeklyDigest, { WeeklyDigestSubject } from "./templates/WeeklyDigest"; @@ -37,124 +19,19 @@ export class EmailService { @Inject(NodemailerConfig.KEY) private readonly nodemailerConfig: ConfigType, @Inject(MAIL_TRANSPORTER) - private transporter: Transporter, - @Inject(UserRepository) - private readonly userRepository: UserRepository, - @Inject(RecRepository) - private readonly recRepository: RecRepository, - @Inject(WeeklyDigestCronLogRepository) - private readonly weeklyDigestCronLogRepository: WeeklyDigestCronLogRepository, - @Inject(InviteCodeRepository) - private readonly inviteCodeRepository: InviteCodeRepository, - @Inject(AnnouncementRepository) - private readonly announcementRepository: AnnouncementRepository + private transporter: Transporter ) {} - @Cron(WEEKLY_DIGEST_CRON, { utcOffset: 0 }) - public async weeklyDigestCron(): Promise { - const logger = new Logger("WeeklyDigestCron"); - - logger.log("Start weekly digest cron"); - const cutoff = getLatestCutOff(); - const cronLog = - await this.weeklyDigestCronLogRepository.createWeeklyDigestCronLog( - cutoff - ); - - try { - const users = await this.userRepository.findAllUsers(); - const results = []; - - for (const user of users) { - const recs = await this.getRecsForUser(user, cutoff); - if (recs.length === 0) { - results.push({ success: true, skip: true }); - continue; - } - - const result = await this.sendWeeklyDigest(user, recs, cutoff); - results.push(result); - - // avoid rate limit - await sleep(SLEEP_DURATION_MS); - } - - const successCount = results.filter( - (result) => result.success && !result.skip - ).length; - const errorUserIds = results - .filter((result) => !result.success) - .map((result) => result.userId) - .filter((userId) => userId !== undefined) as string[]; - - // log the successful result to DB - await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, { - status: CronStatus.SUCCESS, - result: { successCount, errorUserIds }, - }); - logger.log( - `Finish weekly digest cron: ${successCount} emails sent, ${errorUserIds.length} errors` - ); - } catch (error) { - logger.error(`Error in weekly digest cron: ${error}`); - - // log the failed result to DB - const errorMsg = error instanceof Error ? error.message : "Unknown error"; - await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, { - status: CronStatus.FAILURE, - errorMsg, - }); - } - } - - public async sendTestEmail(userId: string): Promise<{ success: boolean }> { - const user = await this.userRepository.findUserById(userId); - const cutoff = getLatestCutOff(); - const recs = await this.getRecsForUser(user, cutoff); - return this.sendWeeklyDigest(user, recs, cutoff); - } - - private async getRecsForUser(user: DbUser, cutoff: Date): Promise { - const followings = user.following.map( - (following: { followingId: string }) => following.followingId - ); - const filter: RecFilterBy = { - userIds: followings, - cutoff, - }; - - const dbRecs = await this.recRepository.findRecs( - 1, - MAX_REC_PER_MAIL, - filter - ); - return dbRecs.map((dbRec) => transformRec(dbRec)); - } - - private async sendWeeklyDigest( + public async sendWeeklyDigest( user: DbUser, - recs: Rec[], + content: WeeklyDigestContent, cutoff: Date ): Promise { - const recsGroupByTitle = groupBy(recs, (rec) => { + const recsGroupByTitle = groupBy(content.recs, (rec) => { const titleLowercase = rec.article.title.toLowerCase(); const words = titleLowercase.split(" ").filter((w) => w.length > 0); return words.join(""); }); - const numUnusedInviteCodes = - await this.inviteCodeRepository.countInviteCodes({ - used: false, - ownerId: user.id, - }); - const currentActivatedAnnouncements = - await this.announcementRepository.findAnnouncements(1, 1, { - activatedOnly: true, - currentOnly: true, - }); - const latestAnnouncement = - currentActivatedAnnouncements.length > 0 - ? transformAnnouncement(currentActivatedAnnouncements[0]) - : undefined; // send email const mailOptions = { @@ -165,8 +42,8 @@ export class EmailService { WeeklyDigest({ env: this.appConfig.nodeEnv, recsGroupByTitle, - numUnusedInviteCodes, - latestAnnouncement, + numUnusedInviteCodes: content.numUnusedInviteCodes, + latestAnnouncement: content.latestAnnouncement, }) ), }; diff --git a/apps/recnet-api/src/modules/email/transporters/email.dev.transporters.ts b/apps/recnet-api/src/modules/email/transporters/email.dev.transporters.ts index 40e686f9..e3522ed9 100644 --- a/apps/recnet-api/src/modules/email/transporters/email.dev.transporters.ts +++ b/apps/recnet-api/src/modules/email/transporters/email.dev.transporters.ts @@ -8,7 +8,7 @@ import { sleep } from "@recnet-api/utils"; import { RecnetError } from "@recnet-api/utils/error/recnet.error"; import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; -import { SLEEP_DURATION_MS } from "../email.const"; +import { RETRY_DURATION_MS } from "../email.const"; import { SendMailResult } from "../email.type"; const devHandleWhitelist = ["joannechen1223", "swh00tw"]; @@ -53,7 +53,7 @@ class EmailDevTransporter { ); // avoid rate limit - await sleep(SLEEP_DURATION_MS); + await sleep(RETRY_DURATION_MS); } } diff --git a/apps/recnet-api/src/modules/email/transporters/email.transporters.ts b/apps/recnet-api/src/modules/email/transporters/email.transporters.ts index 266ebeda..959908c4 100644 --- a/apps/recnet-api/src/modules/email/transporters/email.transporters.ts +++ b/apps/recnet-api/src/modules/email/transporters/email.transporters.ts @@ -8,7 +8,7 @@ import { sleep } from "@recnet-api/utils"; import { RecnetError } from "@recnet-api/utils/error/recnet.error"; import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; -import { RETRY_LIMIT, SLEEP_DURATION_MS } from "../email.const"; +import { RETRY_LIMIT, RETRY_DURATION_MS } from "../email.const"; import { SendMailResult } from "../email.type"; @Injectable() @@ -47,7 +47,7 @@ class EmailTransporter { ); // avoid rate limit - await sleep(SLEEP_DURATION_MS); + await sleep(RETRY_DURATION_MS); } } diff --git a/apps/recnet-api/src/modules/subscription/subscription.const.ts b/apps/recnet-api/src/modules/subscription/subscription.const.ts new file mode 100644 index 00000000..0aadec0b --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/subscription.const.ts @@ -0,0 +1,5 @@ +export const WEEKLY_DIGEST_CRON = "0 30 6 * * *"; + +export const MAX_REC_PER_DIGEST = 5; + +export const SLEEP_DURATION_MS = 1000; diff --git a/apps/recnet-api/src/modules/subscription/subscription.module.ts b/apps/recnet-api/src/modules/subscription/subscription.module.ts index 152ba7c1..6e00f6db 100644 --- a/apps/recnet-api/src/modules/subscription/subscription.module.ts +++ b/apps/recnet-api/src/modules/subscription/subscription.module.ts @@ -1,13 +1,15 @@ 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 { SlackService } from "./slack.service"; import { SubscriptionController } from "./subscription.controller"; +import { WeeklyDigestWorker } from "./weekly-digest.worker"; @Module({ controllers: [SubscriptionController], - providers: [SlackService], - imports: [DbRepositoryModule], + providers: [SlackService, WeeklyDigestWorker], + imports: [DbRepositoryModule, EmailModule], }) export class SubscriptionModule {} diff --git a/apps/recnet-api/src/modules/subscription/subscription.type.ts b/apps/recnet-api/src/modules/subscription/subscription.type.ts new file mode 100644 index 00000000..b312fb1b --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/subscription.type.ts @@ -0,0 +1,8 @@ +import { Announcement } from "@recnet-api/modules/announcement/entities/announcement.entity"; +import { Rec } from "@recnet-api/modules/rec/entities/rec.entity"; + +export type WeeklyDigestContent = { + recs: Rec[]; + numUnusedInviteCodes: number; + latestAnnouncement: Announcement | undefined; +}; diff --git a/apps/recnet-api/src/modules/subscription/weekly-digest.worker.ts b/apps/recnet-api/src/modules/subscription/weekly-digest.worker.ts new file mode 100644 index 00000000..69d22c30 --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/weekly-digest.worker.ts @@ -0,0 +1,152 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { Channel, CronStatus, SubscriptionType } from "@prisma/client"; + +import AnnouncementRepository from "@recnet-api/database/repository/announcement.repository"; +import InviteCodeRepository from "@recnet-api/database/repository/invite-code.repository"; +import RecRepository from "@recnet-api/database/repository/rec.repository"; +import { RecFilterBy } from "@recnet-api/database/repository/rec.repository.type"; +import UserRepository from "@recnet-api/database/repository/user.repository"; +import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; +import WeeklyDigestCronLogRepository from "@recnet-api/database/repository/weekly-digest-cron-log.repository"; +import { transformAnnouncement } from "@recnet-api/modules/announcement/announcement.transform"; +import { Announcement } from "@recnet-api/modules/announcement/entities/announcement.entity"; +import { EmailService } from "@recnet-api/modules/email/email.service"; +import { Rec } from "@recnet-api/modules/rec/entities/rec.entity"; +import { transformRec } from "@recnet-api/modules/rec/rec.transformer"; +import { sleep } from "@recnet-api/utils"; + +import { getLatestCutOff } from "@recnet/recnet-date-fns"; + +import { + MAX_REC_PER_DIGEST, + SLEEP_DURATION_MS, + WEEKLY_DIGEST_CRON, +} from "./subscription.const"; + +@Injectable() +export class WeeklyDigestWorker { + private readonly logger = new Logger(WeeklyDigestWorker.name); + + constructor( + @Inject(UserRepository) + private readonly userRepository: UserRepository, + @Inject(RecRepository) + private readonly recRepository: RecRepository, + @Inject(WeeklyDigestCronLogRepository) + private readonly weeklyDigestCronLogRepository: WeeklyDigestCronLogRepository, + @Inject(InviteCodeRepository) + private readonly inviteCodeRepository: InviteCodeRepository, + @Inject(AnnouncementRepository) + private readonly announcementRepository: AnnouncementRepository, + @Inject(EmailService) + private readonly emailService: EmailService + ) {} + + @Cron(WEEKLY_DIGEST_CRON, { utcOffset: 0 }) + public async weeklyDigestCron(): Promise { + this.logger.log("Start weekly digest cron"); + const cutoff = getLatestCutOff(); + const cronLog = + await this.weeklyDigestCronLogRepository.createWeeklyDigestCronLog( + cutoff + ); + + try { + // in-memory cache key: userId, value: weekly digest content + // const weeklyDigestContentCache = new Map(); + + const latestAnnouncement = await this.getLatestAnnouncement(); + + // send email + const emailResults = []; + const emailSubscribers = + await this.userRepository.findUsersBySubscription({ + type: SubscriptionType.WEEKLY_DIGEST, + channel: Channel.EMAIL, + }); + + for (const subscriber of emailSubscribers) { + const weeklyDigestContent = { + recs: await this.getRecsForUser(subscriber, cutoff), + numUnusedInviteCodes: + await this.inviteCodeRepository.countInviteCodes({ + used: false, + ownerId: subscriber.id, + }), + latestAnnouncement, + }; + + if (weeklyDigestContent.recs.length === 0) { + emailResults.push({ success: true, skip: true }); + continue; + } + + const result = await this.emailService.sendWeeklyDigest( + subscriber, + weeklyDigestContent, + cutoff + ); + emailResults.push(result); + + // avoid rate limit + await sleep(SLEEP_DURATION_MS); + } + + const successCount = emailResults.filter( + (result) => result.success && !result.skip + ).length; + const errorUserIds = emailResults + .filter((result) => !result.success) + .map((result) => result.userId) + .filter((userId) => userId !== undefined) as string[]; + + // log the successful result to DB + await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, { + status: CronStatus.SUCCESS, + result: { email: { successCount, errorUserIds } }, + }); + this.logger.log( + `Finish weekly digest cron: ${successCount} emails sent, ${errorUserIds.length} errors` + ); + } catch (error) { + this.logger.error(`Error in weekly digest cron: ${error}`); + + // log the failed result to DB + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, { + status: CronStatus.FAILURE, + errorMsg, + }); + } + } + + private async getRecsForUser(user: DbUser, cutoff: Date): Promise { + const followings = user.following.map( + (following: { followingId: string }) => following.followingId + ); + const filter: RecFilterBy = { + userIds: followings, + cutoff, + }; + + const dbRecs = await this.recRepository.findRecs( + 1, + MAX_REC_PER_DIGEST, + filter + ); + + return dbRecs.map((dbRec) => transformRec(dbRec)); + } + + private async getLatestAnnouncement(): Promise { + const currentActivatedAnnouncements = + await this.announcementRepository.findAnnouncements(1, 1, { + activatedOnly: true, + currentOnly: true, + }); + return currentActivatedAnnouncements.length > 0 + ? transformAnnouncement(currentActivatedAnnouncements[0]) + : undefined; + } +} From b74f4adc976b0920e8355cf55e54295f78bd5e11 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Wed, 6 Nov 2024 21:04:08 -0500 Subject: [PATCH 02/24] refactor: move component to dedicated folder and refactor interface --- apps/recnet/src/app/[handle]/Profile.tsx | 21 +++++++++++++++---- .../setting}/UserSettingDialog.tsx | 15 ++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) rename apps/recnet/src/{app/[handle] => components/setting}/UserSettingDialog.tsx (98%) diff --git a/apps/recnet/src/app/[handle]/Profile.tsx b/apps/recnet/src/app/[handle]/Profile.tsx index f33d8b93..185bbf51 100644 --- a/apps/recnet/src/app/[handle]/Profile.tsx +++ b/apps/recnet/src/app/[handle]/Profile.tsx @@ -11,11 +11,10 @@ import { Avatar } from "@recnet/recnet-web/components/Avatar"; import { FollowButton } from "@recnet/recnet-web/components/FollowButton"; import { RecNetLink } from "@recnet/recnet-web/components/Link"; import { Skeleton, SkeletonText } from "@recnet/recnet-web/components/Skeleton"; +import { UserSettingDialog } from "@recnet/recnet-web/components/setting/UserSettingDialog"; import { cn } from "@recnet/recnet-web/utils/cn"; import { interleaveWithValue } from "@recnet/recnet-web/utils/interleaveWithValue"; -import { UserSettingDialog } from "./UserSettingDialog"; - function StatDivider() { return
; } @@ -184,7 +183,14 @@ export function Profile(props: { handle: string }) { {isMe ? ( - + + Settings + + } + /> ) : ( )} @@ -196,7 +202,14 @@ export function Profile(props: { handle: string }) {
{userInfo}
{isMe ? ( - + + Settings + + } + /> ) : ( )} diff --git a/apps/recnet/src/app/[handle]/UserSettingDialog.tsx b/apps/recnet/src/components/setting/UserSettingDialog.tsx similarity index 98% rename from apps/recnet/src/app/[handle]/UserSettingDialog.tsx rename to apps/recnet/src/components/setting/UserSettingDialog.tsx index 06e00caf..5ac7782f 100644 --- a/apps/recnet/src/app/[handle]/UserSettingDialog.tsx +++ b/apps/recnet/src/components/setting/UserSettingDialog.tsx @@ -378,8 +378,13 @@ const tabs = { } as const; type TabKey = keyof typeof tabs; -export function UserSettingDialog(props: { handle: string }) { - const { handle } = props; +interface UserSettingDialogProps { + handle: string; + trigger: React.ReactNode; +} + +export function UserSettingDialog(props: UserSettingDialogProps) { + const { handle, trigger } = props; const utils = trpc.useUtils(); const router = useRouter(); const [open, setOpen] = useState(false); @@ -415,11 +420,7 @@ export function UserSettingDialog(props: { handle: string }) { return ( - - - + {trigger} Date: Wed, 6 Nov 2024 21:06:21 -0500 Subject: [PATCH 03/24] chore: remove redundant button --- apps/recnet/src/components/setting/UserSettingDialog.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/recnet/src/components/setting/UserSettingDialog.tsx b/apps/recnet/src/components/setting/UserSettingDialog.tsx index 5ac7782f..1e02d03b 100644 --- a/apps/recnet/src/components/setting/UserSettingDialog.tsx +++ b/apps/recnet/src/components/setting/UserSettingDialog.tsx @@ -301,11 +301,6 @@ function EditProfileForm(props: TabProps) { - - - - -
-
- {Object.entries(tabs).map(([key, { label }]) => ( -
setActiveTab(key as TabKey)} - > - {tabs[key as TabKey].icon} - + + {trigger} + + + + +
+
+ {Object.entries(tabs).map(([key, { label }]) => ( +
setActiveTab(key as TabKey)} > - {label} - -
- ))} -
-
- + {tabs[key as TabKey].icon} + + {label} + +
+ ))} +
+
+ +
-
- - + + + ); } From 9e6d207de82bca10693336924b03d70068614bb2 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Wed, 6 Nov 2024 21:38:19 -0500 Subject: [PATCH 05/24] refactor: move ProfileEditForm to dedicated file --- .../components/setting/UserSettingDialog.tsx | 314 +---------------- .../setting/profile/ProfileEditForm.tsx | 319 ++++++++++++++++++ 2 files changed, 322 insertions(+), 311 deletions(-) create mode 100644 apps/recnet/src/components/setting/profile/ProfileEditForm.tsx diff --git a/apps/recnet/src/components/setting/UserSettingDialog.tsx b/apps/recnet/src/components/setting/UserSettingDialog.tsx index ea666f63..884bb4c1 100644 --- a/apps/recnet/src/components/setting/UserSettingDialog.tsx +++ b/apps/recnet/src/components/setting/UserSettingDialog.tsx @@ -1,325 +1,17 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; import { PersonIcon, Cross1Icon } from "@radix-ui/react-icons"; -import { - Dialog, - Button, - Flex, - Text, - TextField, - TextArea, -} from "@radix-ui/themes"; -import { TRPCClientError } from "@trpc/client"; +import { Dialog, Button, Text } from "@radix-ui/themes"; import { Settings } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { useMemo, useState, createContext, useContext } from "react"; -import { useForm, useFormState } from "react-hook-form"; -import { toast } from "sonner"; -import * as z from "zod"; -import { useAuth } from "@recnet/recnet-web/app/AuthContext"; 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 { ErrorMessages } from "@recnet/recnet-web/constant"; import { logout } from "@recnet/recnet-web/firebase/auth"; import { cn } from "@recnet/recnet-web/utils/cn"; -const HandleBlacklist = [ - "about", - "api", - "all-users", - "feeds", - "help", - "onboard", - "search", - "user", -]; - -const EditUserProfileSchema = z.object({ - displayName: z.string().min(1, "Name cannot be blank."), - handle: z - .string() - .min(4) - .max(15) - .regex( - /^[A-Za-z0-9_]+$/, - "User handle should be between 4 to 15 characters and contain only letters (A-Z, a-z), numbers, and underscores (_)." - ) - .refine( - (name) => { - return !HandleBlacklist.includes(name); - }, - { - message: "User handle is not allowed.", - } - ), - affiliation: z - .string() - .max(64, "Affiliation must contain at most 64 character(s)") - .nullable(), - bio: z - .string() - .max(200, "Bio must contain at most 200 character(s)") - .nullable(), - url: z.string().url().nullable(), - googleScholarLink: z.string().url().nullable(), - semanticScholarLink: z.string().url().nullable(), - openReviewUserName: z.string().nullable(), -}); - -function EditProfileForm() { - const utils = trpc.useUtils(); - const router = useRouter(); - const { setOpen, userHandle } = useUserSettingDialogContext(); - const { user, revalidateUser } = useAuth(); - const oldHandle = user?.handle; - - const { register, handleSubmit, formState, setError, control, watch } = - useForm({ - resolver: zodResolver(EditUserProfileSchema), - defaultValues: { - displayName: user?.displayName ?? null, - handle: user?.handle ?? null, - affiliation: user?.affiliation ?? null, - bio: user?.bio ?? null, - url: user?.url ?? null, - googleScholarLink: user?.googleScholarLink ?? null, - semanticScholarLink: user?.semanticScholarLink ?? null, - openReviewUserName: user?.openReviewUserName ?? null, - }, - mode: "onTouched", - }); - const { isDirty } = useFormState({ control: control }); - - const updateProfileMutation = trpc.updateUser.useMutation(); - - return ( -
{ - e?.preventDefault(); - const res = EditUserProfileSchema.safeParse(data); - if (!res.success || !user?.id) { - // should not happen, just in case and for typescript to narrow down type - console.error("Invalid form data."); - return; - } - // if no changes, close dialog - if (!isDirty) { - setOpen(false); - return; - } - try { - const updatedData = await updateProfileMutation.mutateAsync( - { - ...res.data, - }, - { - onError: (error) => { - if ( - error instanceof TRPCClientError && - error.data.code === "CONFLICT" && - error.message === ErrorMessages.USER_HANDLE_USED - ) { - setError("handle", { - type: "manual", - message: "User handle already exists.", - }); - } - }, - } - ); - toast.success("Profile updated successfully!"); - // revaildate user profile - revalidateUser(); - // revalidate cache for user profile or redirect to new user profile if handle changed - const updatedUser = updatedData.user; - if (updatedUser.handle !== oldHandle) { - // if user change user handle, redirect to new user profile - router.replace(`/${updatedUser.handle}`); - } else { - utils.getUserByHandle.invalidate({ handle: userHandle }); - setOpen(false); - } - } catch (error) { - console.log(error); - } - })} - > - Edit profile - - Make changes to your profile. - - - - - - - - - - -