Menu
CoddyTech

Hạn chót

Thuộc phần REST API trong hành trình Node.js của Coddy. Bài học 25 trên 25.

Các nhiệm vụ có ngày đến hạn, và bảng cho biết nhiệm vụ nào đã quá hạn. Ngày không kèm giờ được truyền dưới dạng chuỗi ISO, 2026-03-15, mà zod kiểm tra bằng z.iso.date():

z.iso.date().safeParse('2026-07-01').success   // true
z.iso.date().safeParse('2026-02-30').success   // false: không có ngày này
'2026-03-01' < '2026-03-15'                    // true

Ngày ISO có cùng độ dài và đặt năm lên trước, nên so sánh chúng dưới dạng chuỗi sẽ cho thứ tự thời gian. Trong bộ định tuyến, đường dẫn cố định như /overdue phải đứng trước /:id: Express thử các tuyến theo thứ tự, và /:id khớp với bất kỳ từ nào.

challenge icon

Thử thách

Dễ

Bảng hiển thị những việc đã quá hạn. Thêm ngày đến hạn tùy chọn cho các việc và thêm danh sách những việc đã quá hạn.

  1. Trong routes/tasks.js, thêm trường due tùy chọn vào lược đồ Task, kiểm tra bằng z.iso.date(). Trường này chỉ chấp nhận ngày hợp lệ viết theo định dạng YYYY-MM-DD. "2026-03-15" hợp lệ, còn "15/03/2026", "2026-02-30" và "tomorrow" sẽ gây ra lỗi 400 invalid_body như thường lệ. Đảm bảo POST lưu due vào việc mới.
  2. Thêm GET /api/v1/tasks/overdue vào router. Khai báo route này phía trên route /:id, nếu không Express sẽ coi overdue là một id và chuyển nó đến /:id.
  3. Route đọc ngày dùng để so sánh từ ?today=, ví dụ ?today=2026-03-20. Nếu không có today, route sẽ dùng ngày hiện tại viết theo định dạng YYYY-MM-DD. Nếu today không phải ngày hợp lệ, chẳng hạn ?today=soon, route sẽ trả về trạng thái 400:
    { "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } }
  4. Route trả về một mảng thuần gồm các việc, không phải đối tượng phân trang mà danh sách trả về. Một việc quá hạn khi thỏa cả ba điều kiện: việc đó chưa hoàn thành, có ngày due và ngày đó trước today. Việc có ngày đến hạn chính là today thì chưa quá hạn. Sắp xếp mảng theo due, ngày sớm nhất đứng trước.

Thử ngay: trong một lần chạy mới, gửi POST { "title": "Renew the lease", "due": "2026-03-15" }, sau đó gửi { "title": "File the taxes", "due": "2026-03-01" } đến /api/v1/tasks. Cả hai đều trả về 201 cùng với due. Sau đó, GET /api/v1/tasks/overdue?today=2026-03-20 trả về ngày đến hạn sớm hơn trước (thời điểm createdAt của bạn sẽ khác):

[
  { "id": 5, "title": "File the taxes", "done": false, "labels": [], "due": "2026-03-01", "createdAt": "2026-10-30T09:15:02.118Z" },
  { "id": 4, "title": "Renew the lease", "done": false, "labels": [], "due": "2026-03-15", "createdAt": "2026-10-30T09:14:40.006Z" }
]

GET /api/v1/tasks/overdue?today=2026-03-15 chỉ trả về "File the taxes". GET /api/v1/tasks/overdue?today=soon trả về 400 với mã invalid_query.

Tự làm thử

import { Router } from 'express';
import { z } from 'zod';
import { config } from '../config.js';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// Một tác vụ đầy đủ như PUT gửi đến. .strict() từ chối các trường không có trong danh sách của nó.
const Task = z
    .object({
        title: z.string().trim().min(1).max(100),
        done: z.boolean(),
        labels: z.array(z.string().trim().min(1).max(20)).max(5).optional(),
    })
    .strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];

// True nếu một tác vụ khác đã có tiêu đề này, không phân biệt chữ hoa chữ thường.
const titleTaken = (title, exceptId) =>
    tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());

router.get('/', (req, res) => {
    const { done, q, sort, label } = req.query;
    let list = tasks;
    if (done !== undefined) {
        if (done !== 'true' && done !== 'false') {
            return sendError(res, 400, 'invalid_query', 'done must be true or false');
        }
        list = list.filter((t) => t.done === (done === 'true'));
    }
    if (q !== undefined) {
        const words = String(q).toLowerCase();
        list = list.filter((t) => t.title.toLowerCase().includes(words));
    }
    if (label !== undefined) {
        list = list.filter((t) => t.labels.includes(String(label)));
    }
    if (sort !== undefined) {
        const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
        if (!SORT_FIELDS.includes(field)) {
            return sendError(
                res,
                400,
                'invalid_query',
                'sort must be title or createdAt, with - in front to reverse',
            );
        }
        const direction = String(sort).startsWith('-') ? -1 : 1;
        list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
    }
    const page = req.query.page === undefined ? 1 : Number(req.query.page);
    const limit = req.query.limit === undefined ? config.pageSize : Number(req.query.limit);
    if (!Number.isInteger(page) || page < 1) {
        return sendError(res, 400, 'invalid_query', 'page must be a whole number from 1');
    }
    if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
        return sendError(res, 400, 'invalid_query', 'limit must be a whole number from 1 to 50');
    }
    const start = (page - 1) * limit;
    res.json({
        items: list.slice(start, start + limit),
        page,
        total: list.length,
        pages: Math.ceil(list.length / limit),
    });
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const result = NewTask.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { title, done = false, labels = [] } = result.data;
    if (titleTaken(title)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    const task = { id: nextId(), title, done, labels, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/v1/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = TaskChanges.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    Object.assign(task, result.data);
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = Task.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { id, createdAt } = tasks[index];
    if (titleTaken(result.data.title, id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    tasks[index] = { id, ...result.data, labels: result.data.labels ?? [], createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

Tất cả bài học trong REST API

Tự luyện tập: Playground Express