diff --git a/prisma/migrations/20260803164651_add_review_comment_and_option_group_description/migration.sql b/prisma/migrations/20260803164651_add_review_comment_and_option_group_description/migration.sql new file mode 100644 index 0000000..50b3832 --- /dev/null +++ b/prisma/migrations/20260803164651_add_review_comment_and_option_group_description/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE `product_option_group` ADD COLUMN `description` VARCHAR(1000) NULL; + +-- CreateTable +CREATE TABLE `review_comment` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `review_id` BIGINT UNSIGNED NOT NULL, + `account_id` BIGINT UNSIGNED NOT NULL, + `content` VARCHAR(500) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + `deleted_at` DATETIME(3) NULL, + + INDEX `idx_review_comment_review`(`review_id`, `created_at`), + INDEX `idx_review_comment_account`(`account_id`), + INDEX `idx_review_comment_deleted_at`(`deleted_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `review_comment` ADD CONSTRAINT `review_comment_review_id_fkey` FOREIGN KEY (`review_id`) REFERENCES `review`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `review_comment` ADD CONSTRAINT `review_comment_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260803175209_index_review_comment_by_pagination_key/migration.sql b/prisma/migrations/20260803175209_index_review_comment_by_pagination_key/migration.sql new file mode 100644 index 0000000..2c123c6 --- /dev/null +++ b/prisma/migrations/20260803175209_index_review_comment_by_pagination_key/migration.sql @@ -0,0 +1,5 @@ +-- 커서 쿼리(review_id = ? AND id > ? ORDER BY id) 정합 인덱스로 교체. +-- FK(review_id)가 인덱스를 요구하므로 DROP/ADD를 단일 ALTER로 원자 처리한다. +ALTER TABLE `review_comment` + DROP INDEX `idx_review_comment_review`, + ADD INDEX `idx_review_comment_review` (`review_id`, `id`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 445240e..00b54f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -156,6 +156,7 @@ model Account { store_wishlist_items StoreWishlistItem[] review_likes ReviewLike[] + review_comments ReviewComment[] store_conversations StoreConversation[] sent_conversation_messages StoreConversationMessage[] @relation("ConversationMessageSender") store Store? @relation("StoreSellerAccount") @@ -594,6 +595,8 @@ model ProductOptionGroup { product_id BigInt @db.UnsignedBigInt name String @db.VarChar(120) + // 그룹 수준 안내 문구(상품 상세 옵션 섹션 인트로. 예: 맛 옵션 크림 설명) + description String? @db.VarChar(1000) is_required Boolean @default(true) @db.TinyInt min_select Int @default(1) @db.UnsignedSmallInt max_select Int @default(1) @db.UnsignedSmallInt @@ -1082,6 +1085,7 @@ model Review { media ReviewMedia[] likes ReviewLike[] + comments ReviewComment[] notifications Notification[] @@index([store_id, created_at], map: "idx_review_store") @@ -1111,6 +1115,27 @@ model ReviewMedia { @@map("review_media") } +model ReviewComment { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + review_id BigInt @db.UnsignedBigInt + account_id BigInt @db.UnsignedBigInt + + content String @db.VarChar(500) + + created_at DateTime @default(now()) @db.DateTime(3) + updated_at DateTime @updatedAt @db.DateTime(3) + deleted_at DateTime? @db.DateTime(3) + + review Review @relation(fields: [review_id], references: [id]) + account Account @relation(fields: [account_id], references: [id]) + + // 커서 쿼리(review_id = ? AND id > ? ORDER BY id) 정합 인덱스 + @@index([review_id, id], map: "idx_review_comment_review") + @@index([account_id], map: "idx_review_comment_account") + @@index([deleted_at], map: "idx_review_comment_deleted_at") + @@map("review_comment") +} + /** * ========================= * 10) Notification diff --git a/src/features/product/constants/product-detail-error-messages.ts b/src/features/product/constants/product-detail-error-messages.ts new file mode 100644 index 0000000..15e372c --- /dev/null +++ b/src/features/product/constants/product-detail-error-messages.ts @@ -0,0 +1,4 @@ +/** 상품 상세 조회 에러 메시지. */ +export const PRODUCT_DETAIL_ERRORS = { + PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', +} as const; diff --git a/src/features/product/constants/product-review-error-messages.ts b/src/features/product/constants/product-review-error-messages.ts new file mode 100644 index 0000000..a001956 --- /dev/null +++ b/src/features/product/constants/product-review-error-messages.ts @@ -0,0 +1,5 @@ +/** 상품 리뷰 조회 에러 메시지. */ +export const PRODUCT_REVIEW_ERRORS = { + REVIEW_NOT_FOUND: '리뷰를 찾을 수 없습니다.', + INVALID_LIKES_CURSOR: '좋아요순 커서 형식이 올바르지 않습니다.', +} as const; diff --git a/src/features/product/constants/product-review.constants.ts b/src/features/product/constants/product-review.constants.ts new file mode 100644 index 0000000..864ce92 --- /dev/null +++ b/src/features/product/constants/product-review.constants.ts @@ -0,0 +1,5 @@ +/** 상품 리뷰 목록 기본 페이지 크기. */ +export const DEFAULT_PRODUCT_REVIEWS_LIMIT = 20; + +/** 리뷰 댓글 목록 기본 페이지 크기. */ +export const DEFAULT_REVIEW_COMMENTS_LIMIT = 20; diff --git a/src/features/product/dto/inputs/product-reviews.input.ts b/src/features/product/dto/inputs/product-reviews.input.ts new file mode 100644 index 0000000..318dd26 --- /dev/null +++ b/src/features/product/dto/inputs/product-reviews.input.ts @@ -0,0 +1,38 @@ +import { + IsBoolean, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export const PRODUCT_REVIEW_SORTS = ['LATEST', 'LIKES'] as const; +export type ProductReviewSort = (typeof PRODUCT_REVIEW_SORTS)[number]; + +export class ProductReviewsInput { + @IsString() + @IsNotEmpty() + productId!: string; + + @IsOptional() + @IsBoolean() + photoOnly?: boolean; + + @IsOptional() + @IsIn(PRODUCT_REVIEW_SORTS) + sort?: ProductReviewSort; + + @IsOptional() + @IsString() + @IsNotEmpty() + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/src/features/product/dto/inputs/review-comments.input.ts b/src/features/product/dto/inputs/review-comments.input.ts new file mode 100644 index 0000000..3bfd240 --- /dev/null +++ b/src/features/product/dto/inputs/review-comments.input.ts @@ -0,0 +1,25 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class ReviewCommentsInput { + @IsString() + @IsNotEmpty() + reviewId!: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/src/features/product/product-detail.graphql b/src/features/product/product-detail.graphql new file mode 100644 index 0000000..21a8722 --- /dev/null +++ b/src/features/product/product-detail.graphql @@ -0,0 +1,52 @@ +extend type Query { + """상품 상세. 없거나 비활성/삭제 상품(또는 매장)이면 NOT_FOUND. 비로그인 접근 가능.""" + productDetail(productId: ID!): ProductDetail! +} + +"""상품 상세(구매자용).""" +type ProductDetail { + id: ID! + """소속 매장 ID(주문/매장 이동용).""" + storeId: ID! + name: String! + description: String + """구매 전 필독사항.""" + purchaseNotice: String + """상품 이미지(캐러셀·썸네일, sort_order asc).""" + images: [String!]! + regularPrice: Int! + salePrice: Int + """할인율(0~100). salePrice 없으면 0.""" + discountRate: Int! + currency: String! + """후기 탭 카운트.""" + reviewCount: Int! + """로그인 사용자의 찜 여부(비로그인 시 false).""" + isWishlisted: Boolean! + """옵션 섹션(케이크 사이즈/맛 등, sort_order asc). 활성 그룹만.""" + optionGroups: [ProductDetailOptionGroup!]! +} + +"""상품 상세 옵션 섹션(그룹).""" +type ProductDetailOptionGroup { + id: ID! + name: String! + """그룹 안내 문구(섹션 인트로).""" + description: String + isRequired: Boolean! + minSelect: Int! + maxSelect: Int! + sortOrder: Int! + items: [ProductDetailOptionItem!]! +} + +"""상품 상세 옵션 항목.""" +type ProductDetailOptionItem { + id: ID! + title: String! + description: String + imageUrl: String + """옵션 선택 시 가격 증감(원).""" + priceDelta: Int! + sortOrder: Int! +} diff --git a/src/features/product/product-reviews.graphql b/src/features/product/product-reviews.graphql new file mode 100644 index 0000000..9588d01 --- /dev/null +++ b/src/features/product/product-reviews.graphql @@ -0,0 +1,127 @@ +extend type Query { + """상품 공개 리뷰 목록(커서). 사진 필터·정렬 지원. 비로그인 접근 가능.""" + productReviews(input: ProductReviewsInput!): ProductReviewConnection! + + """리뷰 상세(후기 상세 화면). 없거나 삭제된 리뷰면 NOT_FOUND. 비로그인 접근 가능.""" + reviewDetail(reviewId: ID!): ReviewDetail! + + """리뷰 댓글 목록(등록순, 커서). 비로그인 접근 가능.""" + reviewComments(input: ReviewCommentsInput!): ReviewCommentConnection! +} + +input ProductReviewsInput { + productId: ID! + """true면 사진(미디어) 있는 리뷰만(사진후기 그리드/사진후기 상세).""" + photoOnly: Boolean = false + sort: ProductReviewSort = LATEST + """이전 페이지의 nextCursor 값(불투명 토큰). 동일 sort에서만 유효.""" + cursor: ID + limit: Int = 20 +} + +"""상품 리뷰 정렬.""" +enum ProductReviewSort { + """최신순.""" + LATEST + """좋아요순(동률이면 최신순).""" + LIKES +} + +"""상품 리뷰 목록(커서 기반).""" +type ProductReviewConnection { + items: [ProductReview!]! + """전체 리뷰 수(후기 탭 카운트). 0이면 빈 상태.""" + totalCount: Int! + """사진 리뷰 수(사진후기 카운트).""" + photoTotalCount: Int! + hasMore: Boolean! + nextCursor: ID +} + +"""상품 공개 리뷰.""" +type ProductReview { + id: ID! + """평점(0.0~5.0).""" + rating: Float! + content: String + media: [ProductReviewMedia!]! + likeCount: Int! + """로그인 사용자의 좋아요 여부(비로그인 시 false).""" + isLiked: Boolean! + """댓글 수.""" + commentCount: Int! + """작성자 닉네임(탈퇴 시 null).""" + authorNickname: String + """작성자 프로필 이미지(탈퇴/미설정 시 null).""" + authorProfileImageUrl: String + """주문 시 선택 옵션 스냅샷(커스텀 정보: 모양/크기/맛 등).""" + customOptions: [ReviewCustomOption!]! + createdAt: DateTime! +} + +"""상품 리뷰 첨부 미디어.""" +type ProductReviewMedia { + mediaType: ReviewMediaType! + mediaUrl: String! + thumbnailUrl: String + sortOrder: Int! +} + +"""리뷰의 주문 옵션 스냅샷(커스텀 정보 행).""" +type ReviewCustomOption { + """옵션 그룹명 스냅샷(예: 모양/크기/맛).""" + groupName: String! + """선택 옵션명 스냅샷(예: (기본) 동그라미).""" + optionTitle: String! +} + +"""리뷰 상세(후기 상세 화면).""" +type ReviewDetail { + """리뷰 본문(목록 카드와 동일 구조, content 전문).""" + review: ProductReview! + """판매 케이크 정보(현재 상품 기준).""" + product: ReviewDetailProduct! +} + +"""리뷰 상세 상단 판매 케이크 정보.""" +type ReviewDetailProduct { + productId: ID! + name: String! + """대표 이미지(sort_order 최소). 없으면 null.""" + thumbnailUrl: String + storeName: String! + """매장 위치 표기(예: 인천 청라동).""" + regionLabel: String + regularPrice: Int! + salePrice: Int + """할인율(0~100). salePrice 없으면 0.""" + discountRate: Int! +} + +input ReviewCommentsInput { + reviewId: ID! + """이전 페이지 마지막 댓글 id(이후부터 조회).""" + cursor: ID + limit: Int = 20 +} + +"""리뷰 댓글 목록(등록순, 커서 기반).""" +type ReviewCommentConnection { + items: [ReviewCommentItem!]! + totalCount: Int! + hasMore: Boolean! + nextCursor: ID +} + +"""리뷰 댓글.""" +type ReviewCommentItem { + id: ID! + content: String! + """작성자 닉네임(탈퇴 시 null).""" + authorNickname: String + """작성자 프로필 이미지(탈퇴/미설정 시 null).""" + authorProfileImageUrl: String + """로그인 사용자 본인 댓글 여부(삭제 버튼 노출용, 비로그인 시 false).""" + isMine: Boolean! + createdAt: DateTime! +} diff --git a/src/features/product/product.module.ts b/src/features/product/product.module.ts index 34b7b10..89f6910 100644 --- a/src/features/product/product.module.ts +++ b/src/features/product/product.module.ts @@ -1,12 +1,22 @@ import { Module } from '@nestjs/common'; +import { ProductReviewRepository } from '@/features/product/repositories/product-review.repository'; import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductDetailQueryResolver } from '@/features/product/resolvers/product-detail-query.resolver'; +import { ProductReviewQueryResolver } from '@/features/product/resolvers/product-review-query.resolver'; import { ProductStorefrontQueryResolver } from '@/features/product/resolvers/product-storefront-query.resolver'; +import { ProductDetailService } from '@/features/product/services/product-detail.service'; +import { ProductReviewService } from '@/features/product/services/product-review.service'; import { ProductStorefrontService } from '@/features/product/services/product-storefront.service'; @Module({ providers: [ ProductRepository, + ProductReviewRepository, + ProductDetailService, + ProductDetailQueryResolver, + ProductReviewService, + ProductReviewQueryResolver, ProductStorefrontService, ProductStorefrontQueryResolver, ], diff --git a/src/features/product/repositories/product-review.repository.ts b/src/features/product/repositories/product-review.repository.ts new file mode 100644 index 0000000..f61e0f6 --- /dev/null +++ b/src/features/product/repositories/product-review.repository.ts @@ -0,0 +1,382 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, type ReviewMediaType } from '@prisma/client'; + +import { PrismaService } from '@/prisma'; + +export interface ProductReviewMediaRow { + media_type: ReviewMediaType; + media_url: string; + thumbnail_url: string | null; + sort_order: number; +} + +/** 리뷰 작성자 프로필 row(탈퇴 여부 포함, 매퍼에서 익명화). */ +export interface ReviewAuthorRow { + user_profile: { + nickname: string; + profile_image_url: string | null; + deleted_at: Date | null; + } | null; +} + +/** 상품 공개 리뷰 조회 결과 row. productReviews 매퍼 입력. */ +export interface ProductReviewRow { + id: bigint; + rating: Prisma.Decimal; + content: string | null; + created_at: Date; + account: ReviewAuthorRow; + media: ProductReviewMediaRow[]; + order_item: { + option_items: { + group_name_snapshot: string; + option_title_snapshot: string; + }[]; + }; +} + +/** 리뷰 상세 상단 판매 케이크 정보 row. */ +export interface ReviewDetailProductRow { + id: bigint; + name: string; + regular_price: number; + sale_price: number | null; + images: { image_url: string }[]; + store: { + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; + }; +} + +export interface ReviewDetailRow extends ProductReviewRow { + product: ReviewDetailProductRow; +} + +/** 리뷰 댓글 row. */ +export interface ReviewCommentRow { + id: bigint; + content: string; + created_at: Date; + account_id: bigint; + account: ReviewAuthorRow; +} + +/** + * 상품 공개 리뷰 조회 전용 repository. + * + * store feature의 StoreReviewRepository(매장 단위)와 대칭 구조. + * 상품 상세 후기 탭(목록/사진후기/후기 상세/댓글) 유스케이스를 담당한다. + */ +@Injectable() +export class ProductReviewRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 공개 리뷰 공통 가드: 리뷰·상품·매장 모두 활성. */ + private publicReviewWhere(photoOnly: boolean): Prisma.ReviewWhereInput { + return { + deleted_at: null, + product: { is_active: true, deleted_at: null }, + store: { is_active: true, deleted_at: null }, + ...(photoOnly ? { media: { some: { deleted_at: null } } } : {}), + }; + } + + /** 상품 리뷰 id 페이지(최신순, 커서 id desc). */ + async listProductReviewIdsLatest(args: { + productId: bigint; + photoOnly: boolean; + limit: number; + cursor?: bigint; + }): Promise { + const rows = await this.prisma.review.findMany({ + where: { + product_id: args.productId, + ...this.publicReviewWhere(args.photoOnly), + // 0n도 유효 인자(parseId("0")=0n). truthiness는 0n을 falsy로 떨궈 + // zero cursor가 페이지를 리셋하므로 undefined로만 분기한다. + ...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}), + }, + select: { id: true }, + orderBy: { id: 'desc' }, + take: args.limit + 1, + }); + return rows.map((row) => row.id); + } + + /** + * 상품 리뷰 id 페이지(좋아요순 desc, 동률이면 id desc). + * + * soft-delete된 좋아요를 제외한 집계 기준 정렬이 Prisma orderBy(_count)로는 + * 불가능하므로 raw 키셋 페이지네이션으로 조회한다. 커서는 이전 페이지 경계의 + * (likeCount, id) 값을 그대로 받아 이어간다 — 경계 리뷰의 좋아요 수가 요청 + * 사이에 변해도 페이지가 중복/누락되지 않는다. + */ + async listProductReviewIdsByLikes(args: { + productId: bigint; + photoOnly: boolean; + limit: number; + cursor?: { likeCount: number; id: bigint }; + }): Promise<{ id: bigint; likeCount: number }[]> { + const photoFilter = args.photoOnly + ? Prisma.sql`AND EXISTS ( + SELECT 1 FROM review_media m + WHERE m.review_id = r.id AND m.deleted_at IS NULL + )` + : Prisma.empty; + const cursorHaving = + args.cursor !== undefined + ? Prisma.sql`HAVING COUNT(l.id) < ${args.cursor.likeCount} + OR (COUNT(l.id) = ${args.cursor.likeCount} AND r.id < ${args.cursor.id})` + : Prisma.empty; + + const rows = await this.prisma.$queryRaw< + { id: bigint; like_count: bigint }[] + >(Prisma.sql` + SELECT r.id AS id, COUNT(l.id) AS like_count + FROM review r + JOIN product p + ON p.id = r.product_id AND p.is_active = 1 AND p.deleted_at IS NULL + JOIN store s + ON s.id = p.store_id AND s.is_active = 1 AND s.deleted_at IS NULL + LEFT JOIN review_like l + ON l.review_id = r.id AND l.deleted_at IS NULL + WHERE r.product_id = ${args.productId} AND r.deleted_at IS NULL + ${photoFilter} + GROUP BY r.id + ${cursorHaving} + ORDER BY like_count DESC, r.id DESC + LIMIT ${args.limit + 1} + `); + return rows.map((row) => ({ + id: row.id, + likeCount: Number(row.like_count), + })); + } + + /** 상품 활성 리뷰 수(photoOnly=true면 사진 리뷰 수). */ + async countProductReviews(args: { + productId: bigint; + photoOnly: boolean; + }): Promise { + return this.prisma.review.count({ + where: { + product_id: args.productId, + ...this.publicReviewWhere(args.photoOnly), + }, + }); + } + + /** id 페이지의 리뷰 본문 row 일괄 조회(정렬은 service에서 id 순서로 복원). */ + async findProductReviewRowsByIds( + reviewIds: bigint[], + ): Promise { + if (reviewIds.length === 0) return []; + return this.prisma.review.findMany({ + where: { id: { in: reviewIds }, deleted_at: null }, + select: { + id: true, + rating: true, + content: true, + created_at: true, + account: { + // soft-delete extension은 nested relation에 deleted_at을 주입하지 않으므로 + // deleted_at을 함께 읽어 탈퇴 작성자는 매퍼에서 익명화한다 + select: { + user_profile: { + select: { + nickname: true, + profile_image_url: true, + deleted_at: true, + }, + }, + }, + }, + media: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { + media_type: true, + media_url: true, + thumbnail_url: true, + sort_order: true, + }, + }, + order_item: { + select: { + option_items: { + where: { deleted_at: null }, + orderBy: { id: 'asc' }, + select: { + group_name_snapshot: true, + option_title_snapshot: true, + }, + }, + }, + }, + }, + }); + } + + /** 리뷰 상세(본문 + 판매 케이크 정보). 리뷰·상품·매장 활성 가드. */ + async findReviewDetailById( + reviewId: bigint, + ): Promise { + return this.prisma.review.findFirst({ + where: { id: reviewId, ...this.publicReviewWhere(false) }, + select: { + id: true, + rating: true, + content: true, + created_at: true, + account: { + select: { + user_profile: { + select: { + nickname: true, + profile_image_url: true, + deleted_at: true, + }, + }, + }, + }, + media: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { + media_type: true, + media_url: true, + thumbnail_url: true, + sort_order: true, + }, + }, + order_item: { + select: { + option_items: { + where: { deleted_at: null }, + orderBy: { id: 'asc' }, + select: { + group_name_snapshot: true, + option_title_snapshot: true, + }, + }, + }, + }, + product: { + select: { + id: true, + name: true, + regular_price: true, + sale_price: true, + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + store: { + select: { + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }, + }, + }, + }, + }); + } + + /** 공개 리뷰 존재 여부(댓글 목록 진입 가드). */ + async existsPublicReview(reviewId: bigint): Promise { + const found = await this.prisma.review.findFirst({ + where: { id: reviewId, ...this.publicReviewWhere(false) }, + select: { id: true }, + }); + return Boolean(found); + } + + /** 리뷰별 좋아요 수. */ + async aggregateLikeCounts(reviewIds: bigint[]): Promise> { + if (reviewIds.length === 0) return new Map(); + const rows = await this.prisma.reviewLike.groupBy({ + by: ['review_id'], + where: { review_id: { in: reviewIds }, deleted_at: null }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.review_id, r._count._all])); + } + + /** 로그인 사용자가 좋아요한 review_id 집합(string). */ + async findLikedReviewIds(args: { + reviewIds: bigint[]; + accountId: bigint; + }): Promise> { + if (args.reviewIds.length === 0) return new Set(); + const rows = await this.prisma.reviewLike.findMany({ + where: { + review_id: { in: args.reviewIds }, + account_id: args.accountId, + deleted_at: null, + }, + select: { review_id: true }, + }); + return new Set(rows.map((r) => r.review_id.toString())); + } + + /** 리뷰별 댓글 수. */ + async aggregateCommentCounts( + reviewIds: bigint[], + ): Promise> { + if (reviewIds.length === 0) return new Map(); + const rows = await this.prisma.reviewComment.groupBy({ + by: ['review_id'], + where: { review_id: { in: reviewIds }, deleted_at: null }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.review_id, r._count._all])); + } + + /** 리뷰 댓글 목록(등록순, 커서 id asc). soft-delete 제외. */ + async listReviewComments(args: { + reviewId: bigint; + limit: number; + cursor?: bigint; + }): Promise { + return this.prisma.reviewComment.findMany({ + where: { + review_id: args.reviewId, + deleted_at: null, + ...(args.cursor !== undefined ? { id: { gt: args.cursor } } : {}), + }, + select: { + id: true, + content: true, + created_at: true, + account_id: true, + account: { + select: { + user_profile: { + select: { + nickname: true, + profile_image_url: true, + deleted_at: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + take: args.limit + 1, + }); + } + + /** 리뷰 활성 댓글 수. */ + async countReviewComments(reviewId: bigint): Promise { + return this.prisma.reviewComment.count({ + where: { review_id: reviewId, deleted_at: null }, + }); + } +} diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index b248ca3..8f24ec7 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -24,6 +24,36 @@ export interface StoreProductCategoryRow { product_count: number; } +/** 구매자 상품 상세 row. product-detail 매퍼 입력. */ +export interface ProductDetailRow { + id: bigint; + store_id: bigint; + name: string; + description: string | null; + purchase_notice: string | null; + regular_price: number; + sale_price: number | null; + currency: string; + images: { image_url: string }[]; + option_groups: { + id: bigint; + name: string; + description: string | null; + is_required: boolean; + min_select: number; + max_select: number; + sort_order: number; + option_items: { + id: bigint; + title: string; + description: string | null; + image_url: string | null; + price_delta: number; + sort_order: number; + }[]; + }[]; +} + @Injectable() export class ProductRepository { constructor(private readonly prisma: PrismaService) {} @@ -796,6 +826,86 @@ export class ProductRepository { }); } + /** + * 구매자 상품 상세. 활성 상품(+활성 매장)만. 이미지·옵션 그룹/아이템 포함. + * nested relation은 soft-delete extension이 root만 patch하므로 가드를 명시한다. + */ + async findProductDetailById( + productId: bigint, + ): Promise { + return this.prisma.product.findFirst({ + where: { + id: productId, + is_active: true, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }, + select: { + id: true, + store_id: true, + name: true, + description: true, + purchase_notice: true, + regular_price: true, + sale_price: true, + currency: true, + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { image_url: true }, + }, + option_groups: { + where: { is_active: true, deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { + id: true, + name: true, + description: true, + is_required: true, + min_select: true, + max_select: true, + sort_order: true, + option_items: { + where: { is_active: true, deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { + id: true, + title: true, + description: true, + image_url: true, + price_delta: true, + sort_order: true, + }, + }, + }, + }, + }, + }); + } + + /** 상품 활성 리뷰 수(후기 탭 카운트). */ + async countProductReviews(productId: bigint): Promise { + return this.prisma.review.count({ + where: { product_id: productId, deleted_at: null }, + }); + } + + /** 로그인 사용자의 상품 찜 여부. */ + async isProductWishlisted(args: { + accountId: bigint; + productId: bigint; + }): Promise { + const found = await this.prisma.wishlistItem.findFirst({ + where: { + account_id: args.accountId, + product_id: args.productId, + deleted_at: null, + }, + select: { id: true }, + }); + return Boolean(found); + } + /** * 매장이 보유한 활성 상품의 카테고리(사이드바). 빈 카테고리 제외. * sort_order asc, productCount는 이 매장의 활성 상품 기준. diff --git a/src/features/product/resolvers/product-detail-query.resolver.spec.ts b/src/features/product/resolvers/product-detail-query.resolver.spec.ts new file mode 100644 index 0000000..68d0fe4 --- /dev/null +++ b/src/features/product/resolvers/product-detail-query.resolver.spec.ts @@ -0,0 +1,73 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductDetailQueryResolver } from '@/features/product/resolvers/product-detail-query.resolver'; +import { ProductDetailService } from '@/features/product/services/product-detail.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createProduct } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/필터 세부 검증은 service.spec.ts에서 담당. + */ +describe('ProductDetail Query Resolver (real DB)', () => { + let resolver: ProductDetailQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ProductDetailQueryResolver, + ProductDetailService, + ProductRepository, + ], + }); + resolver = module.get(ProductDetailQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('productDetail: 비로그인 사용자에게 상품 상세를 반환한다', async () => { + const product = await createProduct(prisma, { name: '그림일기 케이크' }); + + const result = await resolver.productDetail( + product.id.toString(), + undefined, + ); + + expect(result.id).toBe(product.id.toString()); + expect(result.name).toBe('그림일기 케이크'); + expect(result.isWishlisted).toBe(false); + }); + + it('productDetail: 로그인 사용자(JwtUser)의 찜 여부를 채운다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const product = await createProduct(prisma); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: product.id }, + }); + + const result = await resolver.productDetail(product.id.toString(), { + accountId: account.id.toString(), + }); + + expect(result.isWishlisted).toBe(true); + }); + + it('productDetail: 없는 상품은 NotFoundException', async () => { + await expect( + resolver.productDetail('999999', undefined), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/features/product/resolvers/product-detail-query.resolver.ts b/src/features/product/resolvers/product-detail-query.resolver.ts new file mode 100644 index 0000000..c70db1a --- /dev/null +++ b/src/features/product/resolvers/product-detail-query.resolver.ts @@ -0,0 +1,30 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { ProductDetailService } from '@/features/product/services/product-detail.service'; +import type { ProductDetail } from '@/features/product/types/product-detail-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 구매자 상품 상세 resolver. 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. + */ +@Resolver('Query') +export class ProductDetailQueryResolver { + constructor(private readonly service: ProductDetailService) {} + + @Query('productDetail') + @UseGuards(OptionalJwtAuthGuard) + productDetail( + @Args('productId') productId: string, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.productDetail(productId, accountId); + } +} diff --git a/src/features/product/resolvers/product-review-query.resolver.spec.ts b/src/features/product/resolvers/product-review-query.resolver.spec.ts new file mode 100644 index 0000000..e993a3e --- /dev/null +++ b/src/features/product/resolvers/product-review-query.resolver.spec.ts @@ -0,0 +1,132 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ProductReviewRepository } from '@/features/product/repositories/product-review.repository'; +import { ProductReviewQueryResolver } from '@/features/product/resolvers/product-review-query.resolver'; +import { ProductReviewService } from '@/features/product/services/product-review.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrderItem, + createProduct, + createReview, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/필터 세부 검증은 service.spec.ts에서 담당. + */ +describe('ProductReview Query Resolver (real DB)', () => { + let resolver: ProductReviewQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ProductReviewQueryResolver, + ProductReviewService, + ProductReviewRepository, + ], + }); + resolver = module.get(ProductReviewQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('productReviews: 비로그인 사용자에게 리뷰 목록을 반환한다', async () => { + const product = await createProduct(prisma); + const orderItem = await createOrderItem(prisma, { product_id: product.id }); + const review = await createReview(prisma, { order_item_id: orderItem.id }); + + const result = await resolver.productReviews( + { productId: product.id.toString() }, + undefined, + ); + + expect(result.items.map((r) => r.id)).toEqual([review.id.toString()]); + expect(result.totalCount).toBe(1); + expect(result.items[0].isLiked).toBe(false); + }); + + it('reviewDetail: 로그인 사용자(JwtUser)의 isLiked를 채운다', async () => { + const product = await createProduct(prisma); + const orderItem = await createOrderItem(prisma, { product_id: product.id }); + const review = await createReview(prisma, { order_item_id: orderItem.id }); + const liker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker.id }, + }); + + const result = await resolver.reviewDetail(review.id.toString(), { + accountId: liker.id.toString(), + }); + + expect(result.review.isLiked).toBe(true); + expect(result.review.likeCount).toBe(1); + expect(result.product.productId).toBe(product.id.toString()); + }); + + it('productReviews: 로그인 사용자(JwtUser)의 isLiked를 채운다', async () => { + const product = await createProduct(prisma); + const orderItem = await createOrderItem(prisma, { product_id: product.id }); + const review = await createReview(prisma, { order_item_id: orderItem.id }); + const liker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker.id }, + }); + + const result = await resolver.productReviews( + { productId: product.id.toString() }, + { accountId: liker.id.toString() }, + ); + + expect(result.items[0].isLiked).toBe(true); + }); + + it('reviewDetail: 비로그인 사용자는 isLiked=false', async () => { + const product = await createProduct(prisma); + const orderItem = await createOrderItem(prisma, { product_id: product.id }); + const review = await createReview(prisma, { order_item_id: orderItem.id }); + + const result = await resolver.reviewDetail(review.id.toString(), undefined); + + expect(result.review.isLiked).toBe(false); + }); + + it('reviewComments: 로그인 사용자(JwtUser)의 isMine을 채운다', async () => { + const product = await createProduct(prisma); + const orderItem = await createOrderItem(prisma, { product_id: product.id }); + const review = await createReview(prisma, { order_item_id: orderItem.id }); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewComment.create({ + data: { + review_id: review.id, + account_id: commenter.id, + content: '내 댓글', + }, + }); + + const result = await resolver.reviewComments( + { reviewId: review.id.toString() }, + { accountId: commenter.id.toString() }, + ); + + expect(result.items[0].isMine).toBe(true); + }); + + it('reviewComments: 없는 리뷰는 NotFoundException', async () => { + await expect( + resolver.reviewComments({ reviewId: '999999' }, undefined), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/features/product/resolvers/product-review-query.resolver.ts b/src/features/product/resolvers/product-review-query.resolver.ts new file mode 100644 index 0000000..7e4f989 --- /dev/null +++ b/src/features/product/resolvers/product-review-query.resolver.ts @@ -0,0 +1,56 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { ProductReviewsInput } from '@/features/product/dto/inputs/product-reviews.input'; +import { ReviewCommentsInput } from '@/features/product/dto/inputs/review-comments.input'; +import { ProductReviewService } from '@/features/product/services/product-review.service'; +import type { + ProductReviewConnection, + ReviewCommentConnection, + ReviewDetail, +} from '@/features/product/types/product-review-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 상품 공개 리뷰 조회 resolver. 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isLiked/isMine을 채운다. + */ +@Resolver('Query') +export class ProductReviewQueryResolver { + constructor(private readonly service: ProductReviewService) {} + + @Query('productReviews') + @UseGuards(OptionalJwtAuthGuard) + productReviews( + @Args('input') input: ProductReviewsInput, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.productReviews(input, accountId); + } + + @Query('reviewDetail') + @UseGuards(OptionalJwtAuthGuard) + reviewDetail( + @Args('reviewId') reviewId: string, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.reviewDetail(reviewId, accountId); + } + + @Query('reviewComments') + @UseGuards(OptionalJwtAuthGuard) + reviewComments( + @Args('input') input: ReviewCommentsInput, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.reviewComments(input, accountId); + } +} diff --git a/src/features/product/services/product-detail-mappers.helper.ts b/src/features/product/services/product-detail-mappers.helper.ts new file mode 100644 index 0000000..1f837fe --- /dev/null +++ b/src/features/product/services/product-detail-mappers.helper.ts @@ -0,0 +1,41 @@ +import type { ProductDetailRow } from '@/features/product/repositories/product.repository'; +import { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; +import type { ProductDetail } from '@/features/product/types/product-detail-output.type'; + +export function toProductDetail( + row: ProductDetailRow, + reviewCount: number, + isWishlisted: boolean, +): ProductDetail { + return { + id: row.id.toString(), + storeId: row.store_id.toString(), + name: row.name, + description: row.description, + purchaseNotice: row.purchase_notice, + images: row.images.map((image) => image.image_url), + regularPrice: row.regular_price, + salePrice: row.sale_price, + discountRate: calcDiscountRate(row.regular_price, row.sale_price), + currency: row.currency, + reviewCount, + isWishlisted, + optionGroups: row.option_groups.map((group) => ({ + id: group.id.toString(), + name: group.name, + description: group.description, + isRequired: group.is_required, + minSelect: group.min_select, + maxSelect: group.max_select, + sortOrder: group.sort_order, + items: group.option_items.map((item) => ({ + id: item.id.toString(), + title: item.title, + description: item.description, + imageUrl: item.image_url, + priceDelta: item.price_delta, + sortOrder: item.sort_order, + })), + })), + }; +} diff --git a/src/features/product/services/product-detail.service.spec.ts b/src/features/product/services/product-detail.service.spec.ts new file mode 100644 index 0000000..54717a2 --- /dev/null +++ b/src/features/product/services/product-detail.service.spec.ts @@ -0,0 +1,216 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductDetailService } from '@/features/product/services/product-detail.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrderItem, + createProduct, + createReview, + createStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ProductDetailService (real DB)', () => { + let service: ProductDetailService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ProductDetailService, ProductRepository], + }); + service = module.get(ProductDetailService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('존재하지 않는 상품은 NotFoundException', async () => { + await expect(service.productDetail('999999')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('비활성 상품은 NotFoundException', async () => { + const product = await createProduct(prisma, { is_active: false }); + await expect( + service.productDetail(product.id.toString()), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('비활성 매장의 상품은 NotFoundException', async () => { + const store = await createStore(prisma, { is_active: false }); + const product = await createProduct(prisma, { store_id: store.id }); + await expect( + service.productDetail(product.id.toString()), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('잘못된 id 형식은 BadRequestException', async () => { + await expect(service.productDetail('abc')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('상세 필드·할인율·이미지(sort_order asc)를 반환한다', async () => { + const product = await createProduct(prisma, { + name: '그림일기 케이크', + description: '설명 텍스트', + regular_price: 35000, + sale_price: 33000, + }); + await prisma.product.update({ + where: { id: product.id }, + data: { purchase_notice: '픽업 후 이동 중 케이크가 흔들릴 수 있습니다.' }, + }); + // sort_order 역순 생성 → asc 정렬 확인 + await prisma.productImage.create({ + data: { product_id: product.id, image_url: 'second.png', sort_order: 1 }, + }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: 'first.png', sort_order: 0 }, + }); + await prisma.productImage.create({ + data: { + product_id: product.id, + image_url: 'deleted.png', + sort_order: 2, + deleted_at: new Date(), + }, + }); + + const result = await service.productDetail(product.id.toString()); + + expect(result).toMatchObject({ + id: product.id.toString(), + storeId: product.store_id.toString(), + name: '그림일기 케이크', + description: '설명 텍스트', + purchaseNotice: '픽업 후 이동 중 케이크가 흔들릴 수 있습니다.', + regularPrice: 35000, + salePrice: 33000, + // (1 - 33000/35000) * 100 ≈ 5.71 → 반올림 6 + discountRate: 6, + currency: 'KRW', + isWishlisted: false, + reviewCount: 0, + }); + expect(result.images).toEqual(['first.png', 'second.png']); + }); + + it('옵션 그룹/아이템은 활성만 sort_order asc로 반환하고 그룹 설명을 포함한다', async () => { + const product = await createProduct(prisma); + const flavorGroup = await prisma.productOptionGroup.create({ + data: { + product_id: product.id, + name: '케이크 맛 옵션', + description: '동구리 특제 크림으로 제작됩니다.', + sort_order: 1, + }, + }); + await prisma.productOptionGroup.create({ + data: { product_id: product.id, name: '케이크 사이즈', sort_order: 0 }, + }); + await prisma.productOptionGroup.create({ + data: { + product_id: product.id, + name: '비활성 그룹', + sort_order: 2, + is_active: false, + }, + }); + await prisma.productOptionItem.create({ + data: { + option_group_id: flavorGroup.id, + title: '고구마 100프로', + description: '바닐라빈 시트 + 고구마크림', + price_delta: 2000, + sort_order: 1, + }, + }); + await prisma.productOptionItem.create({ + data: { option_group_id: flavorGroup.id, title: '기본', sort_order: 0 }, + }); + await prisma.productOptionItem.create({ + data: { + option_group_id: flavorGroup.id, + title: '삭제된 항목', + sort_order: 2, + deleted_at: new Date(), + }, + }); + + const result = await service.productDetail(product.id.toString()); + + expect(result.optionGroups.map((g) => g.name)).toEqual([ + '케이크 사이즈', + '케이크 맛 옵션', + ]); + const flavor = result.optionGroups[1]; + expect(flavor.description).toBe('동구리 특제 크림으로 제작됩니다.'); + expect(flavor.items.map((item) => item.title)).toEqual([ + '기본', + '고구마 100프로', + ]); + expect(flavor.items[1].priceDelta).toBe(2000); + }); + + it('리뷰 수를 집계하고 soft-delete 리뷰는 제외한다', async () => { + const product = await createProduct(prisma); + const orderItem1 = await createOrderItem(prisma, { + product_id: product.id, + }); + await createReview(prisma, { order_item_id: orderItem1.id }); + const orderItem2 = await createOrderItem(prisma, { + product_id: product.id, + }); + const deletedReview = await createReview(prisma, { + order_item_id: orderItem2.id, + }); + await prisma.review.update({ + where: { id: deletedReview.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.productDetail(product.id.toString()); + + expect(result.reviewCount).toBe(1); + }); + + it('로그인 사용자의 찜 상품은 isWishlisted=true, 찜 해제(soft-delete)면 false', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const product = await createProduct(prisma); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: product.id }, + }); + + const loggedIn = await service.productDetail( + product.id.toString(), + account.id, + ); + expect(loggedIn.isWishlisted).toBe(true); + + const anonymous = await service.productDetail(product.id.toString()); + expect(anonymous.isWishlisted).toBe(false); + + await prisma.wishlistItem.updateMany({ + where: { account_id: account.id, product_id: product.id }, + data: { deleted_at: new Date() }, + }); + const afterRemoval = await service.productDetail( + product.id.toString(), + account.id, + ); + expect(afterRemoval.isWishlisted).toBe(false); + }); +}); diff --git a/src/features/product/services/product-detail.service.ts b/src/features/product/services/product-detail.service.ts new file mode 100644 index 0000000..e3af6b0 --- /dev/null +++ b/src/features/product/services/product-detail.service.ts @@ -0,0 +1,36 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { PRODUCT_DETAIL_ERRORS } from '@/features/product/constants/product-detail-error-messages'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { toProductDetail } from '@/features/product/services/product-detail-mappers.helper'; +import type { ProductDetail } from '@/features/product/types/product-detail-output.type'; + +@Injectable() +export class ProductDetailService { + constructor(private readonly repo: ProductRepository) {} + + /** + * 상품 상세. 비활성/삭제 상품(또는 매장)은 NOT_FOUND. + * 리뷰 수는 실시간 집계, isWishlisted는 로그인 사용자에 한해 채운다(비로그인 false). + */ + async productDetail( + productIdRaw: string, + accountId?: bigint, + ): Promise { + const productId = parseId(productIdRaw); + const row = await this.repo.findProductDetailById(productId); + if (!row) { + throw new NotFoundException(PRODUCT_DETAIL_ERRORS.PRODUCT_NOT_FOUND); + } + + const [reviewCount, isWishlisted] = await Promise.all([ + this.repo.countProductReviews(productId), + accountId !== undefined + ? this.repo.isProductWishlisted({ accountId, productId }) + : Promise.resolve(false), + ]); + + return toProductDetail(row, reviewCount, isWishlisted); + } +} diff --git a/src/features/product/services/product-review-mappers.helper.ts b/src/features/product/services/product-review-mappers.helper.ts new file mode 100644 index 0000000..f0d2df6 --- /dev/null +++ b/src/features/product/services/product-review-mappers.helper.ts @@ -0,0 +1,103 @@ +import type { + ProductReviewRow, + ReviewAuthorRow, + ReviewCommentRow, + ReviewDetailProductRow, +} from '@/features/product/repositories/product-review.repository'; +import { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; +import type { + ProductReview, + ReviewCommentItem, + ReviewDetailProduct, +} from '@/features/product/types/product-review-output.type'; + +/** 리뷰별 집계값(좋아요/댓글/isLiked) 매퍼 입력. */ +export interface ProductReviewStats { + likeCount: number; + isLiked: boolean; + commentCount: number; +} + +/** 탈퇴(soft-delete) 작성자는 닉네임/프로필을 익명화한다. */ +function toAuthor(account: ReviewAuthorRow): { + nickname: string | null; + profileImageUrl: string | null; +} { + const profile = account.user_profile; + if (!profile || profile.deleted_at !== null) { + return { nickname: null, profileImageUrl: null }; + } + return { + nickname: profile.nickname, + profileImageUrl: profile.profile_image_url, + }; +} + +export function toProductReview( + row: ProductReviewRow, + stats: ProductReviewStats, +): ProductReview { + const author = toAuthor(row.account); + return { + id: row.id.toString(), + rating: Number(row.rating), + content: row.content, + media: row.media.map((m) => ({ + mediaType: m.media_type, + mediaUrl: m.media_url, + thumbnailUrl: m.thumbnail_url, + sortOrder: m.sort_order, + })), + likeCount: stats.likeCount, + isLiked: stats.isLiked, + commentCount: stats.commentCount, + authorNickname: author.nickname, + authorProfileImageUrl: author.profileImageUrl, + customOptions: row.order_item.option_items.map((option) => ({ + groupName: option.group_name_snapshot, + optionTitle: option.option_title_snapshot, + })), + createdAt: row.created_at, + }; +} + +/** 매장 위치 표기. address_city/neighborhood 우선, 없으면 region명. */ +function buildRegionLabel( + store: ReviewDetailProductRow['store'], +): string | null { + const parts = [store.address_city, store.address_neighborhood].filter( + (part): part is string => Boolean(part), + ); + if (parts.length > 0) return parts.join(' '); + return store.region?.name ?? null; +} + +export function toReviewDetailProduct( + row: ReviewDetailProductRow, +): ReviewDetailProduct { + return { + productId: row.id.toString(), + name: row.name, + thumbnailUrl: row.images[0]?.image_url ?? null, + storeName: row.store.store_name, + regionLabel: buildRegionLabel(row.store), + regularPrice: row.regular_price, + salePrice: row.sale_price, + discountRate: calcDiscountRate(row.regular_price, row.sale_price), + }; +} + +export function toReviewCommentItem( + row: ReviewCommentRow, + accountId: bigint | undefined, +): ReviewCommentItem { + const author = toAuthor(row.account); + return { + id: row.id.toString(), + content: row.content, + authorNickname: author.nickname, + authorProfileImageUrl: author.profileImageUrl, + isMine: accountId !== undefined && row.account_id === accountId, + createdAt: row.created_at, + }; +} diff --git a/src/features/product/services/product-review.service.spec.ts b/src/features/product/services/product-review.service.spec.ts new file mode 100644 index 0000000..d041369 --- /dev/null +++ b/src/features/product/services/product-review.service.spec.ts @@ -0,0 +1,593 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient, Product, Review } from '@prisma/client'; + +import { ProductReviewRepository } from '@/features/product/repositories/product-review.repository'; +import { ProductReviewService } from '@/features/product/services/product-review.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrder, + createOrderItem, + createProduct, + createReview, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ProductReviewService (real DB)', () => { + let service: ProductReviewService; + let repo: ProductReviewRepository; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ProductReviewService, ProductReviewRepository], + }); + service = module.get(ProductReviewService); + repo = module.get(ProductReviewRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + /** 상품에 리뷰 1건 생성(작성자 프로필 포함 옵션). */ + async function createProductReview( + product: Product, + args: { + nickname?: string; + profileImageUrl?: string; + content?: string | null; + mediaUrls?: string[]; + } = {}, + ): Promise { + const account = await createAccount(prisma, { account_type: 'USER' }); + if (args.nickname) { + await createUserProfile(prisma, { + account_id: account.id, + nickname: args.nickname, + profile_image_url: args.profileImageUrl ?? null, + }); + } + const order = await createOrder(prisma, { account_id: account.id }); + const orderItem = await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + const review = await createReview(prisma, { + order_item_id: orderItem.id, + content: args.content, + }); + for (const [index, url] of (args.mediaUrls ?? []).entries()) { + await prisma.reviewMedia.create({ + data: { review_id: review.id, media_url: url, sort_order: index }, + }); + } + return review; + } + + /** 리뷰에 좋아요 n개 생성(각각 다른 사용자). */ + async function addLikes(reviewId: bigint, count: number): Promise { + for (let i = 0; i < count; i += 1) { + const liker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: reviewId, account_id: liker.id }, + }); + } + } + + describe('productReviews', () => { + it('잘못된 id 형식은 BadRequestException', async () => { + await expect( + service.productReviews({ productId: 'abc' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('최신순(id desc) 목록과 totalCount/photoTotalCount를 반환한다', async () => { + const product = await createProduct(prisma); + const first = await createProductReview(product); + const second = await createProductReview(product, { + mediaUrls: ['photo.png'], + }); + + const result = await service.productReviews({ + productId: product.id.toString(), + }); + + expect(result.items.map((r) => r.id)).toEqual([ + second.id.toString(), + first.id.toString(), + ]); + expect(result.totalCount).toBe(2); + expect(result.photoTotalCount).toBe(1); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('커서 페이지네이션: limit 초과 시 hasMore=true, 다음 페이지로 이어진다', async () => { + const product = await createProduct(prisma); + const reviews = []; + for (let i = 0; i < 3; i += 1) { + reviews.push(await createProductReview(product)); + } + + const page1 = await service.productReviews({ + productId: product.id.toString(), + limit: 2, + }); + expect(page1.items).toHaveLength(2); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBe(reviews[1].id.toString()); + + const page2 = await service.productReviews({ + productId: product.id.toString(), + limit: 2, + cursor: page1.nextCursor!, + }); + expect(page2.items.map((r) => r.id)).toEqual([reviews[0].id.toString()]); + expect(page2.hasMore).toBe(false); + }); + + it('photoOnly=true면 활성 미디어가 있는 리뷰만 반환한다', async () => { + const product = await createProduct(prisma); + await createProductReview(product); + const withPhoto = await createProductReview(product, { + mediaUrls: ['a.png'], + }); + const deletedMediaReview = await createProductReview(product, { + mediaUrls: ['b.png'], + }); + await prisma.reviewMedia.updateMany({ + where: { review_id: deletedMediaReview.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.productReviews({ + productId: product.id.toString(), + photoOnly: true, + }); + + expect(result.items.map((r) => r.id)).toEqual([withPhoto.id.toString()]); + expect(result.photoTotalCount).toBe(1); + }); + + it('좋아요순 정렬: soft-delete 좋아요 제외 집계, 동률이면 최신순', async () => { + const product = await createProduct(prisma); + const zeroLikes = await createProductReview(product); + const twoLikes = await createProductReview(product); + const threeLikes = await createProductReview(product); + await addLikes(twoLikes.id, 2); + await addLikes(threeLikes.id, 3); + // soft-delete된 좋아요는 집계에서 제외 → twoLikes는 2개 유지 + const canceledLiker = await createAccount(prisma, { + account_type: 'USER', + }); + await prisma.reviewLike.create({ + data: { + review_id: twoLikes.id, + account_id: canceledLiker.id, + deleted_at: new Date(), + }, + }); + + const result = await service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + }); + + expect(result.items.map((r) => r.id)).toEqual([ + threeLikes.id.toString(), + twoLikes.id.toString(), + zeroLikes.id.toString(), + ]); + expect(result.items.map((r) => r.likeCount)).toEqual([3, 2, 0]); + }); + + it('좋아요순 + photoOnly 조합: 사진 리뷰만 좋아요순으로 반환한다', async () => { + const product = await createProduct(prisma); + const textOnlyPopular = await createProductReview(product); + await addLikes(textOnlyPopular.id, 5); + const photoFew = await createProductReview(product, { + mediaUrls: ['a.png'], + }); + await addLikes(photoFew.id, 1); + const photoMany = await createProductReview(product, { + mediaUrls: ['b.png'], + }); + await addLikes(photoMany.id, 3); + + const result = await service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + photoOnly: true, + }); + + // 좋아요 5개인 텍스트 리뷰는 photoOnly에서 제외된다 + expect(result.items.map((r) => r.id)).toEqual([ + photoMany.id.toString(), + photoFew.id.toString(), + ]); + }); + + it('좋아요순 커서: (likeCount, id) 키셋으로 다음 페이지를 이어받는다', async () => { + const product = await createProduct(prisma); + const reviewA = await createProductReview(product); + const reviewB = await createProductReview(product); + const reviewC = await createProductReview(product); + await addLikes(reviewA.id, 2); + await addLikes(reviewB.id, 2); + await addLikes(reviewC.id, 1); + + // 동률(2)은 id desc → B, A 순. limit=2로 첫 페이지 [B, A] + const page1 = await service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + limit: 2, + }); + expect(page1.items.map((r) => r.id)).toEqual([ + reviewB.id.toString(), + reviewA.id.toString(), + ]); + expect(page1.hasMore).toBe(true); + + const page2 = await service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + limit: 2, + cursor: page1.nextCursor!, + }); + expect(page2.items.map((r) => r.id)).toEqual([reviewC.id.toString()]); + expect(page2.hasMore).toBe(false); + }); + + it('좋아요순 커서: 경계 리뷰의 좋아요 수가 변해도 이전 페이지가 중복되지 않는다', async () => { + const product = await createProduct(prisma); + const reviewA = await createProductReview(product); + const reviewB = await createProductReview(product); + const reviewC = await createProductReview(product); + await addLikes(reviewA.id, 2); + await addLikes(reviewB.id, 2); + await addLikes(reviewC.id, 1); + + // 첫 페이지 [B(2), A(2)] — 커서에 경계 시점 좋아요 수(2)가 담긴다 + const page1 = await service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + limit: 2, + }); + expect(page1.items.map((r) => r.id)).toEqual([ + reviewB.id.toString(), + reviewA.id.toString(), + ]); + + // 경계 리뷰 A의 좋아요가 요청 사이에 5개로 늘어도 + await addLikes(reviewA.id, 3); + + // 두 번째 페이지는 경계 시점 기준으로 이어져 B가 중복 노출되지 않는다 + const page2 = await service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + limit: 2, + cursor: page1.nextCursor!, + }); + expect(page2.items.map((r) => r.id)).toEqual([reviewC.id.toString()]); + }); + + it('좋아요순 커서 형식이 잘못되면 BadRequestException', async () => { + const product = await createProduct(prisma); + + await expect( + service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + cursor: '123', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('좋아요순 커서 like count가 안전 정수 범위를 넘으면 BadRequestException', async () => { + const product = await createProduct(prisma); + + // 309자리 숫자는 정규식은 통과하지만 Number 변환 시 Infinity가 된다 + await expect( + service.productReviews({ + productId: product.id.toString(), + sort: 'LIKES', + cursor: `${'9'.repeat(309)}:1`, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('soft-delete 리뷰·비활성 상품 리뷰는 노출하지 않는다', async () => { + const product = await createProduct(prisma); + const review = await createProductReview(product); + await prisma.review.update({ + where: { id: review.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.productReviews({ + productId: product.id.toString(), + }); + expect(result.items).toHaveLength(0); + expect(result.totalCount).toBe(0); + + const inactiveProduct = await createProduct(prisma, { + is_active: false, + }); + await createProductReview(inactiveProduct); + const inactiveResult = await service.productReviews({ + productId: inactiveProduct.id.toString(), + }); + expect(inactiveResult.items).toHaveLength(0); + expect(inactiveResult.totalCount).toBe(0); + }); + + it('작성자·미디어·커스텀 정보·댓글 수·isLiked를 채운다', async () => { + const product = await createProduct(prisma); + const review = await createProductReview(product, { + nickname: '곰돌이빵', + profileImageUrl: 'profile.png', + mediaUrls: ['1.png', '2.png'], + }); + // 주문 옵션 스냅샷(커스텀 정보: 모양/크기/맛) + const group = await prisma.productOptionGroup.create({ + data: { product_id: product.id, name: '모양' }, + }); + const item = await prisma.productOptionItem.create({ + data: { option_group_id: group.id, title: '(기본) 동그라미' }, + }); + await prisma.orderItemOptionItem.create({ + data: { + order_item_id: review.order_item_id, + option_group_id: group.id, + option_item_id: item.id, + group_name_snapshot: '모양', + option_title_snapshot: '(기본) 동그라미', + }, + }); + // 댓글 2건(1건은 soft-delete → 카운트 제외) + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewComment.create({ + data: { + review_id: review.id, + account_id: commenter.id, + content: '너무 귀여워요', + }, + }); + await prisma.reviewComment.create({ + data: { + review_id: review.id, + account_id: commenter.id, + content: '삭제된 댓글', + deleted_at: new Date(), + }, + }); + const liker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker.id }, + }); + + const result = await service.productReviews( + { productId: product.id.toString() }, + liker.id, + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + authorNickname: '곰돌이빵', + authorProfileImageUrl: 'profile.png', + likeCount: 1, + isLiked: true, + commentCount: 1, + }); + expect(result.items[0].media.map((m) => m.mediaUrl)).toEqual([ + '1.png', + '2.png', + ]); + expect(result.items[0].customOptions).toEqual([ + { groupName: '모양', optionTitle: '(기본) 동그라미' }, + ]); + }); + + it('탈퇴(soft-delete) 작성자는 닉네임/프로필을 익명화한다', async () => { + const product = await createProduct(prisma); + const review = await createProductReview(product, { + nickname: '탈퇴예정', + profileImageUrl: 'gone.png', + }); + await prisma.userProfile.updateMany({ + where: { account_id: review.account_id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.productReviews({ + productId: product.id.toString(), + }); + + expect(result.items[0].authorNickname).toBeNull(); + expect(result.items[0].authorProfileImageUrl).toBeNull(); + }); + }); + + describe('reviewDetail', () => { + it('없는 리뷰는 NotFoundException', async () => { + await expect(service.reviewDetail('999999')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('soft-delete 리뷰는 NotFoundException', async () => { + const product = await createProduct(prisma); + const review = await createProductReview(product); + await prisma.review.update({ + where: { id: review.id }, + data: { deleted_at: new Date() }, + }); + + await expect( + service.reviewDetail(review.id.toString()), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('리뷰 본문과 판매 케이크 정보(현재 상품 가격 기준)를 반환한다', async () => { + const store = await createStore(prisma, { + store_name: '해즈케이크', + address_city: '인천', + address_neighborhood: '청라동', + }); + const product = await createProduct(prisma, { + store_id: store.id, + name: '그림일기 케이크', + regular_price: 35000, + sale_price: 33000, + }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: 'thumb.png', sort_order: 0 }, + }); + const review = await createProductReview(product, { + nickname: '곰돌이빵', + content: '전체 리뷰 본문', + }); + + const result = await service.reviewDetail(review.id.toString()); + + expect(result.review).toMatchObject({ + id: review.id.toString(), + content: '전체 리뷰 본문', + authorNickname: '곰돌이빵', + }); + expect(result.product).toEqual({ + productId: product.id.toString(), + name: '그림일기 케이크', + thumbnailUrl: 'thumb.png', + storeName: '해즈케이크', + regionLabel: '인천 청라동', + regularPrice: 35000, + salePrice: 33000, + discountRate: 6, + }); + }); + + it('address가 없으면 region명으로 regionLabel을 채운다', async () => { + const region = await prisma.region.create({ + data: { level: 2, name: '청라동', slug: 'cheongna', sort_order: 0 }, + }); + const store = await createStore(prisma); + // 팩토리 기본값(??)이 null override를 덮어쓰므로 직접 비운다 + await prisma.store.update({ + where: { id: store.id }, + data: { + address_city: null, + address_neighborhood: null, + region_id: region.id, + }, + }); + const product = await createProduct(prisma, { store_id: store.id }); + const review = await createProductReview(product); + + const result = await service.reviewDetail(review.id.toString()); + + expect(result.product.regionLabel).toBe('청라동'); + }); + }); + + describe('reviewComments', () => { + it('없는 리뷰는 NotFoundException', async () => { + await expect( + service.reviewComments({ reviewId: '999999' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('등록순(id asc) + 커서 + soft-delete 제외, isMine을 채운다', async () => { + const product = await createProduct(prisma); + const review = await createProductReview(product); + const me = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { + account_id: me.id, + nickname: '쫀뜩한샐러드', + }); + const other = await createAccount(prisma, { account_type: 'USER' }); + + const mine = await prisma.reviewComment.create({ + data: { review_id: review.id, account_id: me.id, content: '내 댓글' }, + }); + const others = await prisma.reviewComment.create({ + data: { + review_id: review.id, + account_id: other.id, + content: '남의 댓글', + }, + }); + await prisma.reviewComment.create({ + data: { + review_id: review.id, + account_id: other.id, + content: '삭제된 댓글', + deleted_at: new Date(), + }, + }); + + const page1 = await service.reviewComments( + { reviewId: review.id.toString(), limit: 1 }, + me.id, + ); + expect(page1.items.map((c) => c.id)).toEqual([mine.id.toString()]); + expect(page1.items[0]).toMatchObject({ + content: '내 댓글', + authorNickname: '쫀뜩한샐러드', + isMine: true, + }); + expect(page1.totalCount).toBe(2); + expect(page1.hasMore).toBe(true); + + const page2 = await service.reviewComments( + { reviewId: review.id.toString(), limit: 1, cursor: page1.nextCursor! }, + me.id, + ); + expect(page2.items.map((c) => c.id)).toEqual([others.id.toString()]); + expect(page2.items[0].isMine).toBe(false); + // 프로필 미생성 작성자는 닉네임 null + expect(page2.items[0].authorNickname).toBeNull(); + expect(page2.hasMore).toBe(false); + }); + + it('비로그인 사용자는 모든 댓글이 isMine=false', async () => { + const product = await createProduct(prisma); + const review = await createProductReview(product); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewComment.create({ + data: { + review_id: review.id, + account_id: commenter.id, + content: '댓글', + }, + }); + + const result = await service.reviewComments({ + reviewId: review.id.toString(), + }); + + expect(result.items[0].isMine).toBe(false); + }); + }); + + describe('repository 빈 입력 가드', () => { + it('reviewIds가 비면 쿼리 없이 빈 컬렉션을 반환한다', async () => { + await expect(repo.aggregateLikeCounts([])).resolves.toEqual(new Map()); + await expect(repo.aggregateCommentCounts([])).resolves.toEqual(new Map()); + await expect( + repo.findLikedReviewIds({ reviewIds: [], accountId: BigInt(1) }), + ).resolves.toEqual(new Set()); + await expect(repo.findProductReviewRowsByIds([])).resolves.toEqual([]); + }); + }); +}); diff --git a/src/features/product/services/product-review.service.ts b/src/features/product/services/product-review.service.ts new file mode 100644 index 0000000..3e8a384 --- /dev/null +++ b/src/features/product/services/product-review.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { PRODUCT_REVIEW_ERRORS } from '@/features/product/constants/product-review-error-messages'; +import { + DEFAULT_PRODUCT_REVIEWS_LIMIT, + DEFAULT_REVIEW_COMMENTS_LIMIT, +} from '@/features/product/constants/product-review.constants'; +import type { ProductReviewsInput } from '@/features/product/dto/inputs/product-reviews.input'; +import type { ReviewCommentsInput } from '@/features/product/dto/inputs/review-comments.input'; +import { ProductReviewRepository } from '@/features/product/repositories/product-review.repository'; +import { + toProductReview, + toReviewCommentItem, + toReviewDetailProduct, +} from '@/features/product/services/product-review-mappers.helper'; +import type { + ProductReview, + ProductReviewConnection, + ReviewCommentConnection, + ReviewDetail, +} from '@/features/product/types/product-review-output.type'; + +@Injectable() +export class ProductReviewService { + constructor(private readonly repo: ProductReviewRepository) {} + + /** + * 상품 공개 리뷰 목록(커서). 사진 필터·정렬(최신/좋아요) 지원. + * id 페이지를 먼저 정한 뒤 본문·집계를 일괄 hydrate한다. + */ + async productReviews( + input: ProductReviewsInput, + accountId?: bigint, + ): Promise { + const productId = parseId(input.productId); + const limit = input.limit ?? DEFAULT_PRODUCT_REVIEWS_LIMIT; + const photoOnly = input.photoOnly ?? false; + const sort = input.sort ?? 'LATEST'; + + const [idPage, totalCount, photoTotalCount] = await Promise.all([ + this.fetchReviewIdPage({ + productId, + photoOnly, + sort, + limit, + cursorRaw: input.cursor, + }), + this.repo.countProductReviews({ productId, photoOnly: false }), + this.repo.countProductReviews({ productId, photoOnly: true }), + ]); + + const items = await this.hydrateReviews(idPage.pageIds, accountId); + + return { + items, + totalCount, + photoTotalCount, + hasMore: idPage.hasMore, + nextCursor: idPage.nextCursor, + }; + } + + /** 리뷰 상세(본문 + 현재 상품 기준 판매 케이크 정보). 없으면 NOT_FOUND. */ + async reviewDetail( + reviewIdRaw: string, + accountId?: bigint, + ): Promise { + const reviewId = parseId(reviewIdRaw); + const row = await this.repo.findReviewDetailById(reviewId); + if (!row) { + throw new NotFoundException(PRODUCT_REVIEW_ERRORS.REVIEW_NOT_FOUND); + } + + const [likeCounts, likedIds, commentCounts] = await Promise.all([ + this.repo.aggregateLikeCounts([reviewId]), + accountId !== undefined + ? this.repo.findLikedReviewIds({ reviewIds: [reviewId], accountId }) + : Promise.resolve(new Set()), + this.repo.aggregateCommentCounts([reviewId]), + ]); + + return { + review: toProductReview(row, { + likeCount: likeCounts.get(reviewId) ?? 0, + isLiked: likedIds.has(reviewId.toString()), + commentCount: commentCounts.get(reviewId) ?? 0, + }), + product: toReviewDetailProduct(row.product), + }; + } + + /** 리뷰 댓글 목록(등록순, 커서). 리뷰가 없으면 NOT_FOUND. */ + async reviewComments( + input: ReviewCommentsInput, + accountId?: bigint, + ): Promise { + const reviewId = parseId(input.reviewId); + const exists = await this.repo.existsPublicReview(reviewId); + if (!exists) { + throw new NotFoundException(PRODUCT_REVIEW_ERRORS.REVIEW_NOT_FOUND); + } + + const limit = input.limit ?? DEFAULT_REVIEW_COMMENTS_LIMIT; + const [rows, totalCount] = await Promise.all([ + this.repo.listReviewComments({ + reviewId, + limit, + cursor: input.cursor ? parseId(input.cursor) : undefined, + }), + this.repo.countReviewComments(reviewId), + ]); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + return { + items: page.map((row) => toReviewCommentItem(row, accountId)), + totalCount, + hasMore, + nextCursor: hasMore ? page[page.length - 1].id.toString() : null, + }; + } + + /** + * 정렬별 리뷰 id 페이지 + 다음 커서 계산. + * + * 좋아요순 커서는 ":" 불투명 토큰 — 경계 시점의 좋아요 수를 + * 담아, 이후 좋아요 수가 변해도 페이지가 중복/누락되지 않는다. + * 최신순 커서는 마지막 리뷰 id. 커서는 동일 sort 안에서만 유효하다. + */ + private async fetchReviewIdPage(args: { + productId: bigint; + photoOnly: boolean; + sort: 'LATEST' | 'LIKES'; + limit: number; + cursorRaw?: string; + }): Promise<{ + pageIds: bigint[]; + hasMore: boolean; + nextCursor: string | null; + }> { + if (args.sort === 'LIKES') { + const rows = await this.repo.listProductReviewIdsByLikes({ + productId: args.productId, + photoOnly: args.photoOnly, + limit: args.limit, + cursor: args.cursorRaw + ? this.parseLikesCursor(args.cursorRaw) + : undefined, + }); + const hasMore = rows.length > args.limit; + const page = hasMore ? rows.slice(0, args.limit) : rows; + const last = page[page.length - 1]; + return { + pageIds: page.map((row) => row.id), + hasMore, + nextCursor: hasMore ? `${last.likeCount}:${last.id.toString()}` : null, + }; + } + + const ids = await this.repo.listProductReviewIdsLatest({ + productId: args.productId, + photoOnly: args.photoOnly, + limit: args.limit, + cursor: args.cursorRaw ? parseId(args.cursorRaw) : undefined, + }); + const hasMore = ids.length > args.limit; + const pageIds = hasMore ? ids.slice(0, args.limit) : ids; + return { + pageIds, + hasMore, + nextCursor: hasMore ? pageIds[pageIds.length - 1].toString() : null, + }; + } + + /** 좋아요순 커서 파싱. ":" 형식이 아니면 BAD_USER_INPUT. */ + private parseLikesCursor(raw: string): { likeCount: number; id: bigint } { + const match = /^(\d+):(\d+)$/.exec(raw); + if (!match) { + throw new BadRequestException(PRODUCT_REVIEW_ERRORS.INVALID_LIKES_CURSOR); + } + const likeCount = Number(match[1]); + // 자릿수 폭탄(예: 309자리)은 Number 변환 시 Infinity가 되어 raw SQL에 + // 비유한 값이 흘러간다. 안전 정수 범위를 벗어나면 형식 오류로 거부한다. + if (!Number.isSafeInteger(likeCount)) { + throw new BadRequestException(PRODUCT_REVIEW_ERRORS.INVALID_LIKES_CURSOR); + } + return { likeCount, id: BigInt(match[2]) }; + } + + /** id 페이지 순서를 유지하며 본문 + 집계(좋아요/댓글/isLiked)를 채운다. */ + private async hydrateReviews( + reviewIds: bigint[], + accountId?: bigint, + ): Promise { + if (reviewIds.length === 0) return []; + + const [rows, likeCounts, likedIds, commentCounts] = await Promise.all([ + this.repo.findProductReviewRowsByIds(reviewIds), + this.repo.aggregateLikeCounts(reviewIds), + accountId !== undefined + ? this.repo.findLikedReviewIds({ reviewIds, accountId }) + : Promise.resolve(new Set()), + this.repo.aggregateCommentCounts(reviewIds), + ]); + + const rowById = new Map(rows.map((row) => [row.id.toString(), row])); + return reviewIds.flatMap((id) => { + const row = rowById.get(id.toString()); + // id 페이지 조회와 hydrate 사이에 삭제된 리뷰는 건너뛴다 + if (!row) return []; + return [ + toProductReview(row, { + likeCount: likeCounts.get(row.id) ?? 0, + isLiked: likedIds.has(row.id.toString()), + commentCount: commentCounts.get(row.id) ?? 0, + }), + ]; + }); + } +} diff --git a/src/features/product/types/product-detail-output.type.ts b/src/features/product/types/product-detail-output.type.ts new file mode 100644 index 0000000..0360971 --- /dev/null +++ b/src/features/product/types/product-detail-output.type.ts @@ -0,0 +1,40 @@ +/** + * product-detail resolver 반환용 도메인 출력 타입. + * SDL(product-detail.graphql)의 타입과 필드 일치. + */ + +export interface ProductDetailOptionItem { + id: string; + title: string; + description: string | null; + imageUrl: string | null; + priceDelta: number; + sortOrder: number; +} + +export interface ProductDetailOptionGroup { + id: string; + name: string; + description: string | null; + isRequired: boolean; + minSelect: number; + maxSelect: number; + sortOrder: number; + items: ProductDetailOptionItem[]; +} + +export interface ProductDetail { + id: string; + storeId: string; + name: string; + description: string | null; + purchaseNotice: string | null; + images: string[]; + regularPrice: number; + salePrice: number | null; + discountRate: number; + currency: string; + reviewCount: number; + isWishlisted: boolean; + optionGroups: ProductDetailOptionGroup[]; +} diff --git a/src/features/product/types/product-review-output.type.ts b/src/features/product/types/product-review-output.type.ts new file mode 100644 index 0000000..8b972fc --- /dev/null +++ b/src/features/product/types/product-review-output.type.ts @@ -0,0 +1,70 @@ +/** + * product-reviews resolver 반환용 도메인 출력 타입. + * SDL(product-reviews.graphql)의 타입과 필드 일치. + */ + +export interface ProductReviewMedia { + mediaType: 'IMAGE' | 'VIDEO'; + mediaUrl: string; + thumbnailUrl: string | null; + sortOrder: number; +} + +export interface ReviewCustomOption { + groupName: string; + optionTitle: string; +} + +export interface ProductReview { + id: string; + rating: number; + content: string | null; + media: ProductReviewMedia[]; + likeCount: number; + isLiked: boolean; + commentCount: number; + authorNickname: string | null; + authorProfileImageUrl: string | null; + customOptions: ReviewCustomOption[]; + createdAt: Date; +} + +export interface ProductReviewConnection { + items: ProductReview[]; + totalCount: number; + photoTotalCount: number; + hasMore: boolean; + nextCursor: string | null; +} + +export interface ReviewDetailProduct { + productId: string; + name: string; + thumbnailUrl: string | null; + storeName: string; + regionLabel: string | null; + regularPrice: number; + salePrice: number | null; + discountRate: number; +} + +export interface ReviewDetail { + review: ProductReview; + product: ReviewDetailProduct; +} + +export interface ReviewCommentItem { + id: string; + content: string; + authorNickname: string | null; + authorProfileImageUrl: string | null; + isMine: boolean; + createdAt: Date; +} + +export interface ReviewCommentConnection { + items: ReviewCommentItem[]; + totalCount: number; + hasMore: boolean; + nextCursor: string | null; +} diff --git a/src/features/seller/constants/seller.constants.ts b/src/features/seller/constants/seller.constants.ts index 980e5f4..d866f00 100644 --- a/src/features/seller/constants/seller.constants.ts +++ b/src/features/seller/constants/seller.constants.ts @@ -18,6 +18,7 @@ export const MIN_PRODUCT_IMAGES = 1; // ── 옵션 ── export const MAX_OPTION_GROUP_NAME_LENGTH = 120; +export const MAX_OPTION_GROUP_DESCRIPTION_LENGTH = 1000; export const MAX_OPTION_ITEM_TITLE_LENGTH = 120; export const MAX_OPTION_ITEM_DESCRIPTION_LENGTH = 500; diff --git a/src/features/seller/dto/inputs/seller-create-option-group.input.ts b/src/features/seller/dto/inputs/seller-create-option-group.input.ts index 6922a5e..7c51583 100644 --- a/src/features/seller/dto/inputs/seller-create-option-group.input.ts +++ b/src/features/seller/dto/inputs/seller-create-option-group.input.ts @@ -12,6 +12,10 @@ export class SellerCreateOptionGroupInput { @IsString() name!: string; + @IsOptional() + @IsString() + description?: string; + @IsOptional() @IsBoolean() isRequired?: boolean; diff --git a/src/features/seller/dto/inputs/seller-update-option-group.input.ts b/src/features/seller/dto/inputs/seller-update-option-group.input.ts index 0f4668d..cf79ddf 100644 --- a/src/features/seller/dto/inputs/seller-update-option-group.input.ts +++ b/src/features/seller/dto/inputs/seller-update-option-group.input.ts @@ -8,6 +8,10 @@ export class SellerUpdateOptionGroupInput { @IsString() name?: string; + @IsOptional() + @IsString() + description?: string; + @IsOptional() @IsBoolean() isRequired?: boolean; diff --git a/src/features/seller/seller-product.graphql b/src/features/seller/seller-product.graphql index 6ef8423..5ede811 100644 --- a/src/features/seller/seller-product.graphql +++ b/src/features/seller/seller-product.graphql @@ -92,6 +92,8 @@ type SellerOptionGroup { id: ID! productId: ID! name: String! + """그룹 안내 문구(상품 상세 옵션 섹션 인트로).""" + description: String isRequired: Boolean! minSelect: Int! maxSelect: Int! @@ -225,6 +227,7 @@ input SellerSetProductTagsInput { input SellerCreateOptionGroupInput { productId: ID! name: String! + description: String isRequired: Boolean = true minSelect: Int = 1 maxSelect: Int = 1 @@ -238,6 +241,7 @@ input SellerCreateOptionGroupInput { input SellerUpdateOptionGroupInput { optionGroupId: ID! name: String + description: String isRequired: Boolean minSelect: Int maxSelect: Int diff --git a/src/features/seller/services/seller-option.service.spec.ts b/src/features/seller/services/seller-option.service.spec.ts index ab114d4..1bafe0d 100644 --- a/src/features/seller/services/seller-option.service.spec.ts +++ b/src/features/seller/services/seller-option.service.spec.ts @@ -87,6 +87,7 @@ describe('SellerOptionService (real DB)', () => { name: '사이즈', }); expect(result.name).toBe('사이즈'); + expect(result.description).toBeNull(); const groups = await prisma.productOptionGroup.findMany({ where: { product_id: product.id }, @@ -98,6 +99,23 @@ describe('SellerOptionService (real DB)', () => { }); expect(auditLogs).toHaveLength(1); }); + + it('description은 trim 저장, 공백뿐이면 null 정규화', async () => { + const { accountId, product } = await setupProductForSeller(); + const withText = await service.sellerCreateOptionGroup(accountId, { + productId: product.id.toString(), + name: '맛', + description: ' 크림 설명 ', + }); + expect(withText.description).toBe('크림 설명'); + + const blank = await service.sellerCreateOptionGroup(accountId, { + productId: product.id.toString(), + name: '사이즈', + description: ' ', + }); + expect(blank.description).toBeNull(); + }); }); describe('sellerUpdateOptionGroup', () => { @@ -153,6 +171,23 @@ describe('SellerOptionService (real DB)', () => { }); expect(result.name).toBe('신규명'); }); + + it('description 수정 및 공백 입력 시 null로 제거', async () => { + const { accountId, product } = await setupProductForSeller(); + const group = await createOptionGroup(product.id); + + const updated = await service.sellerUpdateOptionGroup(accountId, { + optionGroupId: group.id.toString(), + description: '그룹 안내 문구', + }); + expect(updated.description).toBe('그룹 안내 문구'); + + const cleared = await service.sellerUpdateOptionGroup(accountId, { + optionGroupId: group.id.toString(), + description: '', + }); + expect(cleared.description).toBeNull(); + }); }); describe('sellerDeleteOptionGroup', () => { diff --git a/src/features/seller/services/seller-option.service.ts b/src/features/seller/services/seller-option.service.ts index aea1f73..fae39f1 100644 --- a/src/features/seller/services/seller-option.service.ts +++ b/src/features/seller/services/seller-option.service.ts @@ -26,6 +26,7 @@ import { PRODUCT_NOT_FOUND, } from '@/features/seller/constants/seller-error-messages'; import { + MAX_OPTION_GROUP_DESCRIPTION_LENGTH, MAX_OPTION_GROUP_NAME_LENGTH, MAX_OPTION_ITEM_DESCRIPTION_LENGTH, MAX_OPTION_ITEM_TITLE_LENGTH, @@ -79,6 +80,10 @@ export class SellerOptionService extends SellerBaseService { productId, data: { name: cleanRequiredText(input.name, MAX_OPTION_GROUP_NAME_LENGTH), + description: cleanNullableText( + input.description, + MAX_OPTION_GROUP_DESCRIPTION_LENGTH, + ), is_required: input.isRequired ?? true, min_select: minSelect, max_select: maxSelect, @@ -130,6 +135,14 @@ export class SellerOptionService extends SellerBaseService { name: cleanRequiredText(input.name, MAX_OPTION_GROUP_NAME_LENGTH), } : {}), + ...(input.description !== undefined + ? { + description: cleanNullableText( + input.description, + MAX_OPTION_GROUP_DESCRIPTION_LENGTH, + ), + } + : {}), ...(input.isRequired !== undefined ? { is_required: input.isRequired } : {}), @@ -418,6 +431,7 @@ export class SellerOptionService extends SellerBaseService { id: bigint; product_id: bigint; name: string; + description: string | null; is_required: boolean; min_select: number; max_select: number; @@ -440,6 +454,7 @@ export class SellerOptionService extends SellerBaseService { id: row.id.toString(), productId: row.product_id.toString(), name: row.name, + description: row.description, isRequired: row.is_required, minSelect: row.min_select, maxSelect: row.max_select, diff --git a/src/features/seller/services/seller-product-mappers.helper.ts b/src/features/seller/services/seller-product-mappers.helper.ts index 6bdf6c9..920aa6b 100644 --- a/src/features/seller/services/seller-product-mappers.helper.ts +++ b/src/features/seller/services/seller-product-mappers.helper.ts @@ -13,6 +13,7 @@ export interface ProductOptionGroupRow { id: bigint; product_id: bigint; name: string; + description: string | null; is_required: boolean; min_select: number; max_select: number; @@ -90,6 +91,7 @@ export function toOptionGroupOutput(g: ProductOptionGroupRow) { id: g.id.toString(), productId: g.product_id.toString(), name: g.name, + description: g.description, isRequired: g.is_required, minSelect: g.min_select, maxSelect: g.max_select, diff --git a/src/features/seller/types/seller-output.type.ts b/src/features/seller/types/seller-output.type.ts index f02043c..1901e0e 100644 --- a/src/features/seller/types/seller-output.type.ts +++ b/src/features/seller/types/seller-output.type.ts @@ -77,6 +77,7 @@ export interface SellerOptionGroupOutput { id: string; productId: string; name: string; + description: string | null; isRequired: boolean; minSelect: number; maxSelect: number; diff --git a/src/features/user/constants/user-review-error-messages.ts b/src/features/user/constants/user-review-error-messages.ts index ceb2c32..63eab3c 100644 --- a/src/features/user/constants/user-review-error-messages.ts +++ b/src/features/user/constants/user-review-error-messages.ts @@ -8,4 +8,6 @@ export const USER_REVIEW_ERRORS = { CANNOT_WRITE_REVIEW: '리뷰를 작성할 수 없는 주문입니다.', REVIEW_ALREADY_EXISTS: '이미 리뷰가 작성된 주문 아이템입니다.', REVIEW_NOT_FOUND: '리뷰를 찾을 수 없습니다.', + COMMENT_NOT_FOUND: '댓글을 찾을 수 없습니다.', + NOT_COMMENT_OWNER: '본인 댓글만 삭제할 수 있습니다.', } as const; diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index bbabab9..60cedf6 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -20,3 +20,7 @@ export const MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1)); export const DEFAULT_PAGINATION_LIMIT = 20; export const MAX_PAGINATION_LIMIT = 50; + +// ── 리뷰 댓글 ── + +export const MAX_REVIEW_COMMENT_LENGTH = 500; diff --git a/src/features/user/dto/inputs/write-review-comment.input.spec.ts b/src/features/user/dto/inputs/write-review-comment.input.spec.ts new file mode 100644 index 0000000..790b49f --- /dev/null +++ b/src/features/user/dto/inputs/write-review-comment.input.spec.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { WriteReviewCommentInput } from '@/features/user/dto/inputs/write-review-comment.input'; + +function build(plain: object): WriteReviewCommentInput { + return plainToInstance(WriteReviewCommentInput, plain); +} + +describe('WriteReviewCommentInput', () => { + it('필수 필드 통과', async () => { + const dto = build({ reviewId: '123', content: '너무 귀여워요' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('content는 trim 후 길이를 검증한다(공백만 입력 거절)', async () => { + const dto = build({ reviewId: '123', content: ' ' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('content'); + }); + + it('content 500자 초과 거절', async () => { + const dto = build({ reviewId: '123', content: 'a'.repeat(501) }); + const errors = await validate(dto); + expect(errors[0].property).toBe('content'); + }); + + it('trim 결과 500자 이내면 통과', async () => { + const dto = build({ reviewId: '123', content: ` ${'a'.repeat(500)} ` }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('content가 문자열이 아니면 거절(transform은 원값 유지)', async () => { + const dto = build({ reviewId: '123', content: 123 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('content'); + }); +}); diff --git a/src/features/user/dto/inputs/write-review-comment.input.ts b/src/features/user/dto/inputs/write-review-comment.input.ts new file mode 100644 index 0000000..d7fb2aa --- /dev/null +++ b/src/features/user/dto/inputs/write-review-comment.input.ts @@ -0,0 +1,20 @@ +import { Transform } from 'class-transformer'; +import { IsString, Length } from 'class-validator'; + +/** + * 리뷰 댓글 작성 입력. + * + * 길이 검증 전에 trim 한다. service 가 trim 후 저장하므로, 공백만으로 + * 부풀린 입력이 raw 길이로 통과해 빈 댓글로 저장되는 것을 막는다. + */ +export class WriteReviewCommentInput { + @IsString() + reviewId!: string; + + @IsString() + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + @Length(1, 500) + content!: string; +} diff --git a/src/features/user/repositories/review.repository.ts b/src/features/user/repositories/review.repository.ts index 81084dc..d16e79d 100644 --- a/src/features/user/repositories/review.repository.ts +++ b/src/features/user/repositories/review.repository.ts @@ -202,6 +202,15 @@ export class ReviewRepository { }, data: { deleted_at: args.now }, }); + // 리뷰 재작성(createOrRestoreReviewWithMedia)이 같은 review id를 복원하므로 + // 댓글을 남겨두면 삭제 전 댓글이 새 리뷰에 되살아난다. 함께 정리한다. + await tx.reviewComment.updateMany({ + where: { + review_id: args.reviewId, + deleted_at: null, + }, + data: { deleted_at: args.now }, + }); } return result.count > 0; diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 70de2eb..4c8978f 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -5,6 +5,7 @@ import { IdentityProvider, NotificationEvent, NotificationType, + Prisma, } from '@prisma/client'; import { PrismaService } from '@/prisma'; @@ -550,11 +551,24 @@ export class UserRepository { where: { review_id: review.id, account_id: args.accountId, + // soft-delete 필터 우회: 해제(soft-delete)된 좋아요도 찾아 복원한다. + // uk_review_like 유니크 제약 때문에 새로 create하면 충돌한다. + deleted_at: undefined, }, - select: { id: true }, + select: { id: true, deleted_at: true }, }); - if (existing) return 'already-liked'; + if (existing && existing.deleted_at === null) return 'already-liked'; + + if (existing) { + // 해제했던 좋아요 복원. 좋아요↔해제 반복으로 인한 알림 스팸을 막기 위해 + // 알림은 최초 좋아요(신규 생성)에만 발송한다. + await tx.reviewLike.update({ + where: { id: existing.id }, + data: { deleted_at: null }, + }); + return 'liked'; + } await tx.reviewLike.create({ data: { @@ -579,4 +593,86 @@ export class UserRepository { return 'liked'; }); } + + /** 리뷰 좋아요 해제(soft). 좋아요가 없어도 성공 처리(멱등). */ + async unlikeReview(args: { + accountId: bigint; + reviewId: bigint; + }): Promise<'unliked' | 'not-found'> { + const review = await this.prisma.review.findFirst({ + where: { id: args.reviewId }, + select: { id: true }, + }); + if (!review) return 'not-found'; + + await this.prisma.reviewLike.updateMany({ + where: { + review_id: args.reviewId, + account_id: args.accountId, + deleted_at: null, + }, + data: { deleted_at: new Date() }, + }); + return 'unliked'; + } + + /** + * 리뷰 댓글 작성. 리뷰가 없으면(soft-delete 포함) 생성하지 않는다. + * 공개 조회(reviewComments)와 동일하게 상품·매장 활성 가드를 적용해 + * 작성 직후 조회 불가능한 댓글이 생기지 않게 한다. + * + * 리뷰 row를 FOR SHARE로 잠가 삭제 트랜잭션(review UPDATE → 댓글 정리)과 + * 직렬화한다 — 체크와 insert 사이에 리뷰가 삭제되어 정리 대상에서 빠지는 + * 댓글(리뷰 재작성 시 되살아나는 좀비 댓글)을 막는다. + */ + async createReviewComment(args: { + accountId: bigint; + reviewId: bigint; + content: string; + }): Promise< + | { id: bigint; review_id: bigint; content: string; created_at: Date } + | 'review-not-found' + > { + return this.prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw<{ id: bigint }[]>(Prisma.sql` + SELECT r.id + FROM review r + JOIN product p + ON p.id = r.product_id AND p.is_active = 1 AND p.deleted_at IS NULL + JOIN store s + ON s.id = p.store_id AND s.is_active = 1 AND s.deleted_at IS NULL + WHERE r.id = ${args.reviewId} AND r.deleted_at IS NULL + FOR SHARE OF r + `); + if (locked.length === 0) return 'review-not-found'; + + return tx.reviewComment.create({ + data: { + review_id: args.reviewId, + account_id: args.accountId, + content: args.content, + }, + select: { id: true, review_id: true, content: true, created_at: true }, + }); + }); + } + + /** 내 리뷰 댓글 soft-delete. 소유자 검증 포함. */ + async softDeleteMyReviewComment(args: { + accountId: bigint; + commentId: bigint; + }): Promise<'deleted' | 'not-found' | 'forbidden'> { + const comment = await this.prisma.reviewComment.findFirst({ + where: { id: args.commentId }, + select: { id: true, account_id: true }, + }); + if (!comment) return 'not-found'; + if (comment.account_id !== args.accountId) return 'forbidden'; + + await this.prisma.reviewComment.update({ + where: { id: args.commentId }, + data: { deleted_at: new Date() }, + }); + return 'deleted'; + } } diff --git a/src/features/user/resolvers/user-engagement-mutation.resolver.ts b/src/features/user/resolvers/user-engagement-mutation.resolver.ts index 23cb1ec..591a990 100644 --- a/src/features/user/resolvers/user-engagement-mutation.resolver.ts +++ b/src/features/user/resolvers/user-engagement-mutation.resolver.ts @@ -2,7 +2,9 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { parseId } from '@/common/utils/id-parser'; +import { WriteReviewCommentInput } from '@/features/user/dto/inputs/write-review-comment.input'; import { UserEngagementService } from '@/features/user/services/user-engagement.service'; +import type { MyReviewComment } from '@/features/user/types/user-review-output.type'; import { CurrentUser, JwtAuthGuard, @@ -24,4 +26,33 @@ export class UserEngagementMutationResolver { const id = parseId(reviewId); return this.engagementService.likeReview(accountId, id); } + + @Mutation('unlikeReview') + unlikeReview( + @CurrentUser() user: JwtUser, + @Args('reviewId') reviewId: string, + ): Promise { + const accountId = parseAccountId(user); + const id = parseId(reviewId); + return this.engagementService.unlikeReview(accountId, id); + } + + @Mutation('writeReviewComment') + writeReviewComment( + @CurrentUser() user: JwtUser, + @Args('input') input: WriteReviewCommentInput, + ): Promise { + const accountId = parseAccountId(user); + return this.engagementService.writeReviewComment(accountId, input); + } + + @Mutation('deleteMyReviewComment') + deleteMyReviewComment( + @CurrentUser() user: JwtUser, + @Args('commentId') commentId: string, + ): Promise { + const accountId = parseAccountId(user); + const id = parseId(commentId); + return this.engagementService.deleteMyReviewComment(accountId, id); + } } diff --git a/src/features/user/resolvers/user-engagement.resolver.spec.ts b/src/features/user/resolvers/user-engagement.resolver.spec.ts index 4bac2f0..1288f2d 100644 --- a/src/features/user/resolvers/user-engagement.resolver.spec.ts +++ b/src/features/user/resolvers/user-engagement.resolver.spec.ts @@ -66,4 +66,46 @@ describe('User Engagement Resolver (real DB)', () => { ), ).rejects.toThrow(BadRequestException); }); + + it('unlikeReview는 좋아요를 soft-delete하고 true를 반환한다', async () => { + const review = await createReview(prisma); + const liker = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: liker.id }); + await resolver.likeReview( + { accountId: liker.id.toString() }, + review.id.toString(), + ); + + const ok = await resolver.unlikeReview( + { accountId: liker.id.toString() }, + review.id.toString(), + ); + + expect(ok).toBe(true); + const activeLikes = await prisma.reviewLike.count({ + where: { review_id: review.id, account_id: liker.id, deleted_at: null }, + }); + expect(activeLikes).toBe(0); + }); + + it('writeReviewComment는 댓글을 생성하고 deleteMyReviewComment로 삭제된다', async () => { + const review = await createReview(prisma); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: commenter.id }); + const jwtUser = { accountId: commenter.id.toString() }; + + const created = await resolver.writeReviewComment(jwtUser, { + reviewId: review.id.toString(), + content: '너무 귀여워요', + }); + expect(created.content).toBe('너무 귀여워요'); + + const deleted = await resolver.deleteMyReviewComment(jwtUser, created.id); + expect(deleted).toBe(true); + + const activeComments = await prisma.reviewComment.count({ + where: { review_id: review.id, deleted_at: null }, + }); + expect(activeComments).toBe(0); + }); }); diff --git a/src/features/user/services/user-engagement.service.spec.ts b/src/features/user/services/user-engagement.service.spec.ts index 2a10024..02aae02 100644 --- a/src/features/user/services/user-engagement.service.spec.ts +++ b/src/features/user/services/user-engagement.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -127,4 +128,171 @@ describe('UserEngagementService (real DB)', () => { await expect(promise).rejects.toThrow(/Account is deleted/); }); }); + + describe('unlikeReview', () => { + async function setupLikedReview() { + const review = await createReview(prisma); + const liker = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: liker.id }); + await service.likeReview(liker.id, review.id); + return { review, liker }; + } + + it('좋아요를 soft-delete하고 true를 반환한다', async () => { + const { review, liker } = await setupLikedReview(); + + const result = await service.unlikeReview(liker.id, review.id); + + expect(result).toBe(true); + const activeLikes = await prisma.reviewLike.count({ + where: { review_id: review.id, account_id: liker.id, deleted_at: null }, + }); + expect(activeLikes).toBe(0); + }); + + it('좋아요가 없어도 true(멱등)', async () => { + const review = await createReview(prisma); + const user = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: user.id }); + + const result = await service.unlikeReview(user.id, review.id); + expect(result).toBe(true); + }); + + it('존재하지 않는 리뷰면 NotFoundException', async () => { + const user = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: user.id }); + + await expect( + service.unlikeReview(user.id, BigInt(999999)), + ).rejects.toThrow(NotFoundException); + }); + + it('해제 후 다시 좋아요를 누르면 활성 레코드가 복원된다', async () => { + const { review, liker } = await setupLikedReview(); + await service.unlikeReview(liker.id, review.id); + + const relike = await service.likeReview(liker.id, review.id); + + expect(relike).toBe(true); + const activeLikes = await prisma.reviewLike.count({ + where: { review_id: review.id, account_id: liker.id, deleted_at: null }, + }); + expect(activeLikes).toBe(1); + }); + }); + + describe('writeReviewComment', () => { + it('댓글을 생성하고 trim된 내용과 생성 정보를 반환한다', async () => { + const review = await createReview(prisma); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: commenter.id }); + + const result = await service.writeReviewComment(commenter.id, { + reviewId: review.id.toString(), + content: ' 너무 귀여워요 ', + }); + + expect(result.reviewId).toBe(review.id.toString()); + expect(result.content).toBe('너무 귀여워요'); + + const saved = await prisma.reviewComment.findFirstOrThrow({ + where: { review_id: review.id, account_id: commenter.id }, + }); + expect(saved.content).toBe('너무 귀여워요'); + }); + + it('존재하지 않는 리뷰면 NotFoundException', async () => { + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: commenter.id }); + + await expect( + service.writeReviewComment(commenter.id, { + reviewId: '999999', + content: '댓글', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('soft-delete된 리뷰에는 작성할 수 없다', async () => { + const review = await createReview(prisma); + await prisma.review.update({ + where: { id: review.id }, + data: { deleted_at: new Date() }, + }); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: commenter.id }); + + await expect( + service.writeReviewComment(commenter.id, { + reviewId: review.id.toString(), + content: '댓글', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('비활성 상품의 리뷰에는 작성할 수 없다(공개 조회 가드와 일치)', async () => { + const review = await createReview(prisma); + await prisma.product.update({ + where: { id: review.product_id }, + data: { is_active: false }, + }); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: commenter.id }); + + await expect( + service.writeReviewComment(commenter.id, { + reviewId: review.id.toString(), + content: '댓글', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('deleteMyReviewComment', () => { + async function setupComment() { + const review = await createReview(prisma); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: commenter.id }); + const comment = await service.writeReviewComment(commenter.id, { + reviewId: review.id.toString(), + content: '내 댓글', + }); + return { review, commenter, commentId: BigInt(comment.id) }; + } + + it('본인 댓글을 soft-delete하고 true를 반환한다', async () => { + const { commenter, commentId } = await setupComment(); + + const result = await service.deleteMyReviewComment( + commenter.id, + commentId, + ); + + expect(result).toBe(true); + const row = await prisma.reviewComment.findFirstOrThrow({ + where: { id: commentId, deleted_at: { not: null } }, + }); + expect(row.deleted_at).not.toBeNull(); + }); + + it('타인 댓글이면 ForbiddenException', async () => { + const { commentId } = await setupComment(); + const stranger = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: stranger.id }); + + await expect( + service.deleteMyReviewComment(stranger.id, commentId), + ).rejects.toThrow(ForbiddenException); + }); + + it('없는(또는 이미 삭제된) 댓글이면 NotFoundException', async () => { + const { commenter, commentId } = await setupComment(); + await service.deleteMyReviewComment(commenter.id, commentId); + + await expect( + service.deleteMyReviewComment(commenter.id, commentId), + ).rejects.toThrow(NotFoundException); + }); + }); }); diff --git a/src/features/user/services/user-engagement.service.ts b/src/features/user/services/user-engagement.service.ts index aa98797..7669dfd 100644 --- a/src/features/user/services/user-engagement.service.ts +++ b/src/features/user/services/user-engagement.service.ts @@ -1,11 +1,18 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; +import { parseId } from '@/common/utils/id-parser'; +import { cleanRequiredText } from '@/common/utils/text-cleaner'; +import { USER_REVIEW_ERRORS } from '@/features/user/constants/user-review-error-messages'; +import { MAX_REVIEW_COMMENT_LENGTH } from '@/features/user/constants/user.constants'; +import type { WriteReviewCommentInput } from '@/features/user/dto/inputs/write-review-comment.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; +import type { MyReviewComment } from '@/features/user/types/user-review-output.type'; @Injectable() export class UserEngagementService extends UserBaseService { @@ -30,4 +37,62 @@ export class UserEngagementService extends UserBaseService { return true; } + + /** 리뷰 좋아요 해제. 좋아요가 없어도 true(멱등). */ + async unlikeReview(accountId: bigint, reviewId: bigint): Promise { + await this.requireActiveUser(accountId); + + const result = await this.repo.unlikeReview({ accountId, reviewId }); + if (result === 'not-found') { + throw new NotFoundException(USER_REVIEW_ERRORS.REVIEW_NOT_FOUND); + } + + return true; + } + + /** 리뷰 댓글 작성. 리뷰가 없으면 NOT_FOUND. */ + async writeReviewComment( + accountId: bigint, + input: WriteReviewCommentInput, + ): Promise { + await this.requireActiveUser(accountId); + + const content = cleanRequiredText(input.content, MAX_REVIEW_COMMENT_LENGTH); + const created = await this.repo.createReviewComment({ + accountId, + reviewId: parseId(input.reviewId), + content, + }); + if (created === 'review-not-found') { + throw new NotFoundException(USER_REVIEW_ERRORS.REVIEW_NOT_FOUND); + } + + return { + id: created.id.toString(), + reviewId: created.review_id.toString(), + content: created.content, + createdAt: created.created_at, + }; + } + + /** 내 리뷰 댓글 삭제(soft). 본인 댓글이 아니면 FORBIDDEN. */ + async deleteMyReviewComment( + accountId: bigint, + commentId: bigint, + ): Promise { + await this.requireActiveUser(accountId); + + const result = await this.repo.softDeleteMyReviewComment({ + accountId, + commentId, + }); + if (result === 'not-found') { + throw new NotFoundException(USER_REVIEW_ERRORS.COMMENT_NOT_FOUND); + } + if (result === 'forbidden') { + throw new ForbiddenException(USER_REVIEW_ERRORS.NOT_COMMENT_OWNER); + } + + return true; + } } diff --git a/src/features/user/services/user-review.service.spec.ts b/src/features/user/services/user-review.service.spec.ts index ed48fc6..d0da04c 100644 --- a/src/features/user/services/user-review.service.spec.ts +++ b/src/features/user/services/user-review.service.spec.ts @@ -460,6 +460,38 @@ describe('UserReviewService (real DB)', () => { NotFoundException, ); }); + + it('리뷰 삭제 시 댓글도 soft-delete되어 재작성(복원) 리뷰에 되살아나지 않는다', async () => { + const ctx = await setupReviewableOrderItem(); + const review = await service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + }); + const commenter = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewComment.create({ + data: { + review_id: BigInt(review.reviewId), + account_id: commenter.id, + content: '삭제 전 댓글', + }, + }); + + await service.deleteMyReview(ctx.accountId, review.reviewId); + + // 같은 order item으로 재작성하면 동일 review id가 복원된다 + const rewritten = await service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 4, + content: VALID_CONTENT, + }); + expect(rewritten.reviewId).toBe(review.reviewId); + + const activeComments = await prisma.reviewComment.count({ + where: { review_id: BigInt(review.reviewId), deleted_at: null }, + }); + expect(activeComments).toBe(0); + }); }); // ─── createReviewMediaUploadUrl ─── diff --git a/src/features/user/types/user-review-output.type.ts b/src/features/user/types/user-review-output.type.ts index cae0869..e7dce65 100644 --- a/src/features/user/types/user-review-output.type.ts +++ b/src/features/user/types/user-review-output.type.ts @@ -36,3 +36,10 @@ export interface ReviewMediaUploadUrl { key: string; expiresInSeconds: number; } + +export interface MyReviewComment { + id: string; + reviewId: string; + content: string; + createdAt: Date; +} diff --git a/src/features/user/user-engagement.graphql b/src/features/user/user-engagement.graphql index e88ce4d..62093cf 100644 --- a/src/features/user/user-engagement.graphql +++ b/src/features/user/user-engagement.graphql @@ -1,4 +1,24 @@ extend type Mutation { """리뷰 좋아요""" likeReview(reviewId: ID!): Boolean! + """리뷰 좋아요 해제(멱등: 좋아요가 없어도 true)""" + unlikeReview(reviewId: ID!): Boolean! + """리뷰 댓글 작성""" + writeReviewComment(input: WriteReviewCommentInput!): MyReviewComment! + """내 리뷰 댓글 삭제(soft). 본인 댓글만 가능.""" + deleteMyReviewComment(commentId: ID!): Boolean! +} + +input WriteReviewCommentInput { + reviewId: ID! + """1~500자""" + content: String! +} + +"""작성된 내 리뷰 댓글(작성 직후 반환).""" +type MyReviewComment { + id: ID! + reviewId: ID! + content: String! + createdAt: DateTime! } diff --git a/src/prisma/soft-delete.middleware.ts b/src/prisma/soft-delete.middleware.ts index 9d25cda..aea626b 100644 --- a/src/prisma/soft-delete.middleware.ts +++ b/src/prisma/soft-delete.middleware.ts @@ -38,6 +38,7 @@ const SOFT_DELETE_MODELS = new Set([ 'OrderItemCustomFreeEditAttachment', 'Review', 'ReviewMedia', + 'ReviewComment', 'Notification', 'SearchHistory', 'SearchEvent',