مشروع: واجهة مدونة بـ MongoDB — MongoDB
مشروع: واجهة مدونة بـ MongoDB
ستصمم طبقة البيانات لمدونة متعددة الكتّاب. الهدف ليس CRUD فقط، بل قرارات تصميم مبنية على كيف تُقرأ البيانات.
الشاشات (أنماط القراءة)
1. الصفحة الرئيسية: أحدث المنشورات المنشورة، مع اسم الكاتب وعدد التعليقات — تُقرأ آلاف المرات.
2. صفحة المنشور: المنشور كاملاً + أول 20 تعليقاً + زر "المزيد".
3. صفحة الكاتب: منشوراته الأحدث أولاً.
4. لوحة الكاتب: عدد المشاهدات والتعليقات لكل منشور في آخر 30 يوماً.
قرارات التصميم
| القرار | السبب |
|---|---|
| posts يضمّن author: { _id, name } | الصفحة الرئيسية تعرض الاسم بلا populate؛ عند تغيير الاسم (نادر) نحدّث المنشورات بـ updateMany |
| comments مجموعة منفصلة بمرجع post | التعليقات غير محدودة العدد — مصفوفة داخل المنشور تنمو بلا حد |
| commentsCount و views داخل المنشور | تُعرض كثيراً؛ تُحدَّث بـ $inc ذري بدل العد في كل عرض |
| فهرس { status: 1, publishedAt: -1 } | الصفحة الرئيسية |
| فهرس { "author._id": 1, publishedAt: -1 } | صفحة الكاتب |
| فهرس { post: 1, createdAt: 1 } على التعليقات | تعليقات منشور بالترتيب |
| slug فريد | روابط مقروءة /posts/my-first-post |
تزامن العدّاد مع التعليق
إضافة تعليق = إدراج في comments + $inc: { commentsCount: 1 } في المنشور. إن نجح الأول وفشل الثاني ينحرف العدد قليلاً. خياران:
- معاملة (الدرس السابق) — دقة كاملة بتكلفة أعلى.
- قبول انحراف مؤقت + مهمة دورية تعيد حساب العدّادات من التعليقات. شائع جداً لعدّادات العرض، وغير مقبول للمال.
المثال يستخدم المعاملة.
الترقيم
الصفحة الرئيسية بالمؤشر: publishedAt < آخر_تاريخ_في_الصفحة (مع _id لكسر التعادل) بدل skip — ثابت وسريع مع النشر المستمر.
التقرير التجميعي
لوحة الكاتب بـ aggregate: $match على الكاتب والفترة (يستخدم الفهرس) ← $project للحقول ← $sort.
الأمان
- sanitizeFilter مفعّل، والمدخلات تُحوّل لأنواعها.
- الكاتب يعدّل منشوراته فقط: الفلتر يتضمن "author._id": currentUserId في كل تحديث/حذف (منع IDOR).
- محتوى المنشور والتعليقات يُعرض في الواجهة كنص أو بعد تعقيم HTML (منع XSS المخزّن).
تحديات إضافية
1. الإعجابات: مجموعة likes بفهرس فريد { post, user } يمنع الإعجاب المزدوج، مع likesCount.
2. الوسوم: مصفوفة tags بفهرس multikey وصفحة لكل وسم.
3. المسودات والنشر المجدول (status: "scheduled" و publishedAt مستقبلي).
4. البحث بـ Atlas Search، أو فهرس نصي كبداية.
// blog.js — طبقة بيانات المدونة (npm install mongoose)
import mongoose from "mongoose";
const { Schema } = mongoose;
mongoose.set("sanitizeFilter", true);
const postSchema = new Schema(
{
title: { type: String, required: true, trim: true, maxlength: 150 },
slug: { type: String, required: true, unique: true, lowercase: true },
body: { type: String, required: true },
author: { _id: { type: Schema.Types.ObjectId, required: true }, name: { type: String, required: true } },
status: { type: String, enum: ["draft", "published"], default: "draft" },
publishedAt: Date,
commentsCount: { type: Number, default: 0, min: 0 },
views: { type: Number, default: 0, min: 0 },
},
{ timestamps: true }
);
postSchema.index({ status: 1, publishedAt: -1, _id: -1 });
postSchema.index({ "author._id": 1, publishedAt: -1 });
const commentSchema = new Schema(
{
post: { type: Schema.Types.ObjectId, ref: "BlogPost", required: true },
user: { _id: Schema.Types.ObjectId, name: String },
text: { type: String, required: true, maxlength: 2000 },
},
{ timestamps: true }
);
commentSchema.index({ post: 1, createdAt: 1 });
const Post = mongoose.models.BlogPost ?? mongoose.model("BlogPost", postSchema);
const Comment = mongoose.models.BlogComment ?? mongoose.model("BlogComment", commentSchema);
// الصفحة الرئيسية: ترقيم بالمؤشر (publishedAt ثم _id لكسر التعادل)
export async function latestPosts({ before, beforeId, limit = 10 } = {}) {
const filter = { status: "published" };
if (before) {
filter.$or = [
{ publishedAt: { $lt: new Date(before) } },
{ publishedAt: new Date(before), _id: { $lt: new mongoose.Types.ObjectId(String(beforeId)) } },
];
}
return Post.find(filter)
.sort({ publishedAt: -1, _id: -1 })
.limit(Math.min(50, limit))
.select("title slug author.name publishedAt commentsCount")
.lean();
}
// صفحة المنشور: زيادة المشاهدات ذرياً + أول التعليقات
export async function openPost(slug) {
const post = await Post.findOneAndUpdate({ slug: String(slug), status: "published" }, { $inc: { views: 1 } }, { new: true }).lean();
if (!post) return null;
const comments = await Comment.find({ post: post._id }).sort({ createdAt: 1 }).limit(20).lean();
return { post, comments };
}
// تعليق + العدّاد معاً في معاملة
export async function addComment(postId, user, text) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const updated = await Post.updateOne({ _id: postId, status: "published" }, { $inc: { commentsCount: 1 } }, { session });
if (updated.matchedCount === 0) throw new Error("المنشور غير موجود");
await Comment.create([{ post: postId, user, text: String(text).slice(0, 2000) }], { session });
});
} finally {
await session.endSession();
}
}
// الكاتب يعدّل منشوراته فقط: المالك جزء من الفلتر (منع IDOR)
export async function updateOwnPost(postId, currentUserId, changes) {
const allowed = {};
if (typeof changes.title === "string") allowed.title = changes.title;
if (typeof changes.body === "string") allowed.body = changes.body;
const result = await Post.updateOne({ _id: postId, "author._id": currentUserId }, { $set: allowed }, { runValidators: true });
if (result.matchedCount === 0) throw new Error("غير موجود"); // 404 حتى لا نكشف وجود منشورات الآخرين
}
// لوحة الكاتب: آخر 30 يوماً
export async function authorDashboard(authorId) {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return Post.aggregate([
{ $match: { "author._id": new mongoose.Types.ObjectId(String(authorId)), publishedAt: { $gte: since } } },
{ $project: { _id: 0, title: 1, views: 1, commentsCount: 1, engagement: { $add: ["$views", { $multiply: ["$commentsCount", 5] }] } } },
{ $sort: { engagement: -1 } },
]);
}اختبر فهمك