Menu
CoddyTech

تواريخ الاستحقاق

جزء من قسم واجهات REST API في رحلة Node.js على Coddy. الدرس 18 من 18.

تُحدَّد للمهام تواريخ استحقاق، وتُظهر اللوحة المهام المتأخرة. يُمثَّل التاريخ الذي لا يتضمن وقتًا كسلسلة ISO، 2026-03-15، ويتحقق منه zod باستخدام z.iso.date():

z.iso.date().safeParse('2026-07-01').success   // true
z.iso.date().safeParse('2026-02-30').success   // false: لا يوجد يوم بهذا التاريخ
'2026-03-01' < '2026-03-15'                    // true

تكون تواريخ ISO متساوية الطول وتضع السنة أولًا، لذا فإن مقارنتها كسلاسل نصية تعطي ترتيبًا زمنيًا. يجب أن يسبق المسار الثابت مثل /overdue المسار /:id في الموجّه: يجرّب Express المسارات بالترتيب، ويطابق /:id أي كلمة.

challenge icon

التحدي

سهل
  • قد تحتوي المهمة على due، ويتم التحقق منها باستخدام z.iso.date().
  • يعرض GET /api/v1/tasks/overdue?today= المهام المفتوحة التي يحين موعدها قبل today، بدءًا بالأقدم. إذا لم تُحدَّد today، فسيستخدم التاريخ الحالي؛ أما التاريخ غير الصالح فيؤدي إلى استجابة 400 invalid_query.

أرسل POST { "title": "File the taxes", "due": "2026-03-01" }، ثم أرسل GET /api/v1/tasks/overdue?today=2026-03-20 و?today=soon.

جرّب بنفسك

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

// مهمة كاملة، كما يرسلها PUT. ترفض .strict() الحقول التي لا تُدرجها.
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 عندما تكون هناك مهمة أخرى تحمل هذا العنوان، بغض النظر عن حالة الأحرف.
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).replace(/^-/, '');
        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;

جميع دروس واجهات REST API