Kodax

مشروع: واجهة مدونة بـ 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 } },
  ]);
}
اختبر فهمك