Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index comments by the pagination key

For reviews with many comments, this index cannot efficiently serve listReviewComments, whose predicate is review_id = ... AND id > ... and whose ordering is id ASC. MySQL must either sort the review's matching rows or scan the primary-key range while filtering other reviews on every page; using (review_id, id) aligns the index with the cursor query and keeps page retrieval proportional to the requested limit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: 인덱스를 (review_id, id)로 교체하는 마이그레이션 추가. FK가 인덱스를 요구하므로 DROP/ADD 단일 ALTER로 원자 처리. 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;
Original file line number Diff line number Diff line change
@@ -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`);
25 changes: 25 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1082,6 +1085,7 @@ model Review {
media ReviewMedia[]

likes ReviewLike[]
comments ReviewComment[]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Soft-delete comments when their review is deleted

Adding comments to reviews without extending ReviewRepository.softDeleteReview leaves all comment rows active when a review is deleted. Because createOrRestoreReviewWithMedia later restores that same review ID and replaces its content/media, every comment from the deleted version becomes visible again on the rewritten review. Soft-delete the associated comments in the existing review-deletion transaction, as is already done for review media.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: softDeleteReview 트랜잭션에서 댓글도 soft-delete (5e9e201). 재작성 복원 시 옛 댓글 미노출 회귀 테스트 추가.

notifications Notification[]

@@index([store_id, created_at], map: "idx_review_store")
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** 상품 상세 조회 에러 메시지. */
export const PRODUCT_DETAIL_ERRORS = {
PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.',
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** 상품 리뷰 조회 에러 메시지. */
export const PRODUCT_REVIEW_ERRORS = {
REVIEW_NOT_FOUND: '리뷰를 찾을 수 없습니다.',
INVALID_LIKES_CURSOR: '좋아요순 커서 형식이 올바르지 않습니다.',
} as const;
5 changes: 5 additions & 0 deletions src/features/product/constants/product-review.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** 상품 리뷰 목록 기본 페이지 크기. */
export const DEFAULT_PRODUCT_REVIEWS_LIMIT = 20;

/** 리뷰 댓글 목록 기본 페이지 크기. */
export const DEFAULT_REVIEW_COMMENTS_LIMIT = 20;
38 changes: 38 additions & 0 deletions src/features/product/dto/inputs/product-reviews.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions src/features/product/dto/inputs/review-comments.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions src/features/product/product-detail.graphql
Original file line number Diff line number Diff line change
@@ -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!
}
127 changes: 127 additions & 0 deletions src/features/product/product-reviews.graphql
Original file line number Diff line number Diff line change
@@ -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!
}
10 changes: 10 additions & 0 deletions src/features/product/product.module.ts
Original file line number Diff line number Diff line change
@@ -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,
],
Expand Down
Loading
Loading