مشروع: API مهام كامل للإنتاج — REST APIs
مشروع: API مهام كامل للإنتاج
ستبني API لتطبيق مهام فرق صغيرة. ليس "قائمة مهام في الذاكرة" بل خدمة تصلح للنشر.
المتطلبات
1. تسجيل ودخول (رمز وصول قصير + تجديد) — من دورة المصادقة.
2. مشاريع يملكها مستخدم، ومهام داخل المشروع (عنوان، وصف، حالة، أولوية، تاريخ استحقاق).
3. المستخدم يصل لمشاريعه فقط (لا IDOR).
4. قائمة المهام بترقيم وفلترة (الحالة، الأولوية) وترتيب من قائمة بيضاء.
5. توثيق OpenAPI واختبارات.
المخطط (PostgreSQL)
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE projects (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 100)
);
CREATE TABLE tasks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'doing', 'done')),
priority SMALLINT NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 3),
due_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX tasks_project_status_idx ON tasks(project_id, status, created_at DESC);البنية
src/
├── config.js app.js server.js
├── lib/ (errors.js, logger.js, validate.js, auth.js)
├── db/ (pool.js, migrations/)
└── modules/
├── auth/ (routes, service, repository, tests)
├── projects/
└── tasks/المسارات
| الطريقة والمسار | الوصف | النجاح |
|---|---|---|
| POST /v1/auth/register و /login و /refresh | المصادقة | 201 / 200 |
| GET /v1/projects | مشاريعي | 200 |
| POST /v1/projects | إنشاء مشروع | 201 |
| GET /v1/projects/:id/tasks?status=&priority=&sort=&page=&limit= | مهام مشروع أملكه | 200 |
| POST /v1/projects/:id/tasks | إضافة مهمة | 201 |
| PATCH /v1/tasks/:id | تعديل (حقول مسموحة فقط) | 200 |
| DELETE /v1/tasks/:id | حذف | 204 |
قواعد يجب أن تثبتها الاختبارات
- مستخدم ب يطلب مهام مشروع المستخدم أ ← 404 (لا يُكشف وجود المشروع).
- PATCH بحقل project_id أو id ← يُتجاهل أو يُرفض (لا نقل مهمة لمشروع آخر).
- limit=1000 ← يُقص إلى 100؛ sort=password_hash ← يُتجاهل.
- priority: 5 ← 400 برسالة واضحة؛ بريد مكرر عند التسجيل ← 409.
- كل الاستعلامات معاملية.
الملكية في الاستعلام نفسه
بدل "اجلب المهمة ثم افحص المالك"، اربط الملكية في SQL:
CREATE TABLE users (id BIGINT PRIMARY KEY);
CREATE TABLE projects (id BIGINT PRIMARY KEY, owner_id BIGINT REFERENCES users(id));
CREATE TABLE tasks (id BIGINT PRIMARY KEY, project_id BIGINT REFERENCES projects(id), title TEXT, status TEXT);
INSERT INTO users VALUES (1), (2);
INSERT INTO projects VALUES (10, 1), (20, 2);
INSERT INTO tasks VALUES (100, 10, 'مهمة سارة', 'todo'), (200, 20, 'مهمة عمر', 'todo');
-- المستخدم 1 يحاول تعديل مهمة المستخدم 2: لا يُعدَّل أي صف ← 404
UPDATE tasks t SET status = 'done'
FROM projects p
WHERE t.id = 200 AND p.id = t.project_id AND p.owner_id = 1
RETURNING t.id;معايير الإنجاز
- [ ] npm test أخضر، مع اختبارات للحماية لا للنجاح فقط.
- [ ] مواصفة OpenAPI تطابق المسارات (فحص CI من درس التوثيق).
- [ ] لا سر في الكود، والإعدادات متحقق منها عند البدء.
- [ ] سجلات JSON بمعرّف طلب وبلا بيانات حساسة.
- [ ] إيقاف منظم وفحوص صحة.
- [ ] نشر على Render أو Railway مع PostgreSQL مُدار (دورة النشر).
// tasks.routes.js — مسارات المهام: رقيقة، والملكية جزء من كل استعلام
// npm install express zod pg
import express from "express";
import { z } from "zod";
import { pool } from "../../db/pool.js";
import { requireAuth } from "../../lib/auth.js";
export const tasksRouter = express.Router();
const SORTS = { newest: "t.created_at DESC", priority: "t.priority ASC, t.created_at DESC", due: "t.due_at ASC NULLS LAST" };
const listQuery = z.object({
status: z.enum(["todo", "doing", "done"]).optional(),
priority: z.coerce.number().int().min(1).max(3).optional(),
sort: z.enum(Object.keys(SORTS)).default("newest"),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).catch(20).default(20), // 1000 ← 20 بدل الخطأ
});
const createTask = z.object({
title: z.string().trim().min(1).max(200),
priority: z.number().int().min(1).max(3).default(2),
dueAt: z.string().datetime().optional(),
}).strict();
const updateTask = z.object({
title: z.string().trim().min(1).max(200).optional(),
status: z.enum(["todo", "doing", "done"]).optional(),
priority: z.number().int().min(1).max(3).optional(),
}).strict(); // project_id و id مرفوضان
const notFound = (res) => res.status(404).json({ error: { code: "NOT_FOUND", message: "غير موجود" } });
tasksRouter.get("/projects/:projectId/tasks", requireAuth, async (req, res) => {
const q = listQuery.parse(req.query);
const params = [req.params.projectId, req.user.id];
const where = ["p.id = $1", "p.owner_id = $2"];
if (q.status) { params.push(q.status); where.push(`t.status = $${params.length}`); }
if (q.priority) { params.push(q.priority); where.push(`t.priority = $${params.length}`); }
params.push(q.limit, (q.page - 1) * q.limit);
const { rows } = await pool.query(
`SELECT t.id, t.title, t.status, t.priority, t.due_at
FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE ${where.join(" AND ")}
ORDER BY ${SORTS[q.sort]}
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
);
res.json({ data: rows, page: q.page, limit: q.limit });
});
tasksRouter.post("/projects/:projectId/tasks", requireAuth, async (req, res) => {
const body = createTask.parse(req.body);
const { rows } = await pool.query(
`INSERT INTO tasks (project_id, title, priority, due_at)
SELECT p.id, $3, $4, $5 FROM projects p WHERE p.id = $1 AND p.owner_id = $2
RETURNING id, title, status, priority, due_at`,
[req.params.projectId, req.user.id, body.title, body.priority, body.dueAt ?? null]
);
if (rows.length === 0) return notFound(res); // المشروع ليس لك أو غير موجود
res.status(201).json(rows[0]);
});
tasksRouter.patch("/tasks/:id", requireAuth, async (req, res) => {
const changes = updateTask.parse(req.body);
const fields = Object.entries({ title: changes.title, status: changes.status, priority: changes.priority }).filter(([, v]) => v !== undefined);
if (fields.length === 0) return res.status(400).json({ error: { code: "EMPTY", message: "لا تغييرات" } });
const sets = fields.map(([k], i) => `${k} = $${i + 3}`); // أسماء أعمدة من قائمة ثابتة لا من المستخدم
const { rows } = await pool.query(
`UPDATE tasks t SET ${sets.join(", ")}
FROM projects p
WHERE t.id = $1 AND p.id = t.project_id AND p.owner_id = $2
RETURNING t.id, t.title, t.status, t.priority`,
[req.params.id, req.user.id, ...fields.map(([, v]) => v)]
);
if (rows.length === 0) return notFound(res);
res.json(rows[0]);
});
tasksRouter.delete("/tasks/:id", requireAuth, async (req, res) => {
const { rowCount } = await pool.query(
"DELETE FROM tasks t USING projects p WHERE t.id = $1 AND p.id = t.project_id AND p.owner_id = $2",
[req.params.id, req.user.id]
);
if (rowCount === 0) return notFound(res);
res.status(204).end();
});اختبر فهمك