// Real-World Comment System Engineering
Comment System মানে
শুধু table এ row insert
না — এটা ভুল।
বেশিরভাগ developer comment system বানায় তিনটা জিনিস দিয়ে: একটা table, একটা INSERT, একটা SELECT।
তারপর যখন nested reply, notification, soft delete, admin dashboard, multi-tenant filtering আসে — সব ভেঙে
পড়ে।
আজকে দেখবো — কীভাবে একটা production-grade comment system
বানাতে হয় যেটা লাখ user handle করতে পারে।
Nested Comments
Soft Delete
Push Notification
Multi-tenant Filter
Admin Dashboard
02
// The Wrong Way
সবাই যে ভুলগুলো করে
Junior থেকে senior — এই mistakes দেখা যায় সব জায়গায়।
❌ ভুল #1 — Hard Delete
DELETE FROM comments WHERE id = ?
Data
চলে গেলে recover করা যায় না। Child replies orphan হয়ে যায়।
❌ ভুল #2 — Recursive Query নেই
Nested reply fetch করতে সবাই multiple round-trip করে। N+1 query problem।
❌ ভুল #3 — Reply মানে নতুন Table
comments
+ comment_replies
— দুটো table, দুটো service, দুটো join। Self-referential হলে একটাই যথেষ্ট।
❌ ভুল #4 — Reply এ Notification নেই
Teacher reply দিলে student জানতে পারে না। Engagement শূন্য হয়ে যায়।
❌ ভুল #5 — Admin সব comment দেখে
Multi-course platform এ admin নিজের course এর বাইরের comment দেখতে পারা —
data leak।
কেন এই
ভুলগুলো হয়?
// ❌ Most people do this
const comment = await db.query(
`SELECT * FROM comments
WHERE content_id = ?`,
[contentId]
);
// Then for each comment, get replies:
for (const c of
comments) {
c.replies = await db.query(
`SELECT * FROM replies
WHERE comment_id = ?`,
[c.id]
);
}
// 100 comments = 101 DB queries 😱
// N+1 Problem!
এই system এ সমাধান কীভাবে?
Single classComment
table, parentId
self-reference, Prisma include দিয়ে একটাই query।
03
// Data Model
Self-Referential Table Design
একটাই table — parent comment এবং child reply দুটোই। parentId = null
মানে root comment।
// Prisma Schema
model classComment {
id String @id @default(uuid())
comment String
// Who wrote it (mutually exclusive)
studentId String?
adminId String?
student Student? @relation(...)
admin Admin? @relation(...)
// Where it belongs
classContentId String? // Admission/FRB
cycleContentId String? // Academic
// 🔑 Self-reference = nested reply
parentId String?
parent classComment? @relation("CommentReplies", fields: [parentId],
...)
replies classComment[] @relation("CommentReplies")
// Soft delete
isDeleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Visual
Tree Structure
Key Insight
parentId = null →
root comment
parentId = someId
→ reply
Depth কতটাই হোক — একটাই table, একটাই model।
04
// Nested Fetch Strategy
Recursive Reply Fetch
Single content এর সব nested comment — recursive function
দিয়ে unlimited depth support।
// getSingleCommentfromDb()
async function getReplies(commentId) {
const replies = await prisma.classComment.findMany({
where: {
parentId: commentId,
isDeleted: false, // soft-deleted বাদ
},
orderBy: { createdAt: 'asc' },
select: selectFields,
});
// Recursively fetch each reply's children
return Promise.all(
replies.map(async (r) => ({
...r,
replies: await getReplies(r.id),
}))
);
}
// Root comments first (parentId: null)
const parentComments = await prisma.classComment
.findMany({
where: {
OR: [
{ classContentId: id },
{ cycleContentId: id }, // dual content!
],
parentId: null,
isDeleted: false,
},
orderBy: { createdAt: 'desc' },
select: selectFields,
});
// Attach nested replies
return Promise.all(
parentComments.map(async (c) => ({
...c,
replies: await getReplies(c.id),
}))
);
কেন এই approach ভালো?
Depth unlimited — 3 level, 5 level, যতই nested
হোক কাজ করবে।
Promise.all() — replies গুলো parallel এ fetch
হয়, sequential না।
isDeleted filter — deleted comment automatically
hide।
⚡
Dual Content Support
classContentId
— Admission/FRB platform
cycleContentId
— Academic platform
একটাই endpoint, দুই platform এর content handle করে।
🔒
selectFields — nestedSelectFields Helper
nestedSelectFields(["student.name", "admin.photo"])
→ automatically nested Prisma select object। Over-fetching নেই।
⚠️ একটা সীমাবদ্ধতা
Deeply nested (5+ level) হলে DB round-trips বাড়ে। Fix: CTE (Common Table
Expression) বা Redis cache। এই system এ comment সাধারণত 2 level — safe।
05
// Admin Dashboard
Admin Dashboard — 5টা Query একসাথে
Admin একটা request এ পায়: total, filtered, replied,
pending, today — সব prisma.$transaction([])
দিয়ে।
// One DB round-trip, 5 queries parallel
const [
totalCount,
comments,
filteredTotalCount,
repliedComments,
todayComments,
] = await prisma.$transaction([
// 1. সব parent comment count (unfiltered)
prisma.classComment.count({ where: totalCountWhere }),
// 2. Actual data (paginated, filtered)
prisma.classComment.findMany({
where: listWhere, orderBy, skip, take,
select: getAllselectFieldsForGetAllComments,
}),
// 3. Filtered total (for pagination)
prisma.classComment.count({ where: listWhere }),
// 4. Replied count (admin replied করেছে)
prisma.classComment.count({ where: repliedCountWhere }),
// 5. Today's comments (24h window)
prisma.classComment.count({ where: todayCountWhere }),
]);
const pendingComments = totalCount - repliedComments;
// pending derive করা — আলাদা query লাগে না!
5
Queries in 1 round-trip
0
Extra pending query (derived)
📊
filterData: "pending"
replies.none { adminId: not null } — admin reply নেই এমন
✅
filterData:
"replied"
replies.some { adminId: not null } — admin reply আছে এমন
📅
today filter
createdAt: { gte: startOfToday, lt: endOfToday } — midnight to
midnight
🔐
Admin Scope Filter
Admin শুধু তার assigned course এর comment দেখতে পারে
06
// Multi-Tenant Architecture
Host-Based + Role-Based Filtering
একটাই backend, তিনটা platform। কোন admin কোন comment দেখবে —
hostname + adminId দিয়ে decide।
// Admin scope: শুধু assigned courses
if (user?.adminId) {
const accessibleCourses = await
prisma.courseAdmin.findMany({
where: { adminId: user.adminId,
isDeleted: false },
select: { courseId: true },
});
accessibleCourseIds = accessibleCourses
.map((c) => c.courseId);
}
// Host-based platform separation
if (hostName === config.frb_host_name) {
// FRB: Academic + FRB product
classContentRelation = {
courseSubjectChapter: { courseSubject: {
course: { AND: [
{ Category: { contains: 'Academic' } },
{ productName: { contains: 'FRB' } },
{ id: { in: accessibleCourseIds } },
]}
}}
};
} else if (hostName === config.academic_host_name) {
// Academic: cycleContent path
cycleContentRelation = { ... };
} else {
// Admission: non-academic category
classContentRelation = { ... };
}
Platform →
Data Path
admission.domain.com
classContent
(Admission)
frb.domain.com
classContent
(Academic+FRB)
academic.domain.com
cycleContent
(Academic)
Security by Design
Admin A শুধু course X, Y এর comment দেখে। Admin B শুধু course Z।
Cross-platform leakage impossible — DB query level এ filter।
OR Content Condition
contentConditions.length === 0
হলে empty return — unknown platform এ কোনো data expose হয় না।
07
// Soft Delete + Notification
Soft Delete & Push Notification
Delete মানে data মুছে ফেলা না — isDeleted: true
করা। Reply হলে instant notification।
// ✅ Soft Delete — data থাকে, hide হয়
const deleteCommentFromDb = async (id) => {
await prisma.classComment.update({
where: { id },
data: { isDeleted: true }, // not DELETE!
});
return {};
};
// Notification flow on reply:
const replyToCommentIntoDb = async (payload) => {
const result = await prisma.classComment
.create({ data: replayData });
// Non-blocking notification (try/catch)
try {
await replayCommandSentToNotification(
replayData
);
} catch (error) {
console.log("Notif error:", error.message);
// Reply succeeds even if notif fails
}
return response;
};
Non-blocking Design
Notification fail হলেও reply সফল হয়। try/catch দিয়ে isolate করা —
critical path আলাদা।
Push
Notification Flow
1. Content Info Fetch
classContentId → course → subject → chapter chain
2. Domain URL Detect
academic/admission/frb — deep link URL auto-generate
3. Sender Role Check
teacher/co-teacher/cx → আলাদা notification title বাংলায়
4. NotificationLog
Create
DB তে log + bumpNotificationUserVersion() → Firebase push
🔔
শিক্ষক Karim তোমার কমেন্টের উত্তর দিয়েছেন
শিক্ষক Karim: 'এই সূত্রটা মনে রাখো...'
Deep link → course → class content → comment
08
// Reusable Helper
nestedSelectFields() — Hidden Gem
Prisma এর nested select object manually লেখা nightmare। এই
helper দিয়ে string array → Prisma select object।
// nestedSelectFields.js
export const nestedSelectFields = (fields = [])
=> {
const select = {};
fields.forEach((fieldPath) => {
const keys = fieldPath.split('.');
let current = select;
while (keys.length > 0) {
const key = keys.shift();
if (!current[key]) {
current[key] = keys.length > 0
? { select: {} }
: true;
}
if (keys.length > 0) {
current = current[key].select;
}
}
});
return select;
};
Before vs
After
❌ Without
helper (manual)
// 20 lines of nested objects
select: {
id: true,
student: {
select: {
name: true,
profilePhoto: true,
}
},
replies: {
select: {
admin: {
select: { name: true }
}
}
}
}
✅ With helper
(clean)
nestedSelectFields([
"id",
"student.name",
"student.profilePhoto",
"replies.admin.name",
"replies.student.name",
])
// → same nested Prisma object
// Auto-generated! Error-free.
কেন এটা important?
Comment এর select field 20+ nested path। manually লিখলে typo, missing
field, refactor nightmare। এই helper দিয়ে array এ path লিখো — Prisma object automatic।
09
// Validation + Restriction
Comment করার আগে 3 Layer Check
প্রতিটা comment create/reply request এ — auth → restriction
→ validation — তিনটা gate।
Route
Definition
router.post('/',
// Gate 1: JWT verify + role check
authorizationMiddleware.authorize([
Enums.roles.ADMIN,
Enums.roles.STUDENT,
Enums.roles.SUPERADMIN,
]),
// Gate 2: Zod schema validation
validationRequest(
CommentValidationSchema
.createCommentValidationSchema
),
// Gate 3: Student restriction check
checkRestriction(
RestrictionType.MEDIA_COMMENT
),
CommentController.createComment
);
// Zod: one of classContentId OR cycleContentId
body.refine(
(data) => data.classContentId ||
data.cycleContentId,
{ message: 'Either content id required' }
);
3 Gate
Flow
01
JWT Auth + Role
Token verify → student/admin/superAdmin only। Anonymous কেউ
comment করতে পারে না।
authorize()
02
Zod Schema Validate
comment string required, contentId must be UUID, classContentId OR
cycleContentId required।
validationRequest()
03
Student Restriction
Student কি comment করার permission আছে? Admin ban করে রেখেছে?
MEDIA_COMMENT restriction check।
checkRestriction()
Zod Refinement — Smart Validation
.refine() দিয়ে
cross-field validation — classContentId বা cycleContentId এর যেকোনো একটা থাকতেই হবে। Prisma
পর্যন্ত invalid data পৌঁছায় না।
10
// Summary
এই Comment System কেন Best?
প্রতিটা design decision এর পিছনে কারণ আছে।
🌲
Self-Referential Model
একটাই table, unlimited depth nested reply। parent আলাদা table নেই — JOIN
complexity নেই।
🗑️
Soft Delete
isDeleted: true — data হারায় না, audit trail থাকে, child reply orphan হয়
না।
⚡
$transaction([5 queries])
Admin dashboard এ 5টা count/data query একটাই DB round-trip এ। performance
optimal।
🔐
Multi-Tenant Security
hostname + adminId দিয়ে DB query level এ filter। cross-platform data leak
impossible।
🔔
Smart Notification
Reply হলে parent commenter পায় push। Teacher/CX আলাদা বাংলা message। Deep
link দিয়ে সরাসরি comment এ যায়।
🧩
nestedSelectFields Helper
20+ nested path string array → Prisma select object। Over-fetch নেই, typo
নেই।
মূল কথা
Comment system ছোট মনে হয়, কিন্তু এর ভেতরে আছে —
multi-tenancy, nested data, notification pipeline, security layers, admin analytics। এই patterns
জানলে যেকোনো complex feature handle করতে পারবে।