0

[Series] Xây dựng Hệ thống Bất động sản với Node.js & TypeScript - Bài 5: Quản lý Tin đăng (CRUD Posts) & Kỹ thuật Phân trang

Ở bài này, chúng ta không chỉ làm CRUD đơn thuần. Chúng ta sẽ giải quyết bài toán: Làm sao để đăng một tin với đầy đủ thuộc tính (diện tích, giá, địa chỉ...) và làm thế nào để lấy danh sách tin đăng hiệu quả nhất bằng kỹ thuật Pagination (Phân trang).

1. Định nghĩa Validation Schema cho Tin đăng

Vì bảng posts có rất nhiều trường dữ liệu và Enum, việc sử dụng Zod để validate là cực kỳ quan trọng để tránh "rác" trong Database.

File: src/schemas/post.schema.ts

import { z } from 'zod';

export const createPostSchema = z.object({
  title: z.string().min(10, "Tiêu đề phải ít nhất 10 ký tự"),
  address: z.string().min(5),
  province: z.string(),
  district: z.string(),
  ward: z.string(),
  price: z.number().positive(),
  size: z.number().positive(),
  description: z.string().min(20),
  floor: z.number().optional(),
  bedroom: z.number().optional(),
  bathroom: z.number().optional(),
  isFurniture: z.boolean().default(false),
  listingType: z.enum(["Bán", "Cho thuê"]),
  propertyType: z.enum(["Căn hộ chung cư", "Nhà mặt phố", "Nhà riêng", "Biệt thự", "Đất nền", "Khác"]),
  direction: z.enum(["Đông", "Tây", "Nam", "Bắc", "Đông - Bắc", "Tây - Nam", "Đông - Nam", "Tây - Bắc"]).optional(),
});

2. Xử lý Logic tại Service Layer

Chúng ta cần lưu ý: price trong DB là BigInt. Khi nhận từ Client (number), ta phải convert sang BigInt.

File: src/services/post.service.ts

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

export const createPost = async (userId: number, data: any) => {
  return await prisma.posts.create({
    data: {
      ...data,
      price: BigInt(data.price), // Convert sang BigInt cho DB
      idUser: userId,
      status: "Còn trống"
    }
  });
};

export const getAllPosts = async (page: number, limit: number, filters: any) => {
  const skip = (page - 1) * limit;
  
  // Xây dựng điều kiện lọc (Search)
  const where: any = {};
  if (filters.province) where.province = filters.province;
  if (filters.listingType) where.listingType = filters.listingType;

  const [posts, total] = await Promise.all([
    prisma.posts.findMany({
      where,
      skip,
      take: limit,
      orderBy: { id: 'desc' },
      include: { user: { select: { fullname: true, phone: true } } }
    }),
    prisma.posts.count({ where })
  ]);

  return {
    posts: JSON.parse(JSON.stringify(posts, (k, v) => typeof v === 'bigint' ? v.toString() : v)),
    pagination: {
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit)
    }
  };
};

3. Controller & Routes

File: src/controllers/post.controller.ts

import { Request, Response } from 'express';
import { AuthRequest } from '../middlewares/auth.middleware';
import * as PostService from '../services/post.service';

export const handleCreatePost = async (req: AuthRequest, res: Response) => {
  try {
    const post = await PostService.createPost(req.user!.userId, req.body);
    res.status(201).json({ message: "Đăng tin thành công 🏠", post });
  } catch (err: any) {
    res.status(500).json({ error: err.message });
  }
};

export const handleGetPosts = async (req: Request, res: Response) => {
  try {
    const page = parseInt(req.query.page as string) || 1;
    const limit = parseInt(req.query.limit as string) || 10;
    const { province, listingType } = req.query;

    const result = await PostService.getAllPosts(page, limit, { province, listingType });
    res.json(result);
  } catch (err: any) {
    res.status(500).json({ error: err.message });
  }
};

File: src/routes/post.routes.ts

import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware';
import { validate } from '../middlewares/validate.middleware';
import { createPostSchema } from '../schemas/post.schema';
import * as PostController from '../controllers/post.controller';

const router = Router();

// Route công khai: Xem danh sách tin
router.get('/', PostController.handleGetPosts);

// Route bảo vệ: Chỉ người dùng đã đăng nhập mới được đăng tin
router.post('/', authenticate, validate(createPostSchema), PostController.handleCreatePost);

export default router;

4. Hướng dẫn Test Postman (Cực kỳ chi tiết)

Bước 1: Đăng tin mới (Create Post)

Method: POST

URL: http://localhost:3000/api/posts

Headers: Authorization: Bearer <Token_Bài_2>

Body (raw JSON):

{
  "title": "Căn hộ Vinhomes Grand Park view hồ bơi",
  "address": "Phân khu Rainbow",
  "province": "TP. Hồ Chí Minh",
  "district": "Quận 9",
  "ward": "Long Thạnh Mỹ",
  "price": 2500000000,
  "size": 55,
  "description": "Cần bán gấp căn hộ đầy đủ nội thất, view cực đẹp...",
  "listingType": "Bán",
  "propertyType": "Căn hộ chung cư",
  "isFurniture": true
}

Bước 2: Xem danh sách & Phân trang (Get Posts)

Method: GET

URL: http://localhost:3000/api/posts?page=1&limit=5&province=TP. Hồ Chí Minh

Kết quả: Bạn sẽ nhận được mảng posts kèm theo thông tin pagination để hiển thị ở phía Frontend.

5. Tổng kết

Vậy là hệ thống của chúng ta đã có thể:

Đăng tin với sự bảo vệ của Middleware Auth.

Tự động convert giá từ Number sang BigInt.

Tìm kiếm & Phân trang chuyên nghiệp.

Ở bài tiếp theo (Lesson 7), chúng ta sẽ làm một tính năng rất "xịn xò": Upload hình ảnh Bất động sản lên Cloudinary để tin đăng trở nên sinh động và đáng tin hơn.

Nếu anh em chạy code bị lỗi BigInt khi trả về JSON, nhớ kiểm tra lại hàm JSON.stringify tùy chỉnh mình đã hướng dẫn ở Service 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í