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
22 changes: 20 additions & 2 deletions apps/api/backend/api/routes/reviews.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
from uuid import UUID

from fastapi import APIRouter, status

from backend.schemas.review import (
ProductReviewListResponse,
ReviewCreateRequest,
ReviewCreateResponse,
ReviewDeleteRequest,
ReviewDeleteResponse,
ReviewUpdateRequest,
ReviewUpdateResponse,
)
from backend.services.review_service import create_review, delete_review, update_review
from backend.services.review_service import (
create_review,
delete_review,
list_product_reviews,
update_review,
)

router = APIRouter(prefix="/reviews", tags=["reviews"])

Expand Down Expand Up @@ -42,4 +50,14 @@ def delete_product_review(
payload: ReviewDeleteRequest,
) -> ReviewDeleteResponse:
result = delete_review(review_id, payload)
return ReviewDeleteResponse(**result)
return ReviewDeleteResponse(**result)


@router.get(
"/products/{product_id}/reviews",
response_model=ProductReviewListResponse,
status_code=status.HTTP_200_OK,
)
def get_product_reviews(product_id: UUID) -> ProductReviewListResponse:
result = list_product_reviews(product_id)
return ProductReviewListResponse(**result)
24 changes: 23 additions & 1 deletion apps/api/backend/schemas/review.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel, Field
Expand Down Expand Up @@ -50,4 +51,25 @@ class ReviewDeleteResponse(BaseModel):
user_id: UUID
review_status: str
updated_at: datetime
message: str
message: str


class ProductReviewItemResponse(BaseModel):
review_id: UUID
user_id: UUID
product_id: UUID
order_item_id: UUID
rating: int
review_title: str
review_content: str
review_status: str
created_at: datetime
updated_at: datetime | None = None
user_name: str | None = None


class ProductReviewListResponse(BaseModel):
product_id: UUID
total_reviews: int
average_rating: Decimal | None = None
reviews: list[ProductReviewItemResponse]
66 changes: 66 additions & 0 deletions apps/api/backend/services/review_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from typing import Any
from decimal import Decimal
from uuid import UUID

from fastapi import HTTPException, status
from sqlalchemy import text
Expand Down Expand Up @@ -338,4 +340,68 @@ def delete_review(review_id: str, payload: ReviewDeleteRequest) -> dict[str, Any
"review_status": deleted_review["review_status"],
"updated_at": deleted_review["updated_at"],
"message": "Review deleted successfully",
}

def list_product_reviews(product_id: UUID) -> dict[str, Any]:
product_query = text("""
SELECT product_id
FROM products
WHERE product_id = :product_id
AND is_active = TRUE
LIMIT 1
""")

reviews_query = text("""
SELECT
r.review_id,
r.user_id,
r.product_id,
r.order_item_id,
r.rating,
r.review_title,
r.review_content,
r.review_status,
r.created_at,
r.updated_at,
u.user_name
FROM reviews r
JOIN users u
ON u.user_id = r.user_id
WHERE r.product_id = :product_id
AND r.review_status = 'visible'
ORDER BY r.created_at DESC
""")

with engine.begin() as connection:
product = connection.execute(
product_query,
{"product_id": product_id},
).mappings().first()

if product is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)

review_rows = connection.execute(
reviews_query,
{"product_id": product_id},
).mappings().all()

reviews = [dict(row) for row in review_rows]
total_reviews = len(reviews)

average_rating = None
if total_reviews > 0:
average_rating = (
sum(Decimal(str(review["rating"])) for review in reviews)
/ Decimal(str(total_reviews))
).quantize(Decimal("0.1"))

return {
"product_id": product_id,
"total_reviews": total_reviews,
"average_rating": average_rating,
"reviews": reviews,
}
2 changes: 1 addition & 1 deletion apps/web/src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function MainLayout() {
{user ? (
<>
<Link to="/orders">주문 내역</Link>
<span className="app-user">{user.user_name}</span>
<span className="app-user">{user.user_name}</span>
<button type="button" onClick={handleLogout} className="link-button">
로그아웃
</button>
Expand Down
56 changes: 37 additions & 19 deletions apps/web/src/features/orders/OrderHistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,26 +454,44 @@ export function OrderHistoryPage() {
주문 상품 상세가 없습니다.
</p>
) : (
orderItems.map((item) => (
<div key={item.order_item_id} className="order-history-item-row">
<div>
<h3>{item.product_name ?? item.product_id}</h3>
<p>
수량 {item.quantity}개 · 단가{" "}
{formatPrice(item.unit_price, item.currency)}
</p>
orderItems.map((item) => {
const canCreateReview =
order.order_status === "paid" && order.payment_status === "paid";

return (
<div key={item.order_item_id} className="order-history-item-row">
<div>
<h3>{item.product_name ?? item.product_id}</h3>
<p>
수량 {item.quantity}개 · 단가{" "}
{formatPrice(item.unit_price, item.currency)}
</p>
</div>

<div className="order-history-item-actions">
<strong>
{formatPrice(
item.line_total ??
item.final_item_amount ??
Number(item.unit_price) * Number(item.quantity),
item.currency,
)}
</strong>

{canCreateReview && (
<Link
to={`/reviews/new?product_id=${item.product_id}&order_item_id=${item.order_item_id}&product_name=${encodeURIComponent(
item.product_name ?? item.product_id,
)}`}
className="secondary-link compact"
>
리뷰 작성
</Link>
)}
</div>
</div>

<strong>
{formatPrice(
item.line_total ??
item.final_item_amount ??
Number(item.unit_price) * Number(item.quantity),
item.currency,
)}
</strong>
</div>
))
);
})
)}
</div>
</article>
Expand Down
Loading
Loading