From 26d8533c50434eb60e979feca53443503775a901 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Fri, 11 Oct 2024 04:37:59 -0400 Subject: [PATCH 01/15] feat: add verified badge --- apps/recnet/src/components/RecCard.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/recnet/src/components/RecCard.tsx b/apps/recnet/src/components/RecCard.tsx index d380fadd..e38a07dc 100644 --- a/apps/recnet/src/components/RecCard.tsx +++ b/apps/recnet/src/components/RecCard.tsx @@ -1,5 +1,5 @@ -import { CalendarIcon } from "@radix-ui/react-icons"; -import { Flex, Text } from "@radix-ui/themes"; +import { CalendarIcon, CheckCircledIcon } from "@radix-ui/react-icons"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { ChevronRight } from "lucide-react"; import Link from "next/link"; @@ -140,6 +140,16 @@ export function RecCard(props: { recs: Rec[]; showDate?: boolean }) { ) : null} {rec.isSelfRec ? : null} + {rec.article.isVerified ? ( + +
+ + + Verified + +
+
+ ) : null} Date: Fri, 11 Oct 2024 04:41:38 -0400 Subject: [PATCH 02/15] fix: adjust ui --- apps/recnet/src/components/RecCard.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/recnet/src/components/RecCard.tsx b/apps/recnet/src/components/RecCard.tsx index e38a07dc..dc289677 100644 --- a/apps/recnet/src/components/RecCard.tsx +++ b/apps/recnet/src/components/RecCard.tsx @@ -105,6 +105,14 @@ export function RecCard(props: { recs: Rec[]; showDate?: boolean }) { {`${!rec.article.month ? "" : `${numToMonth[rec.article.month]}, `}${rec.article.year}`} + {rec.article.isVerified ? ( + +
+ + Verified +
+
+ ) : null}
Read{" "} @@ -140,16 +148,6 @@ export function RecCard(props: { recs: Rec[]; showDate?: boolean }) { ) : null} {rec.isSelfRec ? : null} - {rec.article.isVerified ? ( - -
- - - Verified - -
-
- ) : null}
Date: Fri, 11 Oct 2024 22:07:57 -0400 Subject: [PATCH 03/15] feat: add AuthOptional decorator --- .../src/modules/rec/rec.controller.ts | 19 +++++-- .../src/utils/auth/auth.decorator.ts | 13 ++++- apps/recnet-api/src/utils/auth/auth.guard.ts | 55 +++++++++++-------- apps/recnet-api/src/utils/auth/auth.type.ts | 2 + .../src/utils/auth/auth.user.decorator.ts | 14 +++++ 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/apps/recnet-api/src/modules/rec/rec.controller.ts b/apps/recnet-api/src/modules/rec/rec.controller.ts index 40d8bd7c..ad48bf6c 100644 --- a/apps/recnet-api/src/modules/rec/rec.controller.ts +++ b/apps/recnet-api/src/modules/rec/rec.controller.ts @@ -17,9 +17,9 @@ import { ApiTags, } from "@nestjs/swagger"; -import { Auth } from "@recnet-api/utils/auth/auth.decorator"; -import { AuthUser } from "@recnet-api/utils/auth/auth.type"; -import { User } from "@recnet-api/utils/auth/auth.user.decorator"; +import { Auth, AuthOptional } from "@recnet-api/utils/auth/auth.decorator"; +import { AuthUser, AuthOptionalUser } from "@recnet-api/utils/auth/auth.type"; +import { User, UserOptional } from "@recnet-api/utils/auth/auth.user.decorator"; import { RecnetExceptionFilter } from "@recnet-api/utils/filters/recnet.exception.filter"; import { ZodValidationBodyPipe, @@ -63,9 +63,14 @@ export class RecController { summary: "Get a single rec", description: "Get a single rec by id.", }) + @ApiBearerAuth() @ApiOkResponse({ type: GetRecResponse }) @Get("rec/:id") - public async getRec(@Param("id") id: string): Promise { + @AuthOptional() + public async getRec( + @Param("id") id: string, + @UserOptional() authUser: AuthOptionalUser + ): Promise { return this.recService.getRec(id); } @@ -76,8 +81,12 @@ export class RecController { @ApiOkResponse({ type: GetRecsResponse }) @ApiBearerAuth() @Get() + @AuthOptional() @UsePipes(new ZodValidationQueryPipe(getRecsParamsSchema)) - public async getRecs(@Query() dto: QueryRecsDto): Promise { + public async getRecs( + @Query() dto: QueryRecsDto, + @UserOptional() authUser: AuthOptionalUser + ): Promise { const { page, pageSize, userId } = dto; // Get the Recs to current date to avoid upcoming rec from showing in a user's profile page diff --git a/apps/recnet-api/src/utils/auth/auth.decorator.ts b/apps/recnet-api/src/utils/auth/auth.decorator.ts index 55c54ea0..fd2c9f1e 100644 --- a/apps/recnet-api/src/utils/auth/auth.decorator.ts +++ b/apps/recnet-api/src/utils/auth/auth.decorator.ts @@ -16,12 +16,21 @@ export const Auth = ( } = {} ) => { const { allowNonActivated = false, allowedRoles = ["USER", "ADMIN"] } = opts; + const isOptional = false; return applyDecorators( - UseGuards(new AuthGuard(verifyRecnetJwt)), + UseGuards(AuthGuard(verifyRecnetJwt, isOptional)), UseGuards(ActivatedGuard(allowNonActivated)), UseGuards(RoleGuard(allowedRoles)) ); }; -export const AuthFirebase = () => UseGuards(new AuthGuard(verifyFirebaseJwt)); +export const AuthOptional = () => { + const isOptional = true; + return UseGuards(AuthGuard(verifyRecnetJwt, isOptional)); +}; + +export const AuthFirebase = () => { + const isOptional = false; + return UseGuards(AuthGuard(verifyFirebaseJwt, isOptional)); +}; diff --git a/apps/recnet-api/src/utils/auth/auth.guard.ts b/apps/recnet-api/src/utils/auth/auth.guard.ts index a6384bcc..82fe4398 100644 --- a/apps/recnet-api/src/utils/auth/auth.guard.ts +++ b/apps/recnet-api/src/utils/auth/auth.guard.ts @@ -1,7 +1,7 @@ import { CanActivate, ExecutionContext, - Injectable, + mixin, UnauthorizedException, } from "@nestjs/common"; import { Request } from "express"; @@ -10,29 +10,40 @@ import { getPublicKey } from "@recnet/recnet-jwt"; import { VerifyJwtFunction } from "./auth.type"; -@Injectable() -export class AuthGuard implements CanActivate { - constructor(private readonly verifyJwt: VerifyJwtFunction) {} +export const AuthGuard = ( + verifyJwt: VerifyJwtFunction, + isOptional: boolean +) => { + class AuthGuardMixin implements CanActivate { + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); - async canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); - const token = this.extractTokenFromHeader(request); + if (!token) { + if (isOptional) { + return true; + } + throw new UnauthorizedException(); + } - if (!token) { - throw new UnauthorizedException(); + try { + const publicKey = await getPublicKey(token); + const payload = verifyJwt(token, publicKey); + request.user = payload; + } catch (error) { + if (isOptional) { + return true; + } + throw new UnauthorizedException(); + } + return true; } - try { - const publicKey = await getPublicKey(token); - const payload = this.verifyJwt(token, publicKey); - request.user = payload; - } catch (error) { - throw new UnauthorizedException(); - } - return true; - } - private extractTokenFromHeader(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(" ") ?? []; - return type === "Bearer" ? token : undefined; + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(" ") ?? []; + return type === "Bearer" ? token : undefined; + } } -} + const guard = mixin(AuthGuardMixin); + return guard; +}; diff --git a/apps/recnet-api/src/utils/auth/auth.type.ts b/apps/recnet-api/src/utils/auth/auth.type.ts index 2b6becc9..9886f9d8 100644 --- a/apps/recnet-api/src/utils/auth/auth.type.ts +++ b/apps/recnet-api/src/utils/auth/auth.type.ts @@ -17,6 +17,8 @@ export type AuthUser< ? RecNetJwtPayload["recnet"][Prop] : RecNetJwtPayload["recnet"]; +export type AuthOptionalUser = AuthUser | null; + export type AuthFirebaseUser = { provider: AuthProvider; providerId: string; diff --git a/apps/recnet-api/src/utils/auth/auth.user.decorator.ts b/apps/recnet-api/src/utils/auth/auth.user.decorator.ts index aa104ce5..81050a38 100644 --- a/apps/recnet-api/src/utils/auth/auth.user.decorator.ts +++ b/apps/recnet-api/src/utils/auth/auth.user.decorator.ts @@ -34,6 +34,20 @@ export const User = createParamDecorator< return prop ? recnetUser[prop] : recnetUser; }); +export const UserOptional = createParamDecorator< + RecNetJwtPayloadProps | undefined, + ExecutionContext +>((prop, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const recnetJwtPayload = recnetJwtPayloadSchema.safeParse(request.user); + if (!recnetJwtPayload.success) { + return null; + } + const recnetUser = recnetJwtPayload.data.recnet; + + return prop ? recnetUser[prop] : recnetUser; +}); + export const FirebaseUser = createParamDecorator( (_, ctx): AuthFirebaseUser => { const request = ctx.switchToHttp().getRequest(); From cf809e8f2929c2e3648c1fec36b26bcc434edc70 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sat, 12 Oct 2024 22:36:04 -0400 Subject: [PATCH 04/15] feat: add reactions to rec response --- .../repository/rec.repository.type.ts | 23 ++++---- .../src/modules/email/email.service.ts | 4 ++ .../src/modules/rec/entities/rec.entity.ts | 21 ++++++++ .../src/modules/rec/rec.controller.ts | 6 ++- .../recnet-api/src/modules/rec/rec.service.ts | 53 +++++++++++++++---- libs/recnet-api-model/src/lib/model.ts | 9 ++++ 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/apps/recnet-api/src/database/repository/rec.repository.type.ts b/apps/recnet-api/src/database/repository/rec.repository.type.ts index d36c5efc..37364e4c 100644 --- a/apps/recnet-api/src/database/repository/rec.repository.type.ts +++ b/apps/recnet-api/src/database/repository/rec.repository.type.ts @@ -3,6 +3,16 @@ import { Prisma } from "@prisma/client"; import { article } from "./article.repository.type"; import { userPreview } from "./user.repository.type"; +export const recReaction = Prisma.validator()({ + select: { + id: true, + userId: true, + recId: true, + reaction: true, + }, +}); +export type RecReaction = Prisma.RecReactionGetPayload; + export const rec = Prisma.validator()({ select: { id: true, @@ -15,20 +25,13 @@ export const rec = Prisma.validator()({ article: { select: article.select, }, + reactions: { + select: recReaction.select, + }, }, }); export type Rec = Prisma.RecommendationGetPayload; -export const recReaction = Prisma.validator()({ - select: { - id: true, - userId: true, - recId: true, - reaction: true, - }, -}); -export type RecReaction = Prisma.RecReactionGetPayload; - export type DateRange = { from?: Date; to?: Date; diff --git a/apps/recnet-api/src/modules/email/email.service.ts b/apps/recnet-api/src/modules/email/email.service.ts index 72816866..45d3ea4c 100644 --- a/apps/recnet-api/src/modules/email/email.service.ts +++ b/apps/recnet-api/src/modules/email/email.service.ts @@ -162,6 +162,10 @@ export class EmailService { ...dbRec, cutoff: dbRec.cutoff.toISOString(), user: transformUserPreview(dbRec.user), + reactions: { + selfReactions: [], + numReactions: [], + }, }; } } diff --git a/apps/recnet-api/src/modules/rec/entities/rec.entity.ts b/apps/recnet-api/src/modules/rec/entities/rec.entity.ts index 56d52cb3..2bee1c19 100644 --- a/apps/recnet-api/src/modules/rec/entities/rec.entity.ts +++ b/apps/recnet-api/src/modules/rec/entities/rec.entity.ts @@ -2,8 +2,26 @@ import { ApiProperty } from "@nestjs/swagger"; import { Article } from "@recnet-api/modules/article/entities/article.entity"; +import { ReactionType } from "@recnet/recnet-api-model"; + import { UserPreview } from "../../user/entities/user.preview.entity"; +class NumReaction { + @ApiProperty() + type: ReactionType; + + @ApiProperty() + count: number; +} + +class Reactions { + @ApiProperty() + selfReactions: ReactionType[]; + + @ApiProperty() + numReactions: NumReaction[]; +} + export class Rec { @ApiProperty() id: string; @@ -22,4 +40,7 @@ export class Rec { @ApiProperty() article: Article; + + @ApiProperty() + reactions: Reactions; } diff --git a/apps/recnet-api/src/modules/rec/rec.controller.ts b/apps/recnet-api/src/modules/rec/rec.controller.ts index ad48bf6c..f76e2651 100644 --- a/apps/recnet-api/src/modules/rec/rec.controller.ts +++ b/apps/recnet-api/src/modules/rec/rec.controller.ts @@ -71,7 +71,8 @@ export class RecController { @Param("id") id: string, @UserOptional() authUser: AuthOptionalUser ): Promise { - return this.recService.getRec(id); + const authUserId = authUser ? authUser.userId : null; + return this.recService.getRec(id, authUserId); } @ApiOperation({ @@ -88,10 +89,11 @@ export class RecController { @UserOptional() authUser: AuthOptionalUser ): Promise { const { page, pageSize, userId } = dto; + const authUserId = authUser ? authUser.userId : null; // Get the Recs to current date to avoid upcoming rec from showing in a user's profile page const to = new Date(); - return this.recService.getRecs(page, pageSize, userId, to); + return this.recService.getRecs(page, pageSize, userId, to, authUserId); } @ApiOperation({ diff --git a/apps/recnet-api/src/modules/rec/rec.service.ts b/apps/recnet-api/src/modules/rec/rec.service.ts index 95f3b06f..e42582b1 100644 --- a/apps/recnet-api/src/modules/rec/rec.service.ts +++ b/apps/recnet-api/src/modules/rec/rec.service.ts @@ -7,6 +7,7 @@ import RecRepository from "@recnet-api/database/repository/rec.repository"; import { Rec as DbRec, RecFilterBy, + RecReaction as DbRecReaction, } from "@recnet-api/database/repository/rec.repository.type"; import UserRepository from "@recnet-api/database/repository/user.repository"; import { getOffset } from "@recnet-api/utils"; @@ -38,16 +39,20 @@ export class RecService { private readonly articleRepository: ArticleRepository ) {} - public async getRec(recId: string): Promise { + public async getRec( + recId: string, + authUserId: string | null + ): Promise { const dbRec = await this.recRepository.findRecById(recId); - return { rec: this.getRecFromDbRec(dbRec) }; + return { rec: this.getRecFromDbRec(dbRec, authUserId) }; } public async getRecs( page: number, pageSize: number, userId: string, - to: Date + to: Date, + authUserId: string | null ): Promise { // validate if the user exists and is activated const user = await this.userRepository.findUserById(userId); @@ -64,7 +69,7 @@ export class RecService { }; const recCount = await this.recRepository.countRecs(filter); const dbRecs = await this.recRepository.findRecs(page, pageSize, filter); - const recs = this.getRecsFromDbRecs(dbRecs); + const recs = this.getRecsFromDbRecs(dbRecs, authUserId); return { hasNext: recs.length + getOffset(page, pageSize) < recCount, @@ -89,7 +94,7 @@ export class RecService { }; const recCount = await this.recRepository.countRecs(filter); const dbRecs = await this.recRepository.findRecs(page, pageSize, filter); - const recs = this.getRecsFromDbRecs(dbRecs); + const recs = this.getRecsFromDbRecs(dbRecs, userId); return { hasNext: recs.length + getOffset(page, pageSize) < recCount, @@ -105,7 +110,7 @@ export class RecService { }; } return { - rec: this.getRecFromDbRec(dbRec), + rec: this.getRecFromDbRec(dbRec, userId), }; } @@ -146,7 +151,7 @@ export class RecService { articleIdToConnect ); return { - rec: this.getRecFromDbRec(newRec), + rec: this.getRecFromDbRec(newRec, userId), }; } @@ -196,7 +201,7 @@ export class RecService { ); } return { - rec: this.getRecFromDbRec(updatedRec), + rec: this.getRecFromDbRec(updatedRec, userId), }; } @@ -240,15 +245,41 @@ export class RecService { ); } - private getRecsFromDbRecs(dbRec: DbRec[]): Rec[] { - return dbRec.map(this.getRecFromDbRec); + private getRecsFromDbRecs(dbRecs: DbRec[], authUserId: string | null): Rec[] { + return dbRecs.map((dbRec) => this.getRecFromDbRec(dbRec, authUserId)); } - private getRecFromDbRec(dbRec: DbRec): Rec { + private getRecFromDbRec(dbRec: DbRec, authUserId: string | null): Rec { + let selfReactions: ReactionType[] = []; + if (authUserId) { + selfReactions = dbRec.reactions + .filter((reaction) => reaction.userId == authUserId) + .map((reaction) => reaction.reaction); + } + + const reactionCounts = dbRec.reactions.reduce( + (acc: Record, reaction: DbRecReaction) => { + if (!acc[reaction.reaction]) { + acc[reaction.reaction] = 0; + } + acc[reaction.reaction] += 1; + return acc; + }, + {} as Record + ); + const numReactions = Object.keys(reactionCounts).map((reactionType) => ({ + type: reactionType as ReactionType, + count: reactionCounts[reactionType as ReactionType], + })); + return { ...dbRec, cutoff: dbRec.cutoff.toISOString(), user: transformUserPreview(dbRec.user), + reactions: { + selfReactions, + numReactions, + }, }; } diff --git a/libs/recnet-api-model/src/lib/model.ts b/libs/recnet-api-model/src/lib/model.ts index 0e692f35..70da6b1d 100644 --- a/libs/recnet-api-model/src/lib/model.ts +++ b/libs/recnet-api-model/src/lib/model.ts @@ -63,6 +63,15 @@ export const recSchema = z.object({ cutoff: dateSchema, user: userPreviewSchema, article: articleSchema, + reactions: z.object({ + selfReactions: z.array(reactionTypeSchema), + numReactions: z.array( + z.object({ + type: reactionTypeSchema, + count: z.number(), + }) + ), + }), }); export type Rec = z.infer; From ead234e9fbea5361fe55f01c7123b7bca515cf67 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Sat, 12 Oct 2024 22:59:43 -0400 Subject: [PATCH 05/15] refactor: extract transformer method --- .../src/modules/email/email.service.ts | 22 +------ .../recnet-api/src/modules/rec/rec.service.ts | 59 ++++--------------- .../src/modules/rec/rec.transformer.ts | 46 +++++++++++++++ 3 files changed, 60 insertions(+), 67 deletions(-) create mode 100644 apps/recnet-api/src/modules/rec/rec.transformer.ts diff --git a/apps/recnet-api/src/modules/email/email.service.ts b/apps/recnet-api/src/modules/email/email.service.ts index 45d3ea4c..db93c070 100644 --- a/apps/recnet-api/src/modules/email/email.service.ts +++ b/apps/recnet-api/src/modules/email/email.service.ts @@ -7,14 +7,12 @@ import groupBy from "lodash.groupby"; import { AppConfig, NodemailerConfig } from "@recnet-api/config/common.config"; import RecRepository from "@recnet-api/database/repository/rec.repository"; -import { - Rec as DbRec, - RecFilterBy, -} from "@recnet-api/database/repository/rec.repository.type"; +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 { 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"; @@ -28,8 +26,6 @@ import { import { SendMailResult, Transporter } from "./email.type"; import WeeklyDigest, { WeeklyDigestSubject } from "./templates/WeeklyDigest"; -import { transformUserPreview } from "../user/user.transformer"; - @Injectable() export class EmailService { constructor( @@ -125,7 +121,7 @@ export class EmailService { MAX_REC_PER_MAIL, filter ); - return dbRecs.map((dbRec) => this.getRecFromDbRec(dbRec)); + return dbRecs.map((dbRec) => transformRec(dbRec)); } private async sendWeeklyDigest( @@ -156,16 +152,4 @@ export class EmailService { return result; } - - private getRecFromDbRec(dbRec: DbRec): Rec { - return { - ...dbRec, - cutoff: dbRec.cutoff.toISOString(), - user: transformUserPreview(dbRec.user), - reactions: { - selfReactions: [], - numReactions: [], - }, - }; - } } diff --git a/apps/recnet-api/src/modules/rec/rec.service.ts b/apps/recnet-api/src/modules/rec/rec.service.ts index e42582b1..22198eb3 100644 --- a/apps/recnet-api/src/modules/rec/rec.service.ts +++ b/apps/recnet-api/src/modules/rec/rec.service.ts @@ -7,7 +7,6 @@ import RecRepository from "@recnet-api/database/repository/rec.repository"; import { Rec as DbRec, RecFilterBy, - RecReaction as DbRecReaction, } from "@recnet-api/database/repository/rec.repository.type"; import UserRepository from "@recnet-api/database/repository/user.repository"; import { getOffset } from "@recnet-api/utils"; @@ -16,7 +15,6 @@ import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; import { getCutOff } from "@recnet/recnet-date-fns"; -import { Rec } from "./entities/rec.entity"; import { CreateRecResponse, GetFeedsResponse, @@ -25,8 +23,7 @@ import { GetUpcomingRecResponse, UpdateRecResponse, } from "./rec.response"; - -import { transformUserPreview } from "../user/user.transformer"; +import { transformRec } from "./rec.transformer"; @Injectable() export class RecService { @@ -44,9 +41,13 @@ export class RecService { authUserId: string | null ): Promise { const dbRec = await this.recRepository.findRecById(recId); - return { rec: this.getRecFromDbRec(dbRec, authUserId) }; + return { rec: transformRec(dbRec, authUserId) }; } + /** + * @param userId is the user id of the user whose recs are being fetched + * @param authUserId is the user id of the user who is making the request + */ public async getRecs( page: number, pageSize: number, @@ -69,7 +70,7 @@ export class RecService { }; const recCount = await this.recRepository.countRecs(filter); const dbRecs = await this.recRepository.findRecs(page, pageSize, filter); - const recs = this.getRecsFromDbRecs(dbRecs, authUserId); + const recs = dbRecs.map((dbRec) => transformRec(dbRec, authUserId)); return { hasNext: recs.length + getOffset(page, pageSize) < recCount, @@ -94,7 +95,7 @@ export class RecService { }; const recCount = await this.recRepository.countRecs(filter); const dbRecs = await this.recRepository.findRecs(page, pageSize, filter); - const recs = this.getRecsFromDbRecs(dbRecs, userId); + const recs = dbRecs.map((dbRec) => transformRec(dbRec, userId)); return { hasNext: recs.length + getOffset(page, pageSize) < recCount, @@ -110,7 +111,7 @@ export class RecService { }; } return { - rec: this.getRecFromDbRec(dbRec, userId), + rec: transformRec(dbRec, userId), }; } @@ -151,7 +152,7 @@ export class RecService { articleIdToConnect ); return { - rec: this.getRecFromDbRec(newRec, userId), + rec: transformRec(newRec, userId), }; } @@ -201,7 +202,7 @@ export class RecService { ); } return { - rec: this.getRecFromDbRec(updatedRec, userId), + rec: transformRec(updatedRec, userId), }; } @@ -245,44 +246,6 @@ export class RecService { ); } - private getRecsFromDbRecs(dbRecs: DbRec[], authUserId: string | null): Rec[] { - return dbRecs.map((dbRec) => this.getRecFromDbRec(dbRec, authUserId)); - } - - private getRecFromDbRec(dbRec: DbRec, authUserId: string | null): Rec { - let selfReactions: ReactionType[] = []; - if (authUserId) { - selfReactions = dbRec.reactions - .filter((reaction) => reaction.userId == authUserId) - .map((reaction) => reaction.reaction); - } - - const reactionCounts = dbRec.reactions.reduce( - (acc: Record, reaction: DbRecReaction) => { - if (!acc[reaction.reaction]) { - acc[reaction.reaction] = 0; - } - acc[reaction.reaction] += 1; - return acc; - }, - {} as Record - ); - const numReactions = Object.keys(reactionCounts).map((reactionType) => ({ - type: reactionType as ReactionType, - count: reactionCounts[reactionType as ReactionType], - })); - - return { - ...dbRec, - cutoff: dbRec.cutoff.toISOString(), - user: transformUserPreview(dbRec.user), - reactions: { - selfReactions, - numReactions, - }, - }; - } - /** * @param article - CreateArticleInput | null * @param articleId - string | null diff --git a/apps/recnet-api/src/modules/rec/rec.transformer.ts b/apps/recnet-api/src/modules/rec/rec.transformer.ts new file mode 100644 index 00000000..f9cbadc5 --- /dev/null +++ b/apps/recnet-api/src/modules/rec/rec.transformer.ts @@ -0,0 +1,46 @@ +import { ReactionType } from "@prisma/client"; + +import { + Rec as DbRec, + RecReaction as DbRecReaction, +} from "@recnet-api/database/repository/rec.repository.type"; +import { transformUserPreview } from "@recnet-api/modules/user/user.transformer"; + +import { Rec } from "./entities/rec.entity"; + +export const transformRec = ( + dbRec: DbRec, + authUserId: string | null = null +): Rec => { + let selfReactions: ReactionType[] = []; + if (authUserId) { + selfReactions = dbRec.reactions + .filter((reaction) => reaction.userId == authUserId) + .map((reaction) => reaction.reaction); + } + + const reactionCounts = dbRec.reactions.reduce( + (acc: Record, reaction: DbRecReaction) => { + if (!acc[reaction.reaction]) { + acc[reaction.reaction] = 0; + } + acc[reaction.reaction] += 1; + return acc; + }, + {} as Record + ); + const numReactions = Object.keys(reactionCounts).map((reactionType) => ({ + type: reactionType as ReactionType, + count: reactionCounts[reactionType as ReactionType], + })); + + return { + ...dbRec, + cutoff: dbRec.cutoff.toISOString(), + user: transformUserPreview(dbRec.user), + reactions: { + selfReactions, + numReactions, + }, + }; +}; From 4bf18632bbb74e20edaccbb1d1bb3c8c77e3c7d4 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 14 Oct 2024 17:32:40 -0400 Subject: [PATCH 06/15] chore: add cache gh action output step --- libs/recnet-release-action/action.yml | 31 +++++++++++++++++-- .../scripts/hash_source.sh | 8 +++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 libs/recnet-release-action/scripts/hash_source.sh diff --git a/libs/recnet-release-action/action.yml b/libs/recnet-release-action/action.yml index 5438b351..ddd11772 100644 --- a/libs/recnet-release-action/action.yml +++ b/libs/recnet-release-action/action.yml @@ -24,19 +24,46 @@ runs: using: "composite" steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 with: version: 8.15.5 + - uses: actions/setup-node@v3 with: node-version: 20 cache: "pnpm" - - run: pnpm install --frozen-lockfile + + - name: Get hash of source files + id: hash + run: echo "hash=$(bash ./libs/recnet-release-action/scripts/hash_source.sh)" >> $GITHUB_OUTPUT + shell: bash + + - name: Cache build output + uses: actions/cache@v3 + id: cache + with: + path: ./libs/recnet-release-action/dist + key: ${{ runner.os }}-recnet-release-action-${{ steps.hash.outputs.hash }} + + - name: Display hash and cache status + run: | + echo "Source hash: ${{ steps.hash.outputs.hash }}" + echo "Cache hit: ${{ steps.cache.outputs.cache-hit == 'true' && 'Yes' || 'No' }}" + shell: bash + + - name: Install dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: pnpm install --frozen-lockfile shell: sh - - run: pnpm nx build recnet-release-action + + - name: Build + if: steps.cache.outputs.cache-hit != 'true' + run: pnpm nx build recnet-release-action shell: sh env: NX_NO_CLOUD: true + - run: | export GITHUB_TOKEN="${{ inputs.github-token }}" export HEAD_BRANCH="${{ inputs.head-branch }}" diff --git a/libs/recnet-release-action/scripts/hash_source.sh b/libs/recnet-release-action/scripts/hash_source.sh new file mode 100644 index 00000000..1284171e --- /dev/null +++ b/libs/recnet-release-action/scripts/hash_source.sh @@ -0,0 +1,8 @@ +# Change to the src directory +cd "$(dirname "$0")/../src" || exit 1 + +# Calculate hash of all files in the src directory +hash=$(find . -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) + +# Output the hash +echo "$hash" From 4a1274e80c40589344db78663e55abc58f17fee1 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 14 Oct 2024 17:32:48 -0400 Subject: [PATCH 07/15] chore: test --- .github/workflows/release-action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index abe79aa8..c9f3e9ee 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -3,8 +3,8 @@ name: RecNet Prod-Release Pipeline on: workflow_dispatch: {} push: - branches: - - dev + # branches: + # - dev jobs: release: From 74978d74552825f07a4154b70ac356aa393048a8 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 14 Oct 2024 17:37:49 -0400 Subject: [PATCH 08/15] revert: revert testing change --- .github/workflows/release-action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index c9f3e9ee..abe79aa8 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -3,8 +3,8 @@ name: RecNet Prod-Release Pipeline on: workflow_dispatch: {} push: - # branches: - # - dev + branches: + - dev jobs: release: From d584f52958e5ee0cf1b52ad0514b5b7c5e60d9d4 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 14 Oct 2024 23:34:42 -0400 Subject: [PATCH 09/15] chore: add new trpc procedure: include bearer token optionally --- apps/recnet/src/server/routers/middleware.ts | 26 ++++++++++++++++++++ apps/recnet/src/server/routers/rec.ts | 6 ++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/recnet/src/server/routers/middleware.ts b/apps/recnet/src/server/routers/middleware.ts index e76fc641..1a676522 100644 --- a/apps/recnet/src/server/routers/middleware.ts +++ b/apps/recnet/src/server/routers/middleware.ts @@ -108,6 +108,32 @@ export const checkRecnetJWTProcedure = publicProcedure.use(async (opts) => { }); }); +export const checkOptionalRecnetJWTProcedure = publicProcedure.use( + async (opts) => { + const tokens = await getTokenServerSide(); + const parseRes = recnetTokenSchema.safeParse(tokens); + if (!tokens || !parseRes.success) { + // if no token, just continue + return opts.next({ + ctx: { + ...opts.ctx, + }, + }); + } + const user = await getUserByTokens(); + const recnetApiInstance = createRecnetApiInstanceWithToken(tokens); + + return opts.next({ + ctx: { + ...opts.ctx, + tokens: parseRes.data, + user: user, + recnetApi: recnetApiInstance, + }, + }); + } +); + export const checkIsAdminProcedure = publicProcedure.use(async (opts) => { const tokens = await getTokenServerSide(); const parseRes = recnetJwtPayloadSchema.safeParse(tokens?.decodedToken); diff --git a/apps/recnet/src/server/routers/rec.ts b/apps/recnet/src/server/routers/rec.ts index b09874b3..d0d70684 100644 --- a/apps/recnet/src/server/routers/rec.ts +++ b/apps/recnet/src/server/routers/rec.ts @@ -14,14 +14,14 @@ import { import { checkIsAdminProcedure, + checkOptionalRecnetJWTProcedure, checkRecnetJWTProcedure, - publicApiProcedure, } from "./middleware"; import { router } from "../trpc"; export const recRouter = router({ - getRecById: publicApiProcedure + getRecById: checkOptionalRecnetJWTProcedure .input( z.object({ id: z.string(), @@ -33,7 +33,7 @@ export const recRouter = router({ const { data } = await recnetApi.get(`/recs/rec/${opts.input.id}`); return getRecIdResponseSchema.parse(data); }), - getHistoricalRecs: publicApiProcedure + getHistoricalRecs: checkOptionalRecnetJWTProcedure .input( z.object({ userId: z.string(), From 8d2c46dca3488bcf4b0e01b79fb639526eff9f02 Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Mon, 14 Oct 2024 23:36:56 -0400 Subject: [PATCH 10/15] chore: use nullish --- apps/recnet-api/src/modules/rec/rec.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/recnet-api/src/modules/rec/rec.controller.ts b/apps/recnet-api/src/modules/rec/rec.controller.ts index f76e2651..974c30dd 100644 --- a/apps/recnet-api/src/modules/rec/rec.controller.ts +++ b/apps/recnet-api/src/modules/rec/rec.controller.ts @@ -71,7 +71,7 @@ export class RecController { @Param("id") id: string, @UserOptional() authUser: AuthOptionalUser ): Promise { - const authUserId = authUser ? authUser.userId : null; + const authUserId = authUser?.userId ?? null; return this.recService.getRec(id, authUserId); } @@ -89,7 +89,7 @@ export class RecController { @UserOptional() authUser: AuthOptionalUser ): Promise { const { page, pageSize, userId } = dto; - const authUserId = authUser ? authUser.userId : null; + const authUserId = authUser?.userId ?? null; // Get the Recs to current date to avoid upcoming rec from showing in a user's profile page const to = new Date(); From 13d5d1d373ae70af9f8498c4f5bc3dd98690618f Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 14 Oct 2024 23:46:14 -0400 Subject: [PATCH 11/15] feat: add add/remove reaction procedure --- apps/recnet/src/server/routers/rec.ts | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/recnet/src/server/routers/rec.ts b/apps/recnet/src/server/routers/rec.ts index d0d70684..7fcfdc10 100644 --- a/apps/recnet/src/server/routers/rec.ts +++ b/apps/recnet/src/server/routers/rec.ts @@ -10,6 +10,8 @@ import { postRecsUpcomingRequestSchema, patchRecsUpcomingRequestSchema, getRecIdResponseSchema, + postRecsReactionsRequestSchema, + deleteRecReactionParamsSchema, } from "@recnet/recnet-api-model"; import { @@ -106,6 +108,34 @@ export const recRouter = router({ const { recnetApi } = opts.ctx; await recnetApi.delete(`/recs/upcoming`); }), + addReaction: checkRecnetJWTProcedure + .input( + postRecsReactionsRequestSchema.extend({ + recId: z.string(), + }) + ) + .mutation(async (opts) => { + const { recnetApi } = opts.ctx; + const { recId, reaction } = opts.input; + await recnetApi.post(`/recs/rec/${recId}/reactions`, { + reaction, + }); + }), + removeReaction: checkRecnetJWTProcedure + .input( + deleteRecReactionParamsSchema.extend({ + recId: z.string(), + }) + ) + .mutation(async (opts) => { + const { recnetApi } = opts.ctx; + const { recId, reaction } = opts.input; + await recnetApi.delete(`/recs/rec/${recId}/reactions`, { + params: { + ...deleteRecReactionParamsSchema.parse({ reaction }), + }, + }); + }), getNumOfRecs: checkIsAdminProcedure .output( z.object({ From fb2ea7dfd27094e59463c0c1b155891c2a41a0f2 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 15 Oct 2024 00:39:46 -0400 Subject: [PATCH 12/15] feat: finish reaction feature --- .../src/app/rec/[id]/RecPageContent.tsx | 2 + .../src/app/rec/[id]/RecReactionsList.tsx | 147 ++++++++++++++++++ apps/recnet/src/server/routers/rec.ts | 4 +- 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 apps/recnet/src/app/rec/[id]/RecReactionsList.tsx diff --git a/apps/recnet/src/app/rec/[id]/RecPageContent.tsx b/apps/recnet/src/app/rec/[id]/RecPageContent.tsx index d99a3e3f..4afb7776 100644 --- a/apps/recnet/src/app/rec/[id]/RecPageContent.tsx +++ b/apps/recnet/src/app/rec/[id]/RecPageContent.tsx @@ -7,6 +7,7 @@ import { LinkPreview } from "@recnet/recnet-web/components/LinkPreview"; import { SelfRecBadge } from "@recnet/recnet-web/components/SelfRecBadge"; import { formatDate } from "@recnet/recnet-date-fns"; +import { RecReactionsList } from "./RecReactionsList"; const fallbackImage = "https://recnet.io/_next/image?url=%2Frecnet-logo.webp&w=64&q=100"; @@ -54,6 +55,7 @@ export async function RecPageContent(props: { id: string }) { fallbackImage } /> + ); } diff --git a/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx b/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx new file mode 100644 index 00000000..4b8b5adf --- /dev/null +++ b/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx @@ -0,0 +1,147 @@ +"use client"; +import { FaceIcon } from "@radix-ui/react-icons"; +import { Popover, Button, Flex, Spinner } from "@radix-ui/themes"; +import { toast } from "sonner"; + +import { useAuth } from "@recnet/recnet-web/app/AuthContext"; +import { trpc } from "@recnet/recnet-web/app/_trpc/client"; +import { cn } from "@recnet/recnet-web/utils/cn"; + +import { ReactionType, reactionTypeSchema } from "@recnet/recnet-api-model"; + +const reactionEmojiMap: Record = { + THUMBS_UP: "👍", + THINKING: "🤔", + SURPRISED: "😲", + CRYING: "😢", + STARRY_EYES: "🤩", + MINDBLOWN: "🤯", + EYES: "👀", + ROCKET: "🚀", + HEART: "❤️", + PRAY: "🙏", + PARTY: "🎉", +}; + +function ReactionButton(props: { onSelect: (reaction: ReactionType) => void }) { + const { onSelect } = props; + + return ( + + +
+ +
+
+ + + {reactionTypeSchema.options.map((reaction) => ( + + + + ))} + + +
+ ); +} + +function ReactionChip(props: { + onClick: (reaction: ReactionType) => void; + reaction: ReactionType; + count: number; + isSelected?: boolean; +}) { + const { onClick, reaction, count, isSelected = false } = props; + + return ( +
{ + onClick(reaction); + }} + > +
{reactionEmojiMap[reaction]}
+
{count}
+
+ ); +} + +export function RecReactionsList(props: { id: string }) { + const { id } = props; + const { user } = useAuth(); + const { data, isLoading, refetch } = trpc.getRecById.useQuery({ + id, + }); + + const addReactionMutation = trpc.addReaction.useMutation(); + const removeReactionMutation = trpc.removeReaction.useMutation(); + + const onClickReaction = async (reaction: ReactionType) => { + if (isLoading || !data) { + return; + } + if (!user) { + toast.error("You need to be logged in to react"); + return; + } + // if yes, send reaction mutation + // if this reaction is already selected, remove it + // else, add it + const isReactionSelected = + data.rec.reactions.selfReactions.includes(reaction); + if (isReactionSelected) { + await removeReactionMutation.mutateAsync({ + recId: id, + reaction, + }); + } else { + await addReactionMutation.mutateAsync({ + recId: id, + reaction, + }); + } + // refresh the list + refetch(); + }; + + return ( +
+ + {isLoading ? ( + + ) : !data ? null : ( + data.rec.reactions.numReactions.map((reactionCountPair) => { + return ( + + ); + }) + )} +
+ ); +} diff --git a/apps/recnet/src/server/routers/rec.ts b/apps/recnet/src/server/routers/rec.ts index 7fcfdc10..e78b18d3 100644 --- a/apps/recnet/src/server/routers/rec.ts +++ b/apps/recnet/src/server/routers/rec.ts @@ -117,7 +117,7 @@ export const recRouter = router({ .mutation(async (opts) => { const { recnetApi } = opts.ctx; const { recId, reaction } = opts.input; - await recnetApi.post(`/recs/rec/${recId}/reactions`, { + await recnetApi.post(`/recs/${recId}/reactions`, { reaction, }); }), @@ -130,7 +130,7 @@ export const recRouter = router({ .mutation(async (opts) => { const { recnetApi } = opts.ctx; const { recId, reaction } = opts.input; - await recnetApi.delete(`/recs/rec/${recId}/reactions`, { + await recnetApi.delete(`/recs/${recId}/reactions`, { params: { ...deleteRecReactionParamsSchema.parse({ reaction }), }, From 5224b88cadb0820d13b0102e0b2f6ddbddf98fa5 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Wed, 16 Oct 2024 17:07:34 -0400 Subject: [PATCH 13/15] feat: add reactions to rec card --- apps/recnet/src/app/rec/[id]/RecReactionsList.tsx | 2 +- apps/recnet/src/components/RecCard.tsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx b/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx index 4b8b5adf..34a203a8 100644 --- a/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx +++ b/apps/recnet/src/app/rec/[id]/RecReactionsList.tsx @@ -29,7 +29,7 @@ function ReactionButton(props: { onSelect: (reaction: ReactionType) => void }) { return ( -
+
diff --git a/apps/recnet/src/components/RecCard.tsx b/apps/recnet/src/components/RecCard.tsx index d380fadd..def3e967 100644 --- a/apps/recnet/src/components/RecCard.tsx +++ b/apps/recnet/src/components/RecCard.tsx @@ -17,6 +17,7 @@ import { LinkCopyButton } from "./LinkCopyButton"; import { SelfRecBadge } from "./SelfRecBadge"; import { getSharableLink } from "../utils/getSharableRecLink"; +import { RecReactionsList } from "../app/rec/[id]/RecReactionsList"; export function RecCardSkeleton() { return ( @@ -152,6 +153,9 @@ export function RecCard(props: { recs: Rec[]; showDate?: boolean }) { +
+ +
); From a7f7350cc32cb981d9670f70cddab8dfcb45ffa3 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Wed, 16 Oct 2024 19:07:41 -0400 Subject: [PATCH 14/15] chore: version bump --- apps/recnet/CHANGELOG.md | 18 ++++++++++++++++++ apps/recnet/package.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/recnet/CHANGELOG.md b/apps/recnet/CHANGELOG.md index 1890d394..ff46d8e4 100644 --- a/apps/recnet/CHANGELOG.md +++ b/apps/recnet/CHANGELOG.md @@ -2,6 +2,24 @@ 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.15.0](https://github.com/lil-lab/recnet/compare/recnet-web-v1.14.2...recnet-web-v1.15.0) (2024-10-16) + + +### Features + +* add add/remove reaction procedure ([13d5d1d](https://github.com/lil-lab/recnet/commit/13d5d1d373ae70af9f8498c4f5bc3dd98690618f)) +* add AuthOptional decorator ([d804f48](https://github.com/lil-lab/recnet/commit/d804f48c407d01d93588b588960086016ecab1b9)) +* add reactions to rec card ([5224b88](https://github.com/lil-lab/recnet/commit/5224b88cadb0820d13b0102e0b2f6ddbddf98fa5)) +* add reactions to rec response ([cf809e8](https://github.com/lil-lab/recnet/commit/cf809e8f2929c2e3648c1fec36b26bcc434edc70)) +* add verified badge ([26d8533](https://github.com/lil-lab/recnet/commit/26d8533c50434eb60e979feca53443503775a901)) +* finish reaction feature ([fb2ea7d](https://github.com/lil-lab/recnet/commit/fb2ea7dfd27094e59463c0c1b155891c2a41a0f2)) + + +### Bug Fixes + +* adjust ui ([7cb3f5e](https://github.com/lil-lab/recnet/commit/7cb3f5edddfa908df89139e5068b6956066aa1f8)) +* delete unused action ([1546bef](https://github.com/lil-lab/recnet/commit/1546bef2c51f8dc713b85a9b18e160fd6a4eb2a3)) + ## [1.14.2](https://github.com/lil-lab/recnet/compare/recnet-web-v1.14.1...recnet-web-v1.14.2) (2024-10-09) diff --git a/apps/recnet/package.json b/apps/recnet/package.json index 63d8e9a6..fb80c376 100644 --- a/apps/recnet/package.json +++ b/apps/recnet/package.json @@ -1,6 +1,6 @@ { "name": "recnet", - "version": "1.14.2", + "version": "1.15.0", "commit-and-tag-version": { "skip": { "commit": true From 6cb0c95eea34287663e1c212eb91d2fde635f7bb Mon Sep 17 00:00:00 2001 From: joannechen1223 Date: Wed, 16 Oct 2024 23:40:41 -0400 Subject: [PATCH 15/15] chore: update recnet-api to v1.7.1 --- apps/recnet-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/recnet-api/package.json b/apps/recnet-api/package.json index ebded217..178393be 100644 --- a/apps/recnet-api/package.json +++ b/apps/recnet-api/package.json @@ -1,4 +1,4 @@ { "name": "recnet-api", - "version": "1.7.0" + "version": "1.7.1" }