الدرس 28 · الوحدة 13: التنظيم وسهولة الصيانة والتوسع
المشروع الختامي: تطبيق ويب متكامل
كل ما تعلمته في تطبيق واحد يستخدمه الناس ⏱ نحو 60 دقيقة.
بعد هذا الدرس ستستطيع أن:
- تخطط لتطبيق ويب متكامل من المتطلبات إلى النشر.
- تدمج
ReactوExpressوSequelizeوJWTوSocket.ioفي مشروع واحد. - تعمل في فريق بـ
Gitومراجعة الكود. - تقيّم مشروعك بمعايير الجودة والأمان والأداء.
١المشروع: تطبيق ويب متكامل
حان وقت جمع كل ما تعلمته في تطبيق واحد حقيقي، تبنيه في فريق من 2 إلى 3 طلاب خلال الأسابيع الأخيرة. اختر فكرة تخدم مستخدمين حقيقيين في كليتك أو مجتمعك، أو اختر من الأفكار المقترحة:
| الفكرة | المستخدمون والأدوار | ميزة فورية مقترحة |
|---|---|---|
| مكتبة الكلية: إعارة الكتب | طالب، أمين مكتبة | إشعار عند توفر كتاب محجوز |
| حجز قاعات ومعامل | محاضر، مشرف | تحديث الجدول لحظيًّا عند الحجز |
| متجر النادي الطلابي | مشترٍ، بائع، مشرف | تتبع حالة الطلب مباشرة |
| منصة مشاريع التخرج | طالب، مشرف أكاديمي | دردشة الفريق مع المشرف |
| بلاغات الصيانة | موظف، فني، مدير | إشعار الفني فور البلاغ |
٢المتطلبات الإلزامية
| الجانب | المطلوب | الدروس |
|---|---|---|
| الواجهة | React بمكوّنات وخصائص وحالة، و Router لعدة صفحات، وتصميم متجاوب بـ Tailwind | 1–9 |
| إدارة الحالة | Context أو Redux Toolkit لحالة مشتركة (المستخدم الحالي مثلًا) | 5–7 |
| الخادم | Express بهيكلة: routes و controllers و services، ووسيط أخطاء موحّد | 10–12، 27 |
| قاعدة البيانات | Sequelize بثلاثة نماذج على الأقل وعلاقة واحد إلى متعدد | 13–14 |
| الأمان | تسجيل ودخول بـ bcrypt و JWT، ودوران (أدوار) على الأقل، وحماية من XSS وحقن SQL | 15–16، 25–26 |
| واجهة REST | مسارات بأسلوب REST ورموز حالة صحيحة وتوثيق مختصر | 17–18 |
| ميزة فورية | Socket.io لميزة واحدة على الأقل | 19–20 |
| الأداء | تحسين واحد مقيس على الأقل (lazy، أو cache، أو فهرس) | 23–24 |
| النشر | بناء الواجهة، وتشغيل التطبيق في Docker أو نشره على منصة سحابية، مع .env.example | 21–22 |
٣نموذج مصغّر يعمل
هذا هيكل مصغّر لفكرة «مكتبة الكلية» يجمع React و Express و Sequelize و JWT في تطبيق واحد. نافذتان لمستخدمين يتشاركان الخادم نفسه: ادخل في النافذتين، واستعر من كل واحدة «أساسيات الويب» (نسخة واحدة) لترى قاعدة النسخ المتاحة تعمل. اعتبره نقطة انطلاق فقط:
import { useState, useEffect } from 'react';import { api, setToken } from './api.js'; export default function App({ user }) { const [me, setMe] = useState(null); const [books, setBooks] = useState([]); const [msg, setMsg] = useState(''); const load = () => api.get('/api/books').then(setBooks); useEffect(() => { load(); }, []); async function login() { const r = await api.post('/api/login', { name: user || 'زائر' }); setToken(r.token); setMe(r.name); } async function borrow(id) { try { await api.post(`/api/books/${id}/borrow`); setMsg('✓ تمت الإعارة'); load(); } catch (e) { setMsg('⚠ ' + e.message); } } return ( <div> {me ? <p>👤 {me}</p> : <button onClick={login}>دخول باسم {user || 'زائر'}</button>} <ul> {books.map(b => ( <li key={b.id}> {b.title} — متاح: {b.available}{' '} <button disabled={!me || b.available === 0} onClick={() => borrow(b.id)}>استعر</button> </li> ))} </ul> <p className="msg">{msg}</p> </div> );}
const BASE = 'http://localhost:3000';let token = null;export const setToken = (t) => { token = t; }; async function request(method, path, body) { const res = await fetch(BASE + path, { method, headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'خطأ'); return data;} export const api = { get: (p) => request('GET', p), post: (p, b) => request('POST', p, b) };
const express = require('express');const jwt = require('jsonwebtoken');const { Sequelize, DataTypes } = require('sequelize'); const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });const Book = db.define('Book', { title: { type: DataTypes.STRING, allowNull: false }, copies: { type: DataTypes.INTEGER, defaultValue: 1 },}, { timestamps: false });const Loan = db.define('Loan', { user: DataTypes.STRING }, { timestamps: false });Book.hasMany(Loan); Loan.belongsTo(Book); const app = express();app.use(express.json());const SECRET = 'capstone-demo-secret'; app.post('/api/login', (req, res) => { const { name } = req.body; if (!name) return res.status(400).json({ error: 'الاسم مطلوب' }); res.json({ token: jwt.sign({ name }, SECRET, { expiresIn: '1h' }), name });}); function auth(req, res, next) { try { req.user = jwt.verify((req.headers.authorization || '').slice(7), SECRET); next(); } catch { res.status(401).json({ error: 'سجّل الدخول' }); }} app.get('/api/books', async (req, res) => { const books = await Book.findAll({ include: Loan, order: [['id', 'ASC']] }); res.json(books.map(b => ({ id: b.id, title: b.title, available: b.copies - b.Loans.length })));}); app.post('/api/books/:id/borrow', auth, async (req, res) => { const book = await Book.findByPk(req.params.id, { include: Loan }); if (!book) return res.status(404).json({ error: 'غير موجود' }); if (book.Loans.length >= book.copies) return res.status(409).json({ error: 'لا نسخ متاحة' }); if (book.Loans.some(l => l.user === req.user.name)) return res.status(409).json({ error: 'استعرته مسبقًا' }); await Loan.create({ user: req.user.name, BookId: book.id }); res.status(201).json({ ok: true });}); db.sync().then(async () => { await Book.bulkCreate([{ title: 'تعلم JavaScript', copies: 2 }, { title: 'أساسيات الويب', copies: 1 }, { title: 'قواعد البيانات', copies: 1 }]); app.listen(3000);});
٤المراحل والتسليمات
| الأسبوع | المرحلة | التسليم |
|---|---|---|
| 1 | الفكرة والمتطلبات | وثيقة صفحة واحدة: المشكلة، والمستخدمون، والأدوار، وقصص المستخدم (أريد… لكي…) |
| 2 | التصميم | مخطط قاعدة البيانات (الجداول والعلاقات)، وجدول مسارات API، ورسم أولي للشاشات |
| 3–4 | الخادم | النماذج والمسارات والمصادقة، واختبار المسارات يدويًّا (Postman أو REST Client) |
| 5–6 | الواجهة | الصفحات والربط بالـ API، والحالة المشتركة، والتصميم المتجاوب |
| 7 | الميزة الفورية والأمان | Socket.io، ومراجعة XSS والحقن والصلاحيات |
| 8 | الأداء والنشر | تحسين مقيس، و Docker أو نشر سحابي، و README |
| 9 | العرض | عرض حي 10 دقائق + أسئلة، وتسليم المستودع |
٥معايير التقييم
| المعيار | الوزن | ممتاز يعني |
|---|---|---|
| اكتمال الوظائف | 25% | كل قصص المستخدم الأساسية تعمل دون أخطاء |
| جودة الخادم وهيكلته | 20% | طبقات واضحة، ورموز حالة صحيحة، وأخطاء موحّدة، وتحقق من المدخلات |
| الواجهة وتجربة المستخدم | 15% | متجاوبة، وحالات التحميل والخطأ معروضة، وعربية سليمة الاتجاه |
| الأمان | 15% | كلمات مرور مجزّأة، وصلاحيات في الخادم، ولا XSS ولا حقن، ولا أسرار في المستودع |
| الميزة الفورية والأداء | 10% | تعمل بين مستخدمين، وتحسين مقيس موثّق |
| النشر والتوثيق | 10% | يعمل بأمر واحد (Docker) أو برابط، و README واضح، و .env.example |
| العمل الجماعي و Git | 5% | مساهمة كل عضو ظاهرة في تاريخ المستودع |
توقّع وتحقّق
أين تُفرض قاعدة «لا إعارة إن لم تتوفر نسخة»؟
الخادم مصدر الحقيقة.
أول تسليم في المشروع:
ابدأ بالمشكلة.
ما الذي لا يُرفع إلى المستودع؟
الأسرار.
الخلاصة
- ابدأ بالمشكلة والمستخدمين وقصص المستخدم.
- صمّم قاعدة البيانات والمسارات قبل الكود.
- الخادم مصدر الحقيقة: القواعد والصلاحيات فيه.
- كل وحدة في المقرر جزء من المشروع: واجهة، وحالة، وخادم، وبيانات، وأمان، وفورية، وأداء، ونشر.
- سلّم مشروعًا يعمل بأمر واحد مع
READMEواضح.
تحدٍّ برمجي: قاعدة الإعارة في الخادم
متقدمجزء من المشروع: مسار الإعارة والإرجاع في الذاكرة. أكمل القواعد.
المطلوب:
POST /api/loansبالجسم{"bookId", "user"}: 404 إن لم يوجد الكتاب، و409{"error": "لا نسخ متاحة"}إن نفدت النسخ، و409 إن استعاره المستخدم نفسه، وإلا 201 والإعارة{"id", "bookId", "user"}.DELETE /api/loans/:idيُرجع الكتاب(204)، أو 404.GET /api/booksيعرض لكل كتابavailable= النسخ − الإعارات الحالية.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
POST /api/loans{"bookId": 2, "user": "ريم"}POST /api/loans{"bookId": 2, "user": "فهد"}POST /api/loans{"bookId": 1, "user": "ريم"}POST /api/loans{"bookId": 1, "user": "ريم"}POST /api/loans{"bookId": 9, "user": "ريم"}GET /api/booksDELETE /api/loans/1DELETE /api/loans/1GET /api/books
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- الإعارة الأولى تنجح
- … بالحالة 201
- النسخة الوحيدة نفدت ← «لا نسخ متاحة»
- التكرار للمستخدم نفسه ← 409
- كتاب غير موجود ← 404
- available محسوبة لكل كتاب
- الإرجاع ← 204
- إرجاع المرجَع ← 404
- بعد الإرجاع صار «أساسيات الويب» متاحًا
💡 تلميح 1
دالة مساعدة تعدّ الإعارات الحالية لكتاب:
loans.filter(l => l.bookId === id).length.💡 تلميح 2
رتّب الفحوص: الوجود، ثم النسخ، ثم التكرار.
حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const app = express();app.use(express.json()); const books = [{ id: 1, title: 'تعلم JavaScript', copies: 2 }, { id: 2, title: 'أساسيات الويب', copies: 1 }];let loans = [];let nextId = 1;const out = (bookId) => loans.filter(l => l.bookId === bookId).length; app.get('/api/books', (req, res) => res.json(books.map(b => ({ id: b.id, title: b.title, available: b.copies - out(b.id) })))); app.post('/api/loans', (req, res) => { const { bookId, user } = req.body; const book = books.find(b => b.id === bookId); if (!book) return res.status(404).json({ error: 'غير موجود' }); if (out(bookId) >= book.copies) return res.status(409).json({ error: 'لا نسخ متاحة' }); if (loans.some(l => l.bookId === bookId && l.user === user)) return res.status(409).json({ error: 'استعرته مسبقًا' }); const loan = { id: nextId++, bookId, user }; loans.push(loan); res.status(201).json(loan);}); app.delete('/api/loans/:id', (req, res) => { const n = loans.length; loans = loans.filter(l => l.id !== Number(req.params.id)); loans.length < n ? res.sendStatus(204) : res.status(404).json({ error: 'غير موجود' });}); app.listen(3000);