0

[Series] Xây dựng Hệ thống Bất động sản với Node.js & TypeScript - Bài 7: Bộ lọc Tìm kiếm "Thông minh" & Query linh hoạt với Prisma

Chào anh em! Một bộ lọc tìm kiếm chuẩn cho Bất động sản thường bao gồm: Tìm theo khu vực (Tỉnh/Huyện), tìm theo khoảng giá (min - max), khoảng diện tích và loại hình tài sản. Trong bài này, chúng ta sẽ học cách xây dựng một hàm Service "cân" hết mọi tham số truyền vào từ Client mà vẫn đảm bảo hiệu năng.

1. Định nghĩa Schema cho Search Query

Vì các tham số tìm kiếm thường truyền qua Query String (ví dụ: ?minPrice=1000&maxPrice=5000), chúng ta cần dùng Zod để validate và ép kiểu dữ liệu về đúng dạng số.

File: src/schemas/post.schema.ts (Thêm vào cuối file)

export const searchPostSchema = z.object({
  page: z.string().optional().transform(Number).default("1"),
  limit: z.string().optional().transform(Number).default("10"),
  keyword: z.string().optional(),
  province: z.string().optional(),
  district: z.string().optional(),
  ward: z.string().optional(),
  minPrice: z.string().optional().transform(v => v ? BigInt(v) : undefined),
  maxPrice: z.string().optional().transform(v => v ? BigInt(v) : undefined),
  minSize: z.string().optional().transform(Number),
  maxSize: z.string().optional().transform(Number),
  propertyType: z.string().optional(),
  listingType: z.string().optional(),
});

2. Logic Query linh hoạt tại Service

Đây là phần "não bộ" của bài học. Chúng ta sẽ xây dựng đối tượng where của Prisma một cách năng động. Nếu Client không gửi tham số nào, where sẽ rỗng (lấy tất cả). Nếu gửi, Prisma sẽ tự động AND các điều kiện lại.

File: src/services/post.service.ts (Cập nhật hàm search)

import { Prisma } from '@prisma/client';
import prisma from '../prisma/client';

export const searchPosts = async (filters: any) => {
  const { 
    page, limit, keyword, province, district, ward, 
    minPrice, maxPrice, minSize, maxSize, 
    propertyType, listingType 
  } = filters;

  const skip = (page - 1) * limit;

  // Xây dựng điều kiện WHERE động
  const where: Prisma.postsWhereInput = {
    status: "Còn trống", // Chỉ tìm tin còn trống
    ...(keyword && {
      title: { contains: keyword, mode: 'insensitive' } // Tìm kiếm không phân biệt hoa thường
    }),
    ...(province && { province }),
    ...(district && { district }),
    ...(ward && { ward }),
    ...(propertyType && { propertyType: propertyType as any }),
    ...(listingType && { listingType: listingType as any }),
    
    // Lọc theo khoảng giá
    price: {
      ...(minPrice && { gte: minPrice }), // Greater than or equal
      ...(maxPrice && { lte: maxPrice }), // Less than or equal
    },
    
    // Lọc theo khoảng diện tích
    size: {
      ...(minSize && { gte: minSize }),
      ...(maxSize && { lte: maxSize }),
    }
  };

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

  // Đừng quên convert BigInt sang String để tránh lỗi JSON
  const safePosts = JSON.parse(JSON.stringify(posts, (k, v) => 
    typeof v === 'bigint' ? v.toString() : v
  ));

  return {
    data: safePosts,
    pagination: {
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit)
    }
  };
};

3. Controller & Routes

File: src/controllers/post.controller.ts

export const handleSearchPosts = async (req: Request, res: Response) => {
  try {
    // Dữ liệu từ req.query đã được validate qua middleware
    const result = await PostService.searchPosts(req.query);
    res.status(200).json(result);
  } catch (err: any) {
    res.status(500).json({ error: "Lỗi khi tìm kiếm tin đăng" });
  }
};

File: src/routes/post.routes.ts

// Route này không cần authenticate (ai cũng có thể tìm nhà)
router.get('/search', validate(searchPostSchema, "query"), PostController.handleSearchPosts);

Lưu ý nhỏ: Ở Middleware validate, bạn cần sửa lại một chút để nó có thể validate req.query thay vì chỉ req.body. const validate = (schema: ZodSchema, source: 'body' | 'query' = 'body') => ...

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

Tìm kiếm qua Query String rất dễ nhầm lẫn, anh em chú ý cách nhập tham số nhé:

Method: GET

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

Tab Params: (Đây là nơi anh em nhập các bộ lọc)

keyword: Vinhomes

province: Hồ Chí Minh

minPrice: 2000000000 (2 tỷ)

maxPrice: 5000000000 (5 tỷ)

propertyType: Căn hộ chung cư

Kết quả mong đợi: Hệ thống sẽ trả về danh sách các căn hộ Vinhomes tại HCM có giá trong khoảng từ 2 đến 5 tỷ.

5. Tổng kết

Bài học hôm nay giúp anh em làm chủ kỹ thuật Dynamic Query với Prisma – một kỹ năng "must-have" cho mọi Backend Developer. Chúng ta đã có:

Bộ lọc theo khoảng (Range Filter).

Tìm kiếm theo từ khóa (Text Search).

Phân trang tự động (Auto Pagination).

Ở bài tiếp theo (Bài 8), chúng ta sẽ xây dựng tính năng tương tác người dùng cực kỳ quan trọng: Hệ thống Đánh giá (Rating) & Bình luận (Comment) để tăng độ uy tín cho tin đăng.

Anh em có muốn mình bổ sung thêm tính năng "Sắp xếp theo giá tăng/giảm" (Sort By Price) vào bộ lọc này không? Nếu có thì comment ngay 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í