Menu
CoddyTech

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

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

يُحدَّد للمهام تاريخ استحقاق، وتُظهر اللوحة المهام المتأخرة. يُمثَّل التاريخ الذي لا يتضمن وقتًا كسلسلة 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: يجرّب Express المسارات بالترتيب، ويطابق /:id أي كلمة.

challenge icon

التحدي

سهل

توضح اللوحة المهام المتأخرة. أضف تاريخ استحقاق اختياريًا إلى المهام، وأضف قائمة بالمهام التي فات موعد استحقاقها.

  1. في routes/tasks.js، أضف حقل due اختياريًا إلى مخطط Task، وتحقق منه باستخدام z.iso.date(). يقبل هذا الحقل التواريخ الحقيقية المكتوبة فقط بصيغة YYYY-MM-DD. تُقبل "2026-03-15"، بينما ينتج عن "15/03/2026" و"2026-02-30" و"tomorrow" خطأ 400 المعتاد invalid_body. تأكد من أن POST يحفظ due في المهمة الجديدة.
  2. أضف GET /api/v1/tasks/overdue إلى الموجّه. عرّف هذا المسار قبل المسار /:id، وإلا فسيرسل Express القيمة overdue إلى /:id باعتبارها معرّفًا.
  3. يقرأ المسار التاريخ المراد المقارنة به من ?today=، مثل ?today=2026-03-20. إذا لم تُحدَّد قيمة today، فسيستخدم التاريخ الحالي المكتوب بصيغة YYYY-MM-DD. إذا لم تكن قيمة today تاريخًا حقيقيًا، مثل ?today=soon، فستكون حالة الاستجابة 400:
    { "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } }
  4. يعيد المسار مصفوفة عادية من المهام، وليس كائن الصفحة الذي تعيده قائمة المهام. تكون المهمة متأخرة عند تحقق الشروط الثلاثة كلها: لم تُنجز، ولها تاريخ due، وذلك التاريخ يسبق today. لا تُعد المهمة التي يحل تاريخ استحقاقها في today متأخرة بعد. رتّب المصفوفة حسب due، بحيث يأتي التاريخ الأسبق أولًا.

جرّب ذلك: في تشغيل جديد، أرسل POST بالقيمة { "title": "Renew the lease", "due": "2026-03-15" }، ثم بالقيمة { "title": "File the taxes", "due": "2026-03-01" } إلى /api/v1/tasks. ستعيد كلتا العمليتين الحالة 201 مع قيمة due. بعد ذلك، يعيد GET /api/v1/tasks/overdue?today=2026-03-20 المهمة ذات تاريخ الاستحقاق الأسبق أولًا (ستختلف أوقات createdAt لديك):

[
  { "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 المهمة "File the taxes" فقط. ويعيد GET /api/v1/tasks/overdue?today=soon الحالة 400 مع الرمز invalid_query.

جرّب بنفسك

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

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

تدرّب بنفسك: Playground لـ Express