diff --git a/apps/recnet-api/package.json b/apps/recnet-api/package.json index 7dab79c2..7aa23d46 100644 --- a/apps/recnet-api/package.json +++ b/apps/recnet-api/package.json @@ -1,4 +1,4 @@ { "name": "recnet-api", - "version": "1.8.1" + "version": "1.8.2" } diff --git a/apps/recnet-api/scripts/userSubscriptionMigrate.sh b/apps/recnet-api/scripts/userSubscriptionMigrate.sh new file mode 100644 index 00000000..1c25625b --- /dev/null +++ b/apps/recnet-api/scripts/userSubscriptionMigrate.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Database connection details +DB_NAME="" +DB_USER="" +DB_HOST="" +DB_PORT="" +DB_PASSWORD="" + +export PGPASSWORD=$DB_PASSWORD + +# Query to select all user IDs and insert into Subscription table +psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -p "$DB_PORT" -t -c "SELECT id FROM recnet.\"User\";" | while read -r userId; do + if [[ -n "$userId" ]]; then + # Insert a new subscription for each user ID + psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -p "$DB_PORT" -c \ + "INSERT INTO recnet.\"Subscription\" (\"userId\", \"type\", \"channel\") VALUES ('$userId', 'WEEKLY_DIGEST', 'EMAIL') ON CONFLICT DO NOTHING;" + fi +done diff --git a/apps/recnet-api/src/database/repository/user.repository.ts b/apps/recnet-api/src/database/repository/user.repository.ts index d9aa694e..1811137c 100644 --- a/apps/recnet-api/src/database/repository/user.repository.ts +++ b/apps/recnet-api/src/database/repository/user.repository.ts @@ -1,5 +1,11 @@ import { Injectable } from "@nestjs/common"; -import { Prisma, Provider } from "@prisma/client"; +import { + Channel, + Prisma, + Provider, + Subscription, + SubscriptionType, +} from "@prisma/client"; import PrismaConnectionProvider from "@recnet-api/database/prisma/prisma.connection.provider"; import { getOffset } from "@recnet-api/utils"; @@ -130,6 +136,44 @@ export default class UserRepository { return user.isActivated; } + public async createOrUpdateSubscription( + userId: string, + type: SubscriptionType, + channels: Channel[] + ): Promise { + // delete if not in list + return this.prisma.$transaction(async (prisma) => { + await prisma.subscription.deleteMany({ + where: { + userId, + type, + channel: { notIn: channels }, + }, + }); + + // create new channels + const subscriptions = await this.prisma.subscription.findMany({ + where: { userId, type }, + }); + const currentChannels = subscriptions.map((s) => s.channel); + const newChannels = channels.filter((c) => !currentChannels.includes(c)); + await prisma.subscription.createMany({ + data: newChannels.map((channel) => ({ + userId, + type, + channel, + })), + }); + + return prisma.subscription.findMany({ + where: { + userId, + type, + }, + }); + }); + } + 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 1e9a65ce..a5ef755c 100644 --- a/apps/recnet-api/src/database/repository/user.repository.type.ts +++ b/apps/recnet-api/src/database/repository/user.repository.type.ts @@ -49,10 +49,12 @@ export const user = Prisma.validator()({ }, }, recommendations: true, + subscriptions: true, }, }); export type User = Prisma.UserGetPayload; +export type Subscriptions = Prisma.UserGetPayload["subscriptions"]; export type UserFilterBy = { handle?: 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..da37d2f9 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,10 @@ export type WeeklyDigestCronResult = { - successCount: number; - errorUserIds: string[]; + email: { + successCount: number; + errorUserIds: string[]; + }; + slack: { + 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..c328e89e 100644 --- a/apps/recnet-api/src/modules/email/email.const.ts +++ b/apps/recnet-api/src/modules/email/email.const.ts @@ -1,10 +1,5 @@ -export const MAX_REC_PER_MAIL = 5; +export const EMAIL_RETRY_DURATION_MS = 1000; -export const WEEKLY_DIGEST_CRON = "0 0 0 * * 3"; +export const EMAIL_RETRY_LIMIT = 3; -export const RETRY_LIMIT = 3; - -export const SLEEP_DURATION_MS = 1000; - -// providers -export const MAIL_TRANSPORTER = "MAIL_TRANSPORTER"; +export const EMAIL_DEV_HANDLE_WHITELIST = ["joannechen1223", "swh00tw"]; 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..75660643 100644 --- a/apps/recnet-api/src/modules/email/email.module.ts +++ b/apps/recnet-api/src/modules/email/email.module.ts @@ -1,34 +1,13 @@ import { Module } from "@nestjs/common"; -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"; -import EmailTransporter from "./transporters/email.transporters"; - -const transporterFactory = (configService: ConfigService): Transporter => { - const nodeEnv = configService.get("app").nodeEnv; - const nodemailerConfig = configService.get("nodemailer"); - - return nodeEnv === "production" - ? new EmailTransporter(nodemailerConfig) - : new EmailDevTransporter(nodemailerConfig); -}; +import EmailTransporter from "./transporters/email.transporter"; @Module({ - controllers: [EmailController], - providers: [ - EmailService, - { - provide: MAIL_TRANSPORTER, - useFactory: transporterFactory, - inject: [ConfigService], - }, - ], + providers: [EmailService, EmailTransporter], 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..059dabc6 100644 --- a/apps/recnet-api/src/modules/email/email.service.ts +++ b/apps/recnet-api/src/modules/email/email.service.ts @@ -1,33 +1,17 @@ -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 { getLatestCutOff } from "@recnet/recnet-date-fns"; - import { - MAIL_TRANSPORTER, - MAX_REC_PER_MAIL, - SLEEP_DURATION_MS, - WEEKLY_DIGEST_CRON, -} from "./email.const"; -import { SendMailResult, Transporter } from "./email.type"; + SendResult, + WeeklyDigestContent, +} from "@recnet-api/modules/subscription/subscription.type"; + import WeeklyDigest, { WeeklyDigestSubject } from "./templates/WeeklyDigest"; +import EmailTransporter from "./transporters/email.transporter"; @Injectable() export class EmailService { @@ -36,125 +20,19 @@ export class EmailService { private readonly appConfig: ConfigType, @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: EmailTransporter ) {} - @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) => { + ): Promise { + 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 +43,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/email.type.ts b/apps/recnet-api/src/modules/email/email.type.ts deleted file mode 100644 index 9a7e203a..00000000 --- a/apps/recnet-api/src/modules/email/email.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -import EmailDevTransporter from "./transporters/email.dev.transporters"; -import EmailTransporter from "./transporters/email.transporters"; - -export type Transporter = EmailTransporter | EmailDevTransporter; -export type SendMailResult = { - success: boolean; - skip?: boolean; - userId?: string; -}; 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 deleted file mode 100644 index 40e686f9..00000000 --- a/apps/recnet-api/src/modules/email/transporters/email.dev.transporters.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; -import { ConfigType } from "@nestjs/config"; -import { createTransport, Transporter, SendMailOptions } from "nodemailer"; - -import { NodemailerConfig } from "@recnet-api/config/common.config"; -import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; -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 { SendMailResult } from "../email.type"; - -const devHandleWhitelist = ["joannechen1223", "swh00tw"]; - -@Injectable() -class EmailDevTransporter { - private logger: Logger = new Logger(EmailDevTransporter.name); - private transporter: Transporter; - - constructor( - @Inject(NodemailerConfig.KEY) - private readonly nodemailerConfig: ConfigType - ) { - const { service, host, port, secure, user, pass } = nodemailerConfig; - this.transporter = createTransport({ - service, - host, - port, - secure, - auth: { user, pass }, - }); - } - - public async sendMail( - user: DbUser, - mailOptions: SendMailOptions - ): Promise { - // hardcode the recipient to be joannechen1223 and swh00tw in dev environment - if (!devHandleWhitelist.includes(user.handle)) { - return { success: true, skip: true }; - } - - let retryCount = 0; - while (retryCount < 3) { - try { - await this.transporter.sendMail(mailOptions); - return { success: true }; - } catch (error) { - retryCount++; - this.logger.error( - `[Attempt ${retryCount}] Failed to send email ${user.id}: ${error}` - ); - - // avoid rate limit - await sleep(SLEEP_DURATION_MS); - } - } - - // throw error if failed after retry limit - throw new RecnetError( - ErrorCode.EMAIL_SEND_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR, - `Failed to send email ${mailOptions.to}` - ); - } -} - -export default EmailDevTransporter; diff --git a/apps/recnet-api/src/modules/email/transporters/email.transporters.ts b/apps/recnet-api/src/modules/email/transporters/email.transporter.ts similarity index 68% rename from apps/recnet-api/src/modules/email/transporters/email.transporters.ts rename to apps/recnet-api/src/modules/email/transporters/email.transporter.ts index 266ebeda..815bb5ca 100644 --- a/apps/recnet-api/src/modules/email/transporters/email.transporters.ts +++ b/apps/recnet-api/src/modules/email/transporters/email.transporter.ts @@ -2,14 +2,18 @@ import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { createTransport, Transporter, SendMailOptions } from "nodemailer"; -import { NodemailerConfig } from "@recnet-api/config/common.config"; +import { AppConfig, NodemailerConfig } from "@recnet-api/config/common.config"; import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; +import { SendResult } from "@recnet-api/modules/subscription/subscription.type"; 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 { SendMailResult } from "../email.type"; +import { + EMAIL_RETRY_LIMIT, + EMAIL_RETRY_DURATION_MS, + EMAIL_DEV_HANDLE_WHITELIST, +} from "../email.const"; @Injectable() class EmailTransporter { @@ -18,7 +22,9 @@ class EmailTransporter { constructor( @Inject(NodemailerConfig.KEY) - private readonly nodemailerConfig: ConfigType + private readonly nodemailerConfig: ConfigType, + @Inject(AppConfig.KEY) + private readonly appConfig: ConfigType ) { const { service, host, port, secure, user, pass } = nodemailerConfig; this.transporter = createTransport({ @@ -33,10 +39,18 @@ class EmailTransporter { public async sendMail( user: DbUser, mailOptions: SendMailOptions - ): Promise { + ): Promise { + if ( + this.appConfig.nodeEnv !== "production" && + !EMAIL_DEV_HANDLE_WHITELIST.includes(user.handle) + ) { + // hardcode the recipient whitelist in dev environment + return { success: true, skip: true }; + } + let retryCount = 0; - while (retryCount < RETRY_LIMIT) { + while (retryCount < EMAIL_RETRY_LIMIT) { try { await this.transporter.sendMail(mailOptions); return { success: true }; @@ -47,7 +61,7 @@ class EmailTransporter { ); // avoid rate limit - await sleep(SLEEP_DURATION_MS); + await sleep(EMAIL_RETRY_DURATION_MS); } } diff --git a/apps/recnet-api/src/modules/slack/slack.const.ts b/apps/recnet-api/src/modules/slack/slack.const.ts new file mode 100644 index 00000000..ad4e437b --- /dev/null +++ b/apps/recnet-api/src/modules/slack/slack.const.ts @@ -0,0 +1,5 @@ +export const SLACK_RETRY_DURATION_MS = 1000; + +export const SLACK_RETRY_LIMIT = 3; + +export const SLACK_DEV_HANDLE_WHITELIST = ["joannechen1223", "frankt"]; diff --git a/apps/recnet-api/src/modules/slack/slack.module.ts b/apps/recnet-api/src/modules/slack/slack.module.ts new file mode 100644 index 00000000..0c6110a2 --- /dev/null +++ b/apps/recnet-api/src/modules/slack/slack.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; + +import { DbRepositoryModule } from "@recnet-api/database/repository/db.repository.module"; + +import { SlackService } from "./slack.service"; +import { SlackTransporter } from "./transporters/slack.transporter"; + +@Module({ + providers: [SlackService, SlackTransporter], + imports: [DbRepositoryModule], + exports: [SlackService], +}) +export class SlackModule {} diff --git a/apps/recnet-api/src/modules/slack/slack.service.ts b/apps/recnet-api/src/modules/slack/slack.service.ts new file mode 100644 index 00000000..3a5913d6 --- /dev/null +++ b/apps/recnet-api/src/modules/slack/slack.service.ts @@ -0,0 +1,39 @@ +import { 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 { SendSlackResult } from "./slack.type"; +import { weeklyDigestSlackTemplate } from "./templates/weekly-digest.template"; +import { SlackTransporter } from "./transporters/slack.transporter"; + +@Injectable() +export class SlackService { + constructor( + @Inject(AppConfig.KEY) + private readonly appConfig: ConfigType, + private readonly transporter: SlackTransporter + ) {} + + public async sendWeeklyDigest( + user: DbUser, + content: WeeklyDigestContent, + cutoff: Date + ): Promise { + let result; + try { + const slackMessage = weeklyDigestSlackTemplate( + cutoff, + content, + this.appConfig.nodeEnv + ); + result = await this.transporter.sendDirectMessage(user, slackMessage); + } catch (e) { + return { success: false, userId: user.id }; + } + + return result; + } +} diff --git a/apps/recnet-api/src/modules/slack/slack.type.ts b/apps/recnet-api/src/modules/slack/slack.type.ts new file mode 100644 index 00000000..e2224a0e --- /dev/null +++ b/apps/recnet-api/src/modules/slack/slack.type.ts @@ -0,0 +1,5 @@ +export type SendSlackResult = { + success: boolean; + skip?: boolean; + userId?: 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 new file mode 100644 index 00000000..67d0240f --- /dev/null +++ b/apps/recnet-api/src/modules/slack/templates/weekly-digest.template.ts @@ -0,0 +1,19 @@ +import { WeeklyDigestContent } from "@recnet-api/modules/subscription/subscription.type"; + +import { formatDate } from "@recnet/recnet-date-fns"; + +export const weeklyDigestSlackTemplate = ( + cutoff: Date, + content: WeeklyDigestContent, + nodeEnv: string +): string => { + const subject = `${nodeEnv !== "production" && "[DEV] "}📬 Your Weekly Digest for ${formatDate(cutoff)}`; + const unusedInviteCodes = `You have ${content.numUnusedInviteCodes} unused invite codes! Share the love ❤️`; + const latestAnnouncement = content.latestAnnouncement + ? `📢 ${content.latestAnnouncement.title} \n ${content.latestAnnouncement.content}` + : ""; + const recsUrls = content.recs.map( + (rec) => `[${rec.article.title}](https://recnet.io/rec/${rec.id})` + ); + return `${subject}\nYou have ${content.recs.length} recommendations this week!\nCheck out these rec'd paper for you from your network!\n${unusedInviteCodes}\n${latestAnnouncement}\n${recsUrls.join("\n")} \n\nAny interesting read this week? 👀\nShare with your network: https://recnet.io/`; +}; diff --git a/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts new file mode 100644 index 00000000..95af04f5 --- /dev/null +++ b/apps/recnet-api/src/modules/slack/transporters/slack.transporter.ts @@ -0,0 +1,106 @@ +import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { WebClient } from "@slack/web-api"; + +import { AppConfig, SlackConfig } from "@recnet-api/config/common.config"; +import { User as DbUser } from "@recnet-api/database/repository/user.repository.type"; +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 { + SLACK_DEV_HANDLE_WHITELIST, + SLACK_RETRY_DURATION_MS, + SLACK_RETRY_LIMIT, +} from "../slack.const"; +import { SendSlackResult } from "../slack.type"; + +@Injectable() +export class SlackTransporter { + private logger: Logger = new Logger(SlackTransporter.name); + private readonly client: WebClient; + + constructor( + @Inject(SlackConfig.KEY) + private readonly slackConfig: ConfigType, + @Inject(AppConfig.KEY) + private readonly appConfig: ConfigType + ) { + this.client = new WebClient(this.slackConfig.token); + } + + public async sendDirectMessage( + user: DbUser, + message: string + ): Promise { + if ( + this.appConfig.nodeEnv !== "production" && + !SLACK_DEV_HANDLE_WHITELIST.includes(user.handle) + ) { + // hardcode the recipient whitelist in dev environment + return { success: true, skip: true }; + } + + let retryCount = 0; + while (retryCount < SLACK_RETRY_LIMIT) { + try { + const slackId = await this.getUserSlackId(user); + await this.postDirectMessage(slackId, message); + return { success: true }; + } catch (error) { + retryCount++; + this.logger.error( + `[Attempt ${retryCount}] Failed to send email ${user.id}: ${error}` + ); + + // avoid rate limit + await sleep(SLACK_RETRY_DURATION_MS); + } + } + + // throw error if failed after retry limit + throw new RecnetError( + ErrorCode.SLACK_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR, + `Failed to send slack ${user.id}` + ); + } + + private async getUserSlackId(user: DbUser): Promise { + const email = user.slackEmail || user.email; + const userResp = await this.client.users.lookupByEmail({ email }); + const slackId = userResp?.user?.id; + if (!slackId) { + throw new RecnetError( + ErrorCode.SLACK_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR, + `Failed to get Slack ID` + ); + } + return slackId; + } + + private async postDirectMessage( + userSlackId: string, + message: string + ): Promise { + // Open a direct message conversation + const conversationResp = await this.client.conversations.open({ + users: userSlackId, + }); + const conversationId = conversationResp?.channel?.id; + if (!conversationId) { + throw new RecnetError( + ErrorCode.SLACK_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR, + `Failed to open conversation` + ); + } + + // Send the message + await this.client.chat.postMessage({ + channel: conversationId, + text: message, + }); + } +} diff --git a/apps/recnet-api/src/modules/subscription/slack.service.ts b/apps/recnet-api/src/modules/subscription/slack.service.ts deleted file mode 100644 index 516a2ef9..00000000 --- a/apps/recnet-api/src/modules/subscription/slack.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { HttpStatus, Inject, Injectable } from "@nestjs/common"; -import { ConfigType } from "@nestjs/config"; -import { WebClient } from "@slack/web-api"; - -import { SlackConfig } 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"; - -@Injectable() -export class SlackService { - private readonly client: WebClient; - - constructor( - @Inject(SlackConfig.KEY) - private readonly slackConfig: ConfigType, - private readonly userRepository: UserRepository - ) { - this.client = new WebClient(this.slackConfig.token); - } - - public async sendDirectMessage( - userId: string, - message: string - ): Promise { - const user = await this.userRepository.findUserById(userId); - const email = user.slackEmail || user.email; - - // Get the user's Slack ID - const userResp = await this.client.users.lookupByEmail({ email }); - const slackId = userResp?.user?.id; - if (!slackId) { - throw new RecnetError( - ErrorCode.SLACK_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR, - `Failed to get Slack ID` - ); - } - - // Open a direct message conversation - const conversationResp = await this.client.conversations.open({ - users: slackId, - }); - const conversationId = conversationResp?.channel?.id; - if (!conversationId) { - throw new RecnetError( - ErrorCode.SLACK_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR, - `Failed to open conversation` - ); - } - - // Send the message - await this.client.chat.postMessage({ - channel: conversationId, - text: message, - }); - } -} 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..34de170f --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/subscription.const.ts @@ -0,0 +1,5 @@ +export const WEEKLY_DIGEST_CRON = "0 0 0 * * 3"; + +export const MAX_REC_PER_DIGEST = 5; + +export const SLEEP_DURATION_MS = 1000; diff --git a/apps/recnet-api/src/modules/subscription/subscription.controller.ts b/apps/recnet-api/src/modules/subscription/subscription.controller.ts deleted file mode 100644 index 9cf092e1..00000000 --- a/apps/recnet-api/src/modules/subscription/subscription.controller.ts +++ /dev/null @@ -1,52 +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 { SlackService } from "./slack.service"; - -@ApiTags("subscriptions") -@Controller("subscriptions") -export class SubscriptionController { - constructor( - @Inject(AppConfig.KEY) - private readonly appConfig: ConfigType, - private readonly slackService: SlackService - ) {} - - /* 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" - ); - } - return this.slackService.sendDirectMessage(userId, "Test message"); - } -} diff --git a/apps/recnet-api/src/modules/subscription/subscription.module.ts b/apps/recnet-api/src/modules/subscription/subscription.module.ts index 152ba7c1..b94a1323 100644 --- a/apps/recnet-api/src/modules/subscription/subscription.module.ts +++ b/apps/recnet-api/src/modules/subscription/subscription.module.ts @@ -1,13 +1,14 @@ 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"; + +import { SlackModule } from "../slack/slack.module"; @Module({ - controllers: [SubscriptionController], - providers: [SlackService], - imports: [DbRepositoryModule], + providers: [WeeklyDigestWorker], + imports: [DbRepositoryModule, EmailModule, SlackModule], }) 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..a39a67f0 --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/subscription.type.ts @@ -0,0 +1,14 @@ +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; +}; + +export type SendResult = { + success: boolean; + skip?: boolean; + userId?: string; +}; 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..b3da36bd --- /dev/null +++ b/apps/recnet-api/src/modules/subscription/weekly-digest.worker.ts @@ -0,0 +1,188 @@ +import { 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 { SlackService } from "@recnet-api/modules/slack/slack.service"; +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"; +import { SendResult, WeeklyDigestContent } from "./subscription.type"; + +@Injectable() +export class WeeklyDigestWorker { + private readonly logger = new Logger(WeeklyDigestWorker.name); + + constructor( + private readonly userRepository: UserRepository, + private readonly recRepository: RecRepository, + private readonly weeklyDigestCronLogRepository: WeeklyDigestCronLogRepository, + private readonly inviteCodeRepository: InviteCodeRepository, + private readonly announcementRepository: AnnouncementRepository, + private readonly emailService: EmailService, + private readonly slackService: SlackService + ) {} + + @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 { + const latestAnnouncement = await this.getLatestAnnouncement(); + const allUsers = await this.userRepository.findAllUsers(); + + const emailResults: SendResult[] = []; + const slackResults: SendResult[] = []; + + for (const user of allUsers) { + const weeklyDigestContent = await this.getWeeklyDigestContent( + user, + cutoff, + latestAnnouncement + ); + + if (weeklyDigestContent.recs.length === 0) { + continue; + } + + // send email + const isEmailSubscribed = user.subscriptions.some( + (sub) => + sub.type === SubscriptionType.WEEKLY_DIGEST && + sub.channel === Channel.EMAIL + ); + if (isEmailSubscribed) { + const result = await this.emailService.sendWeeklyDigest( + user, + weeklyDigestContent, + cutoff + ); + emailResults.push(result); + } + + // send slack + const isSlackSubscribed = user.subscriptions.some( + (sub) => + sub.type === SubscriptionType.WEEKLY_DIGEST && + sub.channel === Channel.SLACK + ); + if (isSlackSubscribed) { + const result = await this.slackService.sendWeeklyDigest( + user, + weeklyDigestContent, + cutoff + ); + slackResults.push(result); + } + + // avoid rate limit + await sleep(SLEEP_DURATION_MS); + } + + const emailSummarizedResult = this.summarizeSendResults(emailResults); + const slackSummarizedResult = this.summarizeSendResults(slackResults); + + this.logger.log( + `Weekly digest cron completed: ${emailSummarizedResult.successCount} emails sent, ${emailSummarizedResult.errorUserIds.length} errors. ${slackSummarizedResult.successCount} slack messages sent, ${slackSummarizedResult.errorUserIds.length} errors.` + ); + + // log the successful result to DB + await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, { + status: CronStatus.SUCCESS, + result: { + email: emailSummarizedResult, + slack: slackSummarizedResult, + }, + }); + } 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 getWeeklyDigestContent( + user: DbUser, + cutoff: Date, + latestAnnouncement: Announcement | undefined + ): Promise { + return { + recs: await this.getRecsForUser(user, cutoff), + numUnusedInviteCodes: await this.inviteCodeRepository.countInviteCodes({ + used: false, + ownerId: user.id, + }), + latestAnnouncement, + }; + } + + 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; + } + + private summarizeSendResults(results: SendResult[]): { + successCount: number; + errorUserIds: string[]; + } { + 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[]; + return { successCount, errorUserIds }; + } +} diff --git a/apps/recnet-api/src/modules/user/entities/user.subscription.entity.ts b/apps/recnet-api/src/modules/user/entities/user.subscription.entity.ts new file mode 100644 index 00000000..b23eaf09 --- /dev/null +++ b/apps/recnet-api/src/modules/user/entities/user.subscription.entity.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Channel, SubscriptionType } from "@prisma/client"; + +export class Subscription { + @ApiProperty() + type: SubscriptionType; + + @ApiProperty() + channels: Channel[]; +} diff --git a/apps/recnet-api/src/modules/user/user.controller.ts b/apps/recnet-api/src/modules/user/user.controller.ts index 7dace9be..3be22e52 100644 --- a/apps/recnet-api/src/modules/user/user.controller.ts +++ b/apps/recnet-api/src/modules/user/user.controller.ts @@ -35,6 +35,7 @@ import { patchUserMeRequestSchema, postUserFollowRequestSchema, postUserMeRequestSchema, + postUsersSubscriptionsRequestSchema, postUserValidateHandleRequestSchema, postUserValidateInviteCodeRequestSchema, } from "@recnet/recnet-api-model"; @@ -47,7 +48,13 @@ import { ValidateUserHandleDto, ValidateUserInviteCodeDto, } from "./dto/validate.user.dto"; -import { GetUserMeResponse, GetUsersResponse } from "./user.response"; +import { Subscription } from "./entities/user.subscription.entity"; +import { + GetSubscriptionsResponse, + GetUserMeResponse, + GetUsersResponse, + PostSubscriptionsResponse, +} from "./user.response"; import { UserService } from "./user.service"; @ApiTags("users") @@ -220,4 +227,35 @@ export class UserController { const { userId } = authUser; return this.userService.unfollowUser(userId, dto.userId); } + + @ApiOperation({ + summary: "Get subscriptions", + description: "Get the current user's subscriptions.", + }) + @Get("subscriptions") + @ApiBearerAuth() + @Auth() + public async getSubscriptions( + @User() authUser: AuthUser + ): Promise { + const { userId } = authUser; + return this.userService.getSubscriptions(userId); + } + + @ApiOperation({ + summary: "Create or update subscription", + description: "Create or update the current user's subscription.", + }) + @Post("subscriptions") + @ApiBearerAuth() + @UsePipes(new ZodValidationBodyPipe(postUsersSubscriptionsRequestSchema)) + @Auth() + public async createOrUpdateSubscription( + @User() authUser: AuthUser, + @Body() dto: Subscription + ): Promise { + const { userId } = authUser; + const { type, channels } = dto; + return this.userService.createOrUpdateSubscription(userId, type, channels); + } } diff --git a/apps/recnet-api/src/modules/user/user.response.ts b/apps/recnet-api/src/modules/user/user.response.ts index 112cbcd8..627499d7 100644 --- a/apps/recnet-api/src/modules/user/user.response.ts +++ b/apps/recnet-api/src/modules/user/user.response.ts @@ -2,6 +2,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { User } from "./entities/user.entity"; import { UserPreview } from "./entities/user.preview.entity"; +import { Subscription } from "./entities/user.subscription.entity"; export class GetUsersResponse { @ApiProperty() @@ -15,3 +16,13 @@ export class GetUserMeResponse { @ApiProperty() user: User; } + +export class GetSubscriptionsResponse { + @ApiProperty() + subscriptions: Subscription[]; +} + +export class PostSubscriptionsResponse { + @ApiProperty() + subscription: Subscription; +} diff --git a/apps/recnet-api/src/modules/user/user.service.ts b/apps/recnet-api/src/modules/user/user.service.ts index 40363cf3..81f0a34e 100644 --- a/apps/recnet-api/src/modules/user/user.service.ts +++ b/apps/recnet-api/src/modules/user/user.service.ts @@ -1,4 +1,5 @@ import { HttpStatus, Inject, Injectable } from "@nestjs/common"; +import { Channel, SubscriptionType } from "@prisma/client"; import FollowingRecordRepository from "@recnet-api/database/repository/followingRecord.repository"; import InviteCodeRepository from "@recnet-api/database/repository/invite-code.repository"; @@ -7,6 +8,7 @@ import { CreateUserInput, User as DbUser, UserPreview as DbUserPreview, + Subscriptions as DbSubscriptions, UpdateUserInput, } from "@recnet-api/database/repository/user.repository.type"; import { UserFilterBy } from "@recnet-api/database/repository/user.repository.type"; @@ -20,7 +22,12 @@ import { CreateUserDto } from "./dto/create.user.dto"; import { UpdateUserDto } from "./dto/update.user.dto"; import { User } from "./entities/user.entity"; import { UserPreview } from "./entities/user.preview.entity"; -import { GetUsersResponse } from "./user.response"; +import { Subscription } from "./entities/user.subscription.entity"; +import { + GetSubscriptionsResponse, + GetUsersResponse, + PostSubscriptionsResponse, +} from "./user.response"; import { transformUserPreview } from "./user.transformer"; @Injectable() @@ -164,6 +171,41 @@ export class UserService { ); } + public async getSubscriptions( + userId: string + ): Promise { + const user = await this.userRepository.findUserById(userId); + return { + subscriptions: this.transformSubscriptions(user.subscriptions), + }; + } + + public async createOrUpdateSubscription( + userId: string, + type: SubscriptionType, + channels: Channel[] + ): Promise { + if (type === SubscriptionType.WEEKLY_DIGEST && channels.length === 0) { + throw new RecnetError( + ErrorCode.INVALID_SUBSCRIPTION, + HttpStatus.BAD_REQUEST, + "Weekly digest subscription must have at least one channel." + ); + } + + const subscriptions = await this.userRepository.createOrUpdateSubscription( + userId, + type, + channels + ); + return { + subscription: { + type, + channels: subscriptions.map((s) => s.channel), + }, + }; + } + private async transformUser(user: DbUser): Promise { const followingUserIds: string[] = user.following.map( (followingUser) => followingUser.followingId @@ -192,4 +234,25 @@ export class UserService { following: followingUsers, }; } + + private transformSubscriptions( + subscriptions: DbSubscriptions + ): Subscription[] { + const subscriptionMap: Map = new Map(); + Object.values(SubscriptionType).forEach((subscriptionType) => { + subscriptionMap.set(subscriptionType, []); + }); + + subscriptions.forEach((subscription) => { + const channels = subscriptionMap.get(subscription.type); + if (channels) { + channels.push(subscription.channel); + } + }); + + return Array.from(subscriptionMap.entries()).map(([type, channels]) => ({ + type, + channels, + })); + } } 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 95fa705a..0cec6b90 100644 --- a/apps/recnet-api/src/utils/error/recnet.error.const.ts +++ b/apps/recnet-api/src/utils/error/recnet.error.const.ts @@ -10,6 +10,7 @@ export const ErrorCode = { ACCOUNT_NOT_ACTIVATED: 1008, DIGITAL_LIBRARY_RANK_CONFLICT: 1009, INVALID_REACTION_TYPE: 1010, + INVALID_SUBSCRIPTION: 1011, // DB error codes DB_UNKNOWN_ERROR: 2000, @@ -37,6 +38,7 @@ export const errorMessages = { [ErrorCode.DIGITAL_LIBRARY_RANK_CONFLICT]: "Digital library rank must be unique", [ErrorCode.INVALID_REACTION_TYPE]: "Invalid reaction type", + [ErrorCode.INVALID_SUBSCRIPTION]: "Invalid subscription", [ErrorCode.DB_UNKNOWN_ERROR]: "Database error", [ErrorCode.DB_USER_NOT_FOUND]: "User not found", [ErrorCode.DB_UNIQUE_CONSTRAINT]: "Unique constraint violation", diff --git a/apps/recnet/CHANGELOG.md b/apps/recnet/CHANGELOG.md index dc5c0a0d..3e30f3f9 100644 --- a/apps/recnet/CHANGELOG.md +++ b/apps/recnet/CHANGELOG.md @@ -2,6 +2,26 @@ 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.16.0](https://github.com/lil-lab/recnet/compare/recnet-web-v1.15.2...recnet-web-v1.16.0) (2024-11-08) + + +### Features + +* add GET /users/subscriptions ([aacff5e](https://github.com/lil-lab/recnet/commit/aacff5e9599dff37390856e44532056585307700)) +* add POST /users/subscriptions ([41dfeb9](https://github.com/lil-lab/recnet/commit/41dfeb96a40cdb00d64f4c2cb2db27f77546a6a9)) +* add subscription API model and schema ([67de25f](https://github.com/lil-lab/recnet/commit/67de25faeeb26e5fb74fe1be404483e31541ff6a)) +* add subscription card ([d77a169](https://github.com/lil-lab/recnet/commit/d77a1695898b2c62b7189294db50cd9082fd03f9)) +* add Subscription setting page ([6750714](https://github.com/lil-lab/recnet/commit/67507145fc7f09f3df9031d705034d6b75756a6b)) +* finish subscription setting panel ([2421bd6](https://github.com/lil-lab/recnet/commit/2421bd6140595797340eead29349113ffdc4e686)) +* finish subscription trpc procedures ([d2e44ad](https://github.com/lil-lab/recnet/commit/d2e44adf0f15e7db584b191fe20eae2318284548)) +* migrate email digest ([9aac34f](https://github.com/lil-lab/recnet/commit/9aac34f2f1d2b5aae0b5a40b9db9f9acf6d80a93)) +* send slack digest ([b947311](https://github.com/lil-lab/recnet/commit/b9473115ffb32aa0d4ae2b61558ee444fbb4529a)) + + +### Bug Fixes + +* fix typo ([0977b90](https://github.com/lil-lab/recnet/commit/0977b90696e3c618e871036ae0c32695f8b90f11)) + ## [1.15.2](https://github.com/lil-lab/recnet/compare/recnet-web-v1.15.1...recnet-web-v1.15.2) (2024-10-29) diff --git a/apps/recnet/package.json b/apps/recnet/package.json index d90d56fd..45a5edc3 100644 --- a/apps/recnet/package.json +++ b/apps/recnet/package.json @@ -1,6 +1,6 @@ { "name": "recnet", - "version": "1.15.2", + "version": "1.16.0", "commit-and-tag-version": { "skip": { "commit": true diff --git a/apps/recnet/src/app/Headerbar.tsx b/apps/recnet/src/app/Headerbar.tsx index 7d09bc70..fd4c567c 100644 --- a/apps/recnet/src/app/Headerbar.tsx +++ b/apps/recnet/src/app/Headerbar.tsx @@ -17,6 +17,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useAuth } from "@recnet/recnet-web/app/AuthContext"; import { Avatar } from "@recnet/recnet-web/components/Avatar"; +import { useUserSettingDialogContext } from "@recnet/recnet-web/components/setting/UserSettingDialog"; import { UserRole } from "@recnet/recnet-web/constant"; import { logout, useGoogleLogin } from "@recnet/recnet-web/firebase/auth"; import { cn } from "@recnet/recnet-web/utils/cn"; @@ -29,6 +30,8 @@ export function UserDropdown({ user }: { user: User }) { await logout(); router.push("/"); }; + const { setOpen: setUserSettingDialogOpen } = useUserSettingDialogContext(); + return ( @@ -43,6 +46,9 @@ export function UserDropdown({ user }: { user: User }) { Profile + setUserSettingDialogOpen(true)}> + Settings + {user.role && user.role === UserRole.ADMIN ? ( Admin Panel diff --git a/apps/recnet/src/app/[handle]/Profile.tsx b/apps/recnet/src/app/[handle]/Profile.tsx index f33d8b93..5d2088a8 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 { useUserSettingDialogContext } 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
; } @@ -28,6 +27,7 @@ export function Profile(props: { handle: string }) { }); const { user: me } = useAuth(); const isMe = !!me && !!data?.user && me.handle === data.user.handle; + const { setOpen: setUserSettingDialogOpen } = useUserSettingDialogContext(); const userUrl = useMemo( () => (data?.user?.url ? new URL(data.user.url) : null), @@ -184,7 +184,15 @@ export function Profile(props: { handle: string }) { {isMe ? ( - + ) : ( )} @@ -196,7 +204,15 @@ export function Profile(props: { handle: string }) {
{userInfo}
{isMe ? ( - + ) : ( )} diff --git a/apps/recnet/src/app/layout.tsx b/apps/recnet/src/app/layout.tsx index bfae990e..40e25e6b 100644 --- a/apps/recnet/src/app/layout.tsx +++ b/apps/recnet/src/app/layout.tsx @@ -7,6 +7,7 @@ import { Toaster } from "sonner"; import { Footer } from "@recnet/recnet-web/app/Footer"; import { Headerbar } from "@recnet/recnet-web/app/Headerbar"; import { clientEnv } from "@recnet/recnet-web/clientEnv"; +import { UserSettingDialogProvider } from "@recnet/recnet-web/components/setting/UserSettingDialog"; import { getUserServerSide } from "@recnet/recnet-web/utils/getUserServerSide"; import { ApiErrorBoundary } from "./ApiErrorBoundary"; @@ -43,15 +44,17 @@ export default async function RootLayout({ - - - -
- {children} -
-
-
- + + + + +
+ {children} +
+
+
+ + diff --git a/apps/recnet/src/components/setting/UserSettingDialog.tsx b/apps/recnet/src/components/setting/UserSettingDialog.tsx new file mode 100644 index 00000000..45063a94 --- /dev/null +++ b/apps/recnet/src/components/setting/UserSettingDialog.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { + PersonIcon, + Cross1Icon, + EnvelopeClosedIcon, +} from "@radix-ui/react-icons"; +import { Dialog, Button, Text } from "@radix-ui/themes"; +import { Settings } from "lucide-react"; +import React, { useMemo, useState, createContext, useContext } from "react"; + +import { useAuth } from "@recnet/recnet-web/app/AuthContext"; +import { cn } from "@recnet/recnet-web/utils/cn"; + +import { AccountSetting } from "./account/AccountSetting"; +import { ProfileEditForm } from "./profile/ProfileEditForm"; +import { SubscriptionSetting } from "./subscription/SubscriptionSetting"; + +const tabs = { + PROFILE: { + label: "Profile", + icon: , + component: ProfileEditForm, + }, + ACCOUNT: { + label: "Account", + icon: , + component: AccountSetting, + }, + SUBSCRIPTION: { + label: "Subscription", + icon: , + component: SubscriptionSetting, + }, +} as const; +type TabKey = keyof typeof tabs; + +const UserSettingDialogContext = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + activeTab: TabKey; + setActiveTab: (tab: TabKey) => void; +} | null>(null); + +export function useUserSettingDialogContext() { + const context = useContext(UserSettingDialogContext); + if (!context) { + throw new Error( + "useUserSettingDialog must be used within a UserSettingDialogProvider" + ); + } + return context; +} + +interface UserSettingDialogProps { + children: React.ReactNode; +} + +export function UserSettingDialogProvider(props: UserSettingDialogProps) { + const { children } = props; + const { user } = useAuth(); + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState("PROFILE"); + + const TabComponent = useMemo(() => tabs[activeTab].component, [activeTab]); + + if (!user) { + return children; + } + + return ( + + + + + + +
+
+ {Object.entries(tabs).map(([key, { label }]) => ( +
setActiveTab(key as TabKey)} + > + {tabs[key as TabKey].icon} + + {label} + +
+ ))} +
+
+ +
+
+
+
+ {children} +
+ ); +} diff --git a/apps/recnet/src/components/setting/account/AccountSetting.tsx b/apps/recnet/src/components/setting/account/AccountSetting.tsx new file mode 100644 index 00000000..320514d3 --- /dev/null +++ b/apps/recnet/src/components/setting/account/AccountSetting.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Dialog, Button, Text } from "@radix-ui/themes"; +import { useRouter } from "next/navigation"; + +import { trpc } from "@recnet/recnet-web/app/_trpc/client"; +import { DoubleConfirmButton } from "@recnet/recnet-web/components/DoubleConfirmButton"; +import { logout } from "@recnet/recnet-web/firebase/auth"; + +export function AccountSetting() { + const deactivateMutation = trpc.deactivate.useMutation(); + const router = useRouter(); + + return ( +
+ Account Setting + + Make changes to account settings. + + + + Deactivate Account + + + { + "Your account will be deactivated and you will be logged out. You can reactivate your account by logging in again." + } + { + " While your account is deactivated, your profile will be hidden from other users. You will not receive any weekly digest emails." + } + +
+ { + await deactivateMutation.mutateAsync(); + await logout(); + router.replace("/"); + }} + title="Deactivate Account" + description="Are you sure you want to deactivate your account?" + > + + +
+
+ ); +} diff --git a/apps/recnet/src/app/[handle]/UserSettingDialog.tsx b/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx similarity index 63% rename from apps/recnet/src/app/[handle]/UserSettingDialog.tsx rename to apps/recnet/src/components/setting/profile/ProfileEditForm.tsx index 06e00caf..fe7a5f02 100644 --- a/apps/recnet/src/app/[handle]/UserSettingDialog.tsx +++ b/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx @@ -1,7 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PersonIcon, Cross1Icon } from "@radix-ui/react-icons"; import { Dialog, Button, @@ -11,27 +10,18 @@ import { TextArea, } from "@radix-ui/themes"; import { TRPCClientError } from "@trpc/client"; -import { Settings } from "lucide-react"; -import { useRouter } from "next/navigation"; -import React, { useMemo, useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; 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"; -import { User } from "@recnet/recnet-api-model"; - -interface TabProps { - onSuccess?: (user: User) => void; - setOpen: (open: boolean) => void; -} +import { useUserSettingDialogContext } from "../UserSettingDialog"; const HandleBlacklist = [ "about", @@ -44,7 +34,7 @@ const HandleBlacklist = [ "user", ]; -const EditUserProfileSchema = z.object({ +const ProfileEditSchema = z.object({ displayName: z.string().min(1, "Name cannot be blank."), handle: z .string() @@ -76,13 +66,17 @@ const EditUserProfileSchema = z.object({ openReviewUserName: z.string().nullable(), }); -function EditProfileForm(props: TabProps) { - const { onSuccess = () => {}, setOpen } = props; +export function ProfileEditForm() { + const utils = trpc.useUtils(); + const router = useRouter(); + const { setOpen } = useUserSettingDialogContext(); const { user, revalidateUser } = useAuth(); + const oldHandle = user?.handle; + const pathname = usePathname(); const { register, handleSubmit, formState, setError, control, watch } = useForm({ - resolver: zodResolver(EditUserProfileSchema), + resolver: zodResolver(ProfileEditSchema), defaultValues: { displayName: user?.displayName ?? null, handle: user?.handle ?? null, @@ -104,7 +98,7 @@ function EditProfileForm(props: TabProps) { className="w-full" onSubmit={handleSubmit(async (data, e) => { e?.preventDefault(); - const res = EditUserProfileSchema.safeParse(data); + const res = ProfileEditSchema.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."); @@ -138,15 +132,25 @@ function EditProfileForm(props: TabProps) { toast.success("Profile updated successfully!"); // revaildate user profile revalidateUser(); - // fire onSuccess callback - onSuccess(updatedData.user); + // revalidate cache for user profile or redirect to new user profile if handle changed + const updatedUser = updatedData.user; + if (updatedUser.handle !== oldHandle) { + // if user currently at their profile page, + // and if user change user handle, redirect to new user profile + if (pathname === `/${oldHandle}`) { + router.replace(`/${updatedUser.handle}`); + } + } else { + utils.getUserByHandle.invalidate({ handle: oldHandle }); + } + setOpen(false); } catch (error) { console.log(error); } })} > Edit profile - + Make changes to your profile. @@ -301,11 +305,6 @@ function EditProfileForm(props: TabProps) { - - - - -
- - ); -} - -const tabs = { - PROFILE: { - label: "Profile", - icon: , - component: EditProfileForm, - }, - ACCOUNT: { - label: "Account", - icon: , - component: AccountSetting, - }, -} as const; -type TabKey = keyof typeof tabs; - -export function UserSettingDialog(props: { handle: string }) { - const { handle } = props; - const utils = trpc.useUtils(); - const router = useRouter(); - const [open, setOpen] = useState(false); - const { user } = useAuth(); - const oldHandle = user?.handle; - - const tabsProps = useMemo(() => { - return { - ACCOUNT: { - onSuccess: (updatedUser: User) => { - // redirect to home page after deactivating account - router.replace("/"); - }, - setOpen: setOpen, - }, - PROFILE: { - onSuccess: (updatedUser: User) => { - if (updatedUser.handle !== oldHandle) { - // if user change user handle, redirect to new user profile - router.replace(`/${updatedUser.handle}`); - } else { - utils.getUserByHandle.invalidate({ handle: handle }); - setOpen(false); - } - }, - setOpen: setOpen, - }, - }; - }, [handle, oldHandle, router, utils]); - const [activeTab, setActiveTab] = useState("PROFILE"); - - const TabComponent = useMemo(() => tabs[activeTab].component, [activeTab]); - - return ( - - - - - - - - -
-
- {Object.entries(tabs).map(([key, { label }]) => ( -
setActiveTab(key as TabKey)} - > - {tabs[key as TabKey].icon} - - {label} - -
- ))} -
-
- -
-
-
-
- ); -} diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx new file mode 100644 index 00000000..6293d31b --- /dev/null +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import * as Accordion from "@radix-ui/react-accordion"; +import { + Card, + Dialog, + Flex, + Text, + CheckboxCards, + Badge, + Button, +} from "@radix-ui/themes"; +import { ChevronDown } 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 { LoadingBox } from "@recnet/recnet-web/components/LoadingBox"; +import { cn } from "@recnet/recnet-web/utils/cn"; + +import { + SubscriptionChannel, + SubscriptionType, + subscriptionTypeSchema, + subscriptionChannelSchema, +} from "@recnet/recnet-api-model"; + +const transformSubscriptionEnum = (subType: string): string => { + return subType + .replace(/_/g, " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +const subscriptionChannelEditSchema = z.object({ + channels: subscriptionChannelSchema.array(), +}); + +function SubscriptionTypeCard(props: { + type: SubscriptionType; + selectedChannels: SubscriptionChannel[]; +}) { + const { type, selectedChannels } = props; + const title = transformSubscriptionEnum(type); + const isActivated = selectedChannels.length > 0; + const [isSubmitting, setIsSubmitting] = useState(false); + const utils = trpc.useUtils(); + + const { handleSubmit, control, reset, setError, formState } = useForm({ + resolver: zodResolver(subscriptionChannelEditSchema), + defaultValues: { + channels: selectedChannels, + }, + mode: "onTouched", + }); + const { isDirty } = useFormState({ control }); + + const updateSubscriptionMutation = trpc.updateSubscription.useMutation(); + + return ( + + + + + {title} + + Channels:{" "} + {selectedChannels + .map((channel) => channel.toLowerCase()) + .join(", ")} + + + + + + +
{ + setIsSubmitting(true); + // handle special case for 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", + message: + "At least one channel must be selected for Weekly Digest", + }); + setIsSubmitting(false); + return; + } + await updateSubscriptionMutation.mutateAsync({ + type, + channels: data.channels, + }); + utils.getSubscriptions.invalidate(); + toast.success("Subscription updated successfully"); + setIsSubmitting(false); + }, + (e) => { + console.log(e); + } + )} + > +
+ + Distribution Channels: + + { + return ( +
+ field.onChange(value)} + > + {subscriptionChannelSchema.options.map((channel) => { + return ( + + {channel.charAt(0).toUpperCase() + + channel.slice(1).toLowerCase()} + + ); + })} + + {formState.errors.channels && ( + + {`${formState.errors.channels.message}`} + + )} +
+ ); + }} + /> +
+ + + 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. + + + + + + +
+
+
+ ); +} + +export function SubscriptionSetting() { + const { data, isFetching } = trpc.getSubscriptions.useQuery(); + const [openedType, setOpenType] = useState( + undefined + ); + + return ( +
+ Subscription Setting + + Customize the subscription types and channels that best fits your needs. + + + + Subscriptions + + {isFetching ? ( + + ) : ( + setOpenType(value as SubscriptionType)} + > + {subscriptionTypeSchema.options.map((subType) => { + const subscriptionTypeObj = (data?.subscriptions ?? []).find( + (sub) => sub.type === subType + ); + return ( + + ); + })} + + )} +
+ ); +} diff --git a/apps/recnet/src/server/index.ts b/apps/recnet/src/server/index.ts index 7bdc8e9d..916e2e11 100644 --- a/apps/recnet/src/server/index.ts +++ b/apps/recnet/src/server/index.ts @@ -3,6 +3,7 @@ import { articleRouter } from "./routers/article"; import { inviteCodeRouter } from "./routers/inviteCode"; import { publicRouter } from "./routers/public"; import { recRouter } from "./routers/rec"; +import { subscriptionRouter } from "./routers/subscription"; import { userRouter } from "./routers/user"; import { mergeRouters } from "./trpc"; @@ -12,7 +13,8 @@ export const appRouter = mergeRouters( recRouter, inviteCodeRouter, articleRouter, - announcementRouter + announcementRouter, + subscriptionRouter ); export type AppRouter = typeof appRouter; diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts new file mode 100644 index 00000000..6e7da4b6 --- /dev/null +++ b/apps/recnet/src/server/routers/subscription.ts @@ -0,0 +1,30 @@ +import { + getUsersSubscriptionsResponseSchema, + postUsersSubscriptionsRequestSchema, + postUsersSubscriptionsResponseSchema, +} from "@recnet/recnet-api-model"; + +import { checkRecnetJWTProcedure } from "./middleware"; + +import { router } from "../trpc"; + +export const subscriptionRouter = router({ + getSubscriptions: checkRecnetJWTProcedure + .output(getUsersSubscriptionsResponseSchema) + .query(async (opts) => { + const { recnetApi } = opts.ctx; + const { data } = await recnetApi.get("/users/subscriptions"); + return getUsersSubscriptionsResponseSchema.parse(data); + }), + updateSubscription: checkRecnetJWTProcedure + .input(postUsersSubscriptionsRequestSchema) + .output(postUsersSubscriptionsResponseSchema) + .mutation(async (opts) => { + const subscription = opts.input; + const { recnetApi } = opts.ctx; + const { data } = await recnetApi.post("/users/subscriptions", { + ...postUsersSubscriptionsRequestSchema.parse(subscription), + }); + return postUsersSubscriptionsResponseSchema.parse(data); + }), +}); diff --git a/libs/recnet-api-model/src/lib/api/user.ts b/libs/recnet-api-model/src/lib/api/user.ts index 91ff55f5..65059e86 100644 --- a/libs/recnet-api-model/src/lib/api/user.ts +++ b/libs/recnet-api-model/src/lib/api/user.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { userPreviewSchema, userSchema } from "../model"; +import { subscriptionSchema, userPreviewSchema, userSchema } from "../model"; // GET /users export const getUsersParamsSchema = z.object({ @@ -109,3 +109,24 @@ export const deleteUserFollowParamsSchema = z.object({ export type DeleteUserFollowParams = z.infer< typeof deleteUserFollowParamsSchema >; + +// GET /users/subscriptions +export const getUsersSubscriptionsResponseSchema = z.object({ + subscriptions: z.array(subscriptionSchema), +}); +export type GetUsersSubscriptionsResponse = z.infer< + typeof getUsersSubscriptionsResponseSchema +>; + +// POST /users/subscriptions +export const postUsersSubscriptionsRequestSchema = subscriptionSchema; +export type PostUsersSubscriptionsRequest = z.infer< + typeof postUsersSubscriptionsRequestSchema +>; + +export const postUsersSubscriptionsResponseSchema = z.object({ + subscription: subscriptionSchema, +}); +export type PostUsersSubscriptionsResponse = z.infer< + typeof postUsersSubscriptionsResponseSchema +>; diff --git a/libs/recnet-api-model/src/lib/model.ts b/libs/recnet-api-model/src/lib/model.ts index 6c216150..c97558a6 100644 --- a/libs/recnet-api-model/src/lib/model.ts +++ b/libs/recnet-api-model/src/lib/model.ts @@ -20,6 +20,11 @@ export const reactionTypeSchema = z.enum([ ]); export type ReactionType = z.infer; +export const subscriptionTypeSchema = z.enum(["WEEKLY_DIGEST"]); +export type SubscriptionType = z.infer; +export const subscriptionChannelSchema = z.enum(["EMAIL", "SLACK"]); +export type SubscriptionChannel = z.infer; + export const userPreviewSchema = z.object({ id: z.string(), handle: z.string(), @@ -106,3 +111,9 @@ export const digitalLibrarySchema = z.object({ isVerified: z.boolean(), }); export type DigitalLibrary = z.infer; + +export const subscriptionSchema = z.object({ + type: subscriptionTypeSchema, + channels: z.array(subscriptionChannelSchema), +}); +export type Subscription = z.infer; diff --git a/libs/recnet-release-action/action.yml b/libs/recnet-release-action/action.yml index ddd11772..127cad63 100644 --- a/libs/recnet-release-action/action.yml +++ b/libs/recnet-release-action/action.yml @@ -53,7 +53,6 @@ runs: shell: bash - name: Install dependencies - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile shell: sh diff --git a/package.json b/package.json index 39b0a787..1bd91729 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@next/third-parties": "^14.1.0", "@octokit/core": "^6.1.2", "@prisma/client": "5.12.1", + "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/themes": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffbf62dc..cb488afd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - dependencies: '@actions/core': specifier: ^1.10.1 @@ -47,6 +43,9 @@ dependencies: '@prisma/client': specifier: 5.12.1 version: 5.12.1(prisma@5.12.1) + '@radix-ui/react-accordion': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.0(react@18.2.0) @@ -1920,7 +1919,6 @@ packages: cpu: [ppc64] os: [aix] requiresBuild: true - dev: false optional: true /@esbuild/aix-ppc64@0.19.12: @@ -1938,7 +1936,6 @@ packages: cpu: [arm64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-arm64@0.19.12: @@ -1956,7 +1953,6 @@ packages: cpu: [arm] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-arm@0.19.12: @@ -1974,7 +1970,6 @@ packages: cpu: [x64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-x64@0.19.12: @@ -1992,7 +1987,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/darwin-arm64@0.19.12: @@ -2010,7 +2004,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/darwin-x64@0.19.12: @@ -2028,7 +2021,6 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-arm64@0.19.12: @@ -2046,7 +2038,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-x64@0.19.12: @@ -2064,7 +2055,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm64@0.19.12: @@ -2082,7 +2072,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm@0.19.12: @@ -2100,7 +2089,6 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ia32@0.19.12: @@ -2118,7 +2106,6 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-loong64@0.19.12: @@ -2136,7 +2123,6 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-mips64el@0.19.12: @@ -2154,7 +2140,6 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ppc64@0.19.12: @@ -2172,7 +2157,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-riscv64@0.19.12: @@ -2190,7 +2174,6 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-s390x@0.19.12: @@ -2208,7 +2191,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-x64@0.19.12: @@ -2226,7 +2208,6 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true - dev: false optional: true /@esbuild/netbsd-x64@0.19.12: @@ -2244,7 +2225,6 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true - dev: false optional: true /@esbuild/openbsd-x64@0.19.12: @@ -2262,7 +2242,6 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true - dev: false optional: true /@esbuild/sunos-x64@0.19.12: @@ -2280,7 +2259,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-arm64@0.19.12: @@ -2298,7 +2276,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-ia32@0.19.12: @@ -2316,7 +2293,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-x64@0.19.12: @@ -4616,11 +4592,11 @@ packages: style-loader: 3.3.4(webpack@5.91.0) stylus: 0.59.0 stylus-loader: 7.1.3(stylus@0.59.0)(webpack@5.91.0) - terser-webpack-plugin: 5.3.10(@swc/core@1.3.85)(webpack@5.91.0) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.101)(esbuild@0.19.11)(webpack@5.91.0) ts-loader: 9.5.1(typescript@5.4.5)(webpack@5.91.0) tsconfig-paths-webpack-plugin: 4.0.0 tslib: 2.6.2 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-dev-server: 4.15.2(webpack-cli@5.1.4)(webpack@5.91.0) webpack-node-externals: 3.0.0 webpack-subresource-integrity: 5.1.0(webpack@5.91.0) @@ -4879,6 +4855,10 @@ packages: '@babel/runtime': 7.24.4 dev: false + /@radix-ui/primitive@1.1.0: + resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + dev: false + /@radix-ui/react-accessible-icon@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-duVGKeWPSUILr/MdlPxV+GeULTc2rS1aihGdQ3N2qCUPMgxYLxvAsHJM3mCVLF8d5eK+ympmB22mb1F3a5biNw==} peerDependencies: @@ -4900,6 +4880,34 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-accordion@1.2.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collapsible': 1.1.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-direction': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} peerDependencies: @@ -5048,6 +5056,33 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-collapsible@1.1.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: @@ -5072,6 +5107,29 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-collection@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-context': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.67)(react@18.2.0): resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} peerDependencies: @@ -5086,6 +5144,19 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-compose-refs@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-context-menu@2.1.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g==} peerDependencies: @@ -5126,6 +5197,32 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-context@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + + /@radix-ui/react-context@1.1.1(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} peerDependencies: @@ -5174,6 +5271,19 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-direction@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==} peerDependencies: @@ -5389,6 +5499,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-id@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-label@2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} peerDependencies: @@ -5676,6 +5800,27 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-presence@1.1.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} peerDependencies: @@ -5697,6 +5842,26 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} peerDependencies: @@ -5894,6 +6059,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-slot@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} peerDependencies: @@ -6077,6 +6256,19 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.67)(react@18.2.0): resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} peerDependencies: @@ -6092,6 +6284,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.67)(react@18.2.0) + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.67)(react@18.2.0): resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} peerDependencies: @@ -6121,6 +6327,19 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.2.67)(react@18.2.0): + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.67 + react: 18.2.0 + dev: false + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.67)(react@18.2.0): resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} peerDependencies: @@ -6897,7 +7116,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@swc/core-darwin-arm64@1.3.85: @@ -6914,7 +7132,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@swc/core-darwin-x64@1.3.85: @@ -6931,7 +7148,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-arm-gnueabihf@1.3.85: @@ -6948,7 +7164,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-arm64-gnu@1.3.85: @@ -6965,7 +7180,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-arm64-musl@1.3.85: @@ -6982,7 +7196,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-x64-gnu@1.3.85: @@ -6999,7 +7212,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-x64-musl@1.3.85: @@ -7016,7 +7228,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@swc/core-win32-arm64-msvc@1.3.85: @@ -7033,7 +7244,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@swc/core-win32-ia32-msvc@1.3.85: @@ -7050,7 +7260,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@swc/core-win32-x64-msvc@1.3.85: @@ -7085,7 +7294,6 @@ packages: '@swc/core-win32-arm64-msvc': 1.3.101 '@swc/core-win32-ia32-msvc': 1.3.101 '@swc/core-win32-x64-msvc': 1.3.101 - dev: false /@swc/core@1.3.85(@swc/helpers@0.5.2): resolution: {integrity: sha512-qnoxp+2O0GtvRdYnXgR1v8J7iymGGYpx6f6yCK9KxipOZOjrlKILFANYlghQxZyPUfXwK++TFxfSlX4r9wK+kg==} @@ -8325,7 +8533,7 @@ packages: webpack: 5.x.x webpack-cli: 5.x.x dependencies: - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.91.0) /@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.91.0): @@ -8335,7 +8543,7 @@ packages: webpack: 5.x.x webpack-cli: 5.x.x dependencies: - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.91.0) /@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.91.0): @@ -8349,7 +8557,7 @@ packages: webpack-dev-server: optional: true dependencies: - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.91.0) /@xtuc/ieee754@1.2.0: @@ -8867,7 +9075,7 @@ packages: '@babel/core': 7.24.4 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /babel-plugin-const-enum@1.2.0(@babel/core@7.24.4): @@ -9897,7 +10105,7 @@ packages: normalize-path: 3.0.0 schema-utils: 4.2.0 serialize-javascript: 6.0.2 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /core-js-compat@3.36.1: @@ -10039,7 +10247,7 @@ packages: postcss-modules-values: 4.0.0(postcss@8.4.38) postcss-value-parser: 4.2.0 semver: 7.6.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /css-minimizer-webpack-plugin@5.0.1(webpack@5.91.0): @@ -10073,7 +10281,7 @@ packages: postcss: 8.4.38 schema-utils: 4.2.0 serialize-javascript: 6.0.2 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /css-select@5.1.0: @@ -11212,7 +11420,6 @@ packages: '@esbuild/win32-arm64': 0.19.11 '@esbuild/win32-ia32': 0.19.11 '@esbuild/win32-x64': 0.19.11 - dev: false /esbuild@0.19.12: resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} @@ -11951,7 +12158,7 @@ packages: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /filelist@1.0.4: @@ -12128,7 +12335,7 @@ packages: semver: 7.6.0 tapable: 2.2.1 typescript: 5.4.5 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /form-data@4.0.0: @@ -14348,7 +14555,7 @@ packages: dependencies: klona: 2.0.6 less: 4.1.3 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /less@4.1.3: @@ -14388,8 +14595,10 @@ packages: peerDependenciesMeta: webpack: optional: true + webpack-sources: + optional: true dependencies: - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-sources: 3.2.3 dev: true @@ -15464,7 +15673,7 @@ packages: webpack: ^5.0.0 dependencies: schema-utils: 4.2.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /minimalistic-assert@1.0.1: @@ -16712,7 +16921,7 @@ packages: klona: 2.0.6 postcss: 8.4.21 semver: 7.6.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /postcss-merge-longhand@6.0.5(postcss@8.4.38): @@ -17928,7 +18137,7 @@ packages: klona: 2.0.6 neo-async: 2.6.2 sass: 1.72.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /sass@1.72.0: @@ -18344,7 +18553,7 @@ packages: abab: 2.0.6 iconv-lite: 0.6.3 source-map-js: 1.2.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /source-map-support@0.5.13: @@ -18666,7 +18875,7 @@ packages: peerDependencies: webpack: ^5.0.0 dependencies: - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /style-to-object@0.4.4: @@ -18717,7 +18926,7 @@ packages: fast-glob: 3.3.2 normalize-path: 3.0.0 stylus: 0.59.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /stylus@0.59.0: @@ -18935,31 +19144,6 @@ packages: serialize-javascript: 6.0.2 terser: 5.29.2 webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) - dev: false - - /terser-webpack-plugin@5.3.10(@swc/core@1.3.85)(webpack@5.91.0): - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - '@swc/core': 1.3.85(@swc/helpers@0.5.2) - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.29.2 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) /terser@5.29.2: resolution: {integrity: sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw==} @@ -19203,7 +19387,7 @@ packages: semver: 7.6.0 source-map: 0.7.4 typescript: 5.4.5 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /ts-morph@18.0.0: @@ -20065,7 +20249,7 @@ packages: import-local: 3.1.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-merge: 5.10.0 /webpack-dev-middleware@5.3.4(webpack@5.91.0): @@ -20079,7 +20263,7 @@ packages: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /webpack-dev-server@4.15.2(webpack-cli@5.1.4)(webpack@5.91.0): @@ -20123,7 +20307,7 @@ packages: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.91.0) webpack-dev-middleware: 5.3.4(webpack@5.91.0) ws: 8.16.0 @@ -20162,7 +20346,7 @@ packages: optional: true dependencies: typed-assert: 1.0.9 - webpack: 5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4) + webpack: 5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4) dev: true /webpack@5.91.0(@swc/core@1.3.101)(esbuild@0.19.11)(webpack-cli@5.1.4): @@ -20204,47 +20388,6 @@ packages: - '@swc/core' - esbuild - uglify-js - dev: false - - /webpack@5.91.0(@swc/core@1.3.85)(webpack-cli@5.1.4): - resolution: {integrity: sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.5 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.11.3 - acorn-import-assertions: 1.9.0(acorn@8.11.3) - browserslist: 4.23.0 - chrome-trace-event: 1.0.3 - enhanced-resolve: 5.16.0 - es-module-lexer: 1.4.2 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.85)(webpack@5.91.0) - watchpack: 2.4.1 - webpack-cli: 5.1.4(webpack@5.91.0) - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js /websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} @@ -20571,3 +20714,7 @@ packages: /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false