Menu
CoddyTech

Dates d’échéance

Fait partie de la section API REST du Journey Node.js de Coddy. Leçon 25 sur 25.

Les tâches reçoivent une date d’échéance, et le tableau indique lesquelles sont en retard. Une date sans heure est transmise sous forme de chaîne ISO, 2026-03-15, que zod vérifie avec z.iso.date() :

z.iso.date().safeParse('2026-07-01').success   // true
z.iso.date().safeParse('2026-02-30').success   // false : ce jour n’existe pas
'2026-03-01' < '2026-03-15'                    // true

Les dates ISO ont toutes la même longueur et placent l’année en premier ; les comparer sous forme de chaînes donne donc l’ordre chronologique. Un chemin fixe comme /overdue doit apparaître avant /:id dans le routeur : Express essaie les routes dans l’ordre, et /:id correspond à n’importe quel mot.

challenge icon

Défi

Facile

Le tableau indique quelles tâches sont en retard. Ajoutez une date d’échéance facultative aux tâches et ajoutez une liste de celles qui sont en retard.

  1. Dans routes/tasks.js, ajoutez un champ facultatif due au schéma Task, validé avec z.iso.date(). Il n’accepte que des dates réelles écrites au format YYYY-MM-DD. "2026-03-15" est accepté, tandis que "15/03/2026", "2026-02-30" et "tomorrow" provoquent l’erreur 400 habituelle invalid_body. Assurez-vous que POST enregistre due sur la nouvelle tâche.
  2. Ajoutez GET /api/v1/tasks/overdue au routeur. Déclarez cette route au-dessus de la route /:id, sinon Express interprète overdue comme un identifiant pour /:id.
  3. La route lit la date de comparaison dans ?today=, par exemple ?today=2026-03-20. Si today n’est pas fourni, elle utilise la date actuelle, écrite au format YYYY-MM-DD. Si today n’est pas une date réelle, comme ?today=soon, la route renvoie le statut 400 :
    { "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } }
  4. La route renvoie un tableau simple de tâches, et non l’objet de pagination renvoyé par la liste. Une tâche est en retard si les trois conditions suivantes sont réunies : elle n’est pas terminée, elle a une date due et cette date est antérieure à today. Une tâche dont l’échéance tombe le jour même de today n’est pas encore en retard. Triez le tableau par due, de la date la plus proche à la plus éloignée.

Essayez : lors d’une nouvelle exécution avec Run, envoyez POST avec { "title": "Renew the lease", "due": "2026-03-15" }, puis avec { "title": "File the taxes", "due": "2026-03-01" } à /api/v1/tasks. Les deux requêtes renvoient 201 avec leur valeur due. Ensuite, GET /api/v1/tasks/overdue?today=2026-03-20 renvoie d’abord la tâche dont l’échéance est la plus proche (les heures createdAt seront différentes) :

[
  { "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 renvoie uniquement « File the taxes ». GET /api/v1/tasks/overdue?today=soon renvoie le statut 400 avec le code invalid_query.

Essayez vous-même

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();

// Une tâche complète, telle que PUT l’envoie. .strict() refuse les champs qu’elle ne répertorie pas.
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'];

// Vrai lorsqu’une autre tâche porte déjà ce titre, quelle que soit la casse.
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;

Toutes les leçons de API REST

Entraînez-vous par vous-même : Playground Express