0

[Series] Xây dựng Hệ thống Bất động sản với Node.js & TypeScript - Bài 8: Tương tác người dùng - Đánh giá & Bình luận Đa cấp

hào anh em! Ở bài 7 chúng ta đã tìm được nhà, thì bài 8 này là lúc để người dùng "lên tiếng". Trong bài này, chúng ta sẽ giải quyết bài toán: Làm sao để một người dùng chỉ được đánh giá 1 lần, và làm thế nào để quản lý các bình luận lồng nhau (Reply).

1. Phân tích Logic nghiệp vụ

Đánh giá (Rating):Một người dùng chỉ nên đánh giá một tin đăng một lần duy nhất. Nếu họ đánh giá lại, hệ thống sẽ cập nhật điểm cũ. Quan trọng hơn, mỗi khi có đánh giá mới, chúng ta phải tính lại avgStar (điểm trung bình) của posts để hiển thị ngoài danh sách.

Bình luận (Comment): Sử dụng idParent. Nếu một bình luận có idParent, nó được coi là một câu trả lời (Reply).

2. Validation Schema (Zod)

Chúng ta cần đảm bảo số sao (star) phải nằm trong khoảng từ 1 đến 5 và nội dung không được để trống.

File: src/schemas/interaction.schema.ts

import { z } from 'zod';

export const createCommentSchema = z.object({
  idPost: z.number(),
  content: z.string().min(1, "Nội dung bình luận không được để trống"),
  idParent: z.number().optional(),
});

export const createRatingSchema = z.object({
  idPost: z.number(),
  star: z.number().min(1).max(5, "Đánh giá tối đa là 5 sao"),
  content: z.string().optional(),
});

3. Xử lý tại Service Layer (Có Transaction)

Để đảm bảo tính toàn vẹn dữ liệu (vừa lưu rating, vừa cập nhật điểm trung bình bài đăng), chúng ta sẽ dùng Prisma Transaction.

File: src/services/interaction.service.ts

import prisma from '../prisma/client';

// 1. Thêm bình luận mới
export const addComment = async (userId: number, data: any) => {
  return await prisma.comments.create({
    data: {
      idPost: data.idPost,
      idUser: userId,
      content: data.content,
      idParent: data.idParent || null,
    },
    include: {
      user: { select: { fullname: true, avatar: true } }
    }
  });
};

// 2. Đánh giá & Cập nhật avgStar cho bài đăng
export const upsertRating = async (userId: number, data: any) => {
  return await prisma.$transaction(async (tx) => {
    // Upsert Rating: Nếu đã tồn tại thì update, chưa thì tạo mới
    // Lưu ý: Cần thêm Unique Constraint [idUser, idPost] trong schema.prisma
    const rating = await tx.ratings.upsert({
      where: {
        userId_postId: { idUser: userId, idPost: data.idPost }
      },
      update: { star: data.star, content: data.content },
      create: { idUser: userId, idPost: data.idPost, star: data.star, content: data.content },
    });

    // Tính lại điểm trung bình từ tất cả rating của bài đăng này
    const aggregate = await tx.ratings.aggregate({
      where: { idPost: data.idPost },
      _avg: { star: true }
    });

    // Cập nhật lại vào bảng Posts
    await tx.posts.update({
      where: { id: data.idPost },
      data: { avgStar: Math.round(aggregate._avg.star || 0) }
    });

    return rating;
  });
};

Lưu ý cực kỳ quan trọng: Bạn cần mở file prisma/schema.prisma, tại model ratings, hãy thêm dòng @@unique([idUser, idPost])và chạy npx prisma migrate dev để tính năng upsert hoạt động nhé!

4. Controller & Routes

File: src/controllers/interaction.controller.ts

import { Response } from 'express';
import { AuthRequest } from '../middlewares/auth.middleware';
import * as InteractionService from '../services/interaction.service';

export const postComment = async (req: AuthRequest, res: Response) => {
  try {
    const comment = await InteractionService.addComment(req.user!.userId, req.body);
    res.status(201).json({ message: "Đã gửi bình luận!", comment });
  } catch (err: any) {
    res.status(400).json({ error: err.message });
  }
};

export const postRating = async (req: AuthRequest, res: Response) => {
  try {
    const rating = await InteractionService.upsertRating(req.user!.userId, req.body);
    res.status(200).json({ message: "Cảm ơn bạn đã đánh giá! ⭐", rating });
  } catch (err: any) {
    res.status(400).json({ error: err.message });
  }
};

File: src/routes/interaction.routes.ts

import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware';
import { validate } from '../middlewares/validate.middleware';
import { createCommentSchema, createRatingSchema } from '../schemas/interaction.schema';
import * as InteractionController from '../controllers/interaction.controller';

const router = Router();

router.post('/comment', authenticate, validate(createCommentSchema), InteractionController.postComment);
router.post('/rating', authenticate, validate(createRatingSchema), InteractionController.postRating);

export default router;

5. Hướng dẫn Test Postman cho Gophers thực chiến

Bước 1: Bình luận một bài đăng Method: POST

URL: http://localhost:3000/api/interactions/comment

Headers: Authorization: Bearer <token>

Body (JSON):

{
  "idPost": 1,
  "content": "Căn nhà này ở ngoài nhìn đẹp hơn trong hình nhiều anh em ạ!"
}

Bước 2: Reply (Phản hồi) bình luận

Giả sử bình luận trên có id5.

Body (JSON):

{
  "idPost": 1,
  "idParent": 5,
  "content": "Cảm ơn bác đã review tâm huyết!"
}

Bước 3: Đánh giá 5 sao

Method: POST

URL: http://localhost:3000/api/interactions/rating

Body (JSON):

{
  "idPost": 1,
  "star": 5,
  "content": "Pháp lý sạch, chủ nhà hỗ trợ vay vốn tốt."
}

Sau khi gửi, hãy dùng API GET/api/posts để xem, bạn sẽ thấy avgStar của bài đăng đã thay đổi!

6. Tổng kết

Chúc mừng anh em! Hệ thống Bất động sản của chúng ta đã có tính tương tác xã hội rất cao:

Đánh giá thông minh: Tự động tính lại điểm trung bình bài đăng.

Bình luận đa cấp: Cho phép người dùng thảo luận chuyên sâu.

Bảo mật: Chỉ người dùng đã đăng nhập mới được để lại tương tác.

Ở bài tiếp theo (Bài 9), chúng ta sẽ xây dựng tính năng "Danh sách yêu thích" (Wishlist) – giúp người dùng lưu lại những căn nhà "trong mơ" để xem lại sau. Đây là chìa khóa để giữ chân khách hàng quay lại website.

Anh em Gophers thấy cách xử lý Transaction trong Node.js có dễ chịu hơn không? Có thắc mắc gì về Nested Comments cứ để lại lời nhắn nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí