تواريخ الاستحقاق
جزء من قسم واجهات 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 أي كلمة.
التحدي
سهل- قد تحتوي المهمة على
due، ويتم التحقق منها باستخدامz.iso.date(). - يعرض
GET /api/v1/tasks/overdue?today=المهام المفتوحة التي يحين موعدها قبلtoday، بدءًا بالأقدم. إذا لم تُحدَّدtoday، فسيستخدم التاريخ الحالي؛ أما التاريخ غير الصالح فيؤدي إلى استجابة 400invalid_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;