This commit is contained in:
payacom
2026-09-13 21:32:36 +03:30
parent 90c5f94b57
commit c302aea177
49 changed files with 4063 additions and 920 deletions

View File

@@ -0,0 +1,107 @@
const bcrypt = require('bcryptjs')
const AdminModel = require('../../../models/AdminModel')
const { PANEL_PERMISSIONS, PANEL_PERMISSION_KEYS } = require('../../../utils/panelPermissions')
function sanitizeAdmin(admin) {
return {
_id: admin._id,
user_name: admin.user_name,
first_name: admin.first_name,
last_name: admin.last_name,
role: admin.role,
permissions: admin.permissions || [],
createdAt: admin.createdAt,
last_online: admin.last_online
}
}
const listAdmins = async (req, res, next) => {
try {
const admins = await AdminModel.find({}).sort({ createdAt: -1 })
res.status(200).json({
admins: admins.map(sanitizeAdmin),
availablePermissions: PANEL_PERMISSIONS
})
} catch (error) {
next(error)
}
}
const createAdmin = async (req, res, next) => {
try {
const { user_name, password, first_name, last_name, permissions } = req.body
if (!user_name || !password) {
return res.status(422).json({ error: true, message: 'نام کاربری و رمز عبور الزامی است' })
}
const userLow = String(user_name).toLowerCase().trim()
const existing = await AdminModel.findOne({ user_name: userLow })
if (existing) {
return res.status(409).json({ error: true, message: 'این نام کاربری قبلاً استفاده شده است' })
}
const validPermissions = Array.isArray(permissions)
? permissions.filter((p) => PANEL_PERMISSION_KEYS.has(p))
: []
const hashedPassword = await bcrypt.hash(password, 10)
const admin = await AdminModel.create({
user_name: userLow,
password: hashedPassword,
first_name: first_name || null,
last_name: last_name || null,
role: 'staff',
permissions: validPermissions,
createdBy: req.admin._id
})
res.status(201).json({ admin: sanitizeAdmin(admin) })
} catch (error) {
next(error)
}
}
const updateAdminPermissions = async (req, res, next) => {
try {
const { id } = req.params
const { permissions } = req.body
const admin = await AdminModel.findById(id)
if (!admin) return res.status(404).json({ error: true, message: 'کاربر پیدا نشد' })
if (admin.role === 'super_admin') {
return res.status(400).json({ error: true, message: 'دسترسی سوپرادمین قابل تغییر نیست' })
}
const validPermissions = Array.isArray(permissions)
? permissions.filter((p) => PANEL_PERMISSION_KEYS.has(p))
: []
admin.permissions = validPermissions
await admin.save()
res.status(200).json({ admin: sanitizeAdmin(admin) })
} catch (error) {
next(error)
}
}
const deleteAdmin = async (req, res, next) => {
try {
const { id } = req.params
const admin = await AdminModel.findById(id)
if (!admin) return res.status(404).json({ error: true, message: 'کاربر پیدا نشد' })
if (admin.role === 'super_admin') {
return res.status(400).json({ error: true, message: 'حذف سوپرادمین مجاز نیست' })
}
if (String(admin._id) === String(req.admin._id)) {
return res.status(400).json({ error: true, message: 'نمی‌توانید خودتان را حذف کنید' })
}
await AdminModel.deleteOne({ _id: id })
res.status(200).json({ message: 'کاربر پنل حذف شد' })
} catch (error) {
next(error)
}
}
module.exports = { listAdmins, createAdmin, updateAdminPermissions, deleteAdmin }

View File

@@ -0,0 +1,247 @@
const UserModel = require('../../../models/UserModel')
const AnalyticsEvent = require('../../../models/AnalyticsEventModel')
function parseDays(req) {
const days = parseInt(req.query.days, 10)
if (!days || days <= 0) return 30
return Math.min(days, 365)
}
// آمار جغرافیایی/جنسیت مستقیم از خودِ پروفایل کاربرا میاد (چیزی که موقع ثبت‌نام/ویرایش
// پروفایل قبلاً ذخیره می‌شه) — نیازی به ردیابی جداگانه نداره
async function getDemographics(req, res, next) {
try {
const [countries, provinces, cities, genders] = await Promise.all([
UserModel.aggregate([
{ $match: { 'country.name': { $exists: true, $ne: null } } },
{ $group: { _id: '$country.name', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 20 }
]),
UserModel.aggregate([
{ $match: { 'province.name': { $exists: true, $ne: null } } },
{ $group: { _id: '$province.name', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 20 }
]),
UserModel.aggregate([
{ $match: { 'city.name': { $exists: true, $ne: null } } },
{ $group: { _id: '$city.name', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 20 }
]),
UserModel.aggregate([
{ $group: { _id: '$gender', count: { $sum: 1 } } }
])
])
const genderMap = { male: 0, female: 0, unspecified: 0 }
genders.forEach((g) => {
if (g._id === 'male') genderMap.male = g.count
else if (g._id === 'female') genderMap.female = g.count
else genderMap.unspecified += g.count
})
res.status(200).json({
countries: countries.map((c) => ({ name: c._id, count: c.count })),
provinces: provinces.map((p) => ({ name: p._id, count: p.count })),
cities: cities.map((c) => ({ name: c._id, count: c.count })),
gender: genderMap
})
} catch (error) {
next(error)
}
}
// دستگاه/مرورگر/سیستم‌عامل/پلتفرم — از رویدادهای خام AnalyticsEvent (فقط بازه‌ی
// اخیر، برای پرفورمنس)
async function getDeviceStats(req, res, next) {
try {
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
const match = { createdAt: { $gte: since } }
const [devices, browsers, os, platforms] = await Promise.all([
AnalyticsEvent.aggregate([
{ $match: { ...match, device: { $ne: null } } },
{ $group: { _id: '$device', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 15 }
]),
AnalyticsEvent.aggregate([
{ $match: { ...match, browser: { $ne: null } } },
{ $group: { _id: '$browser', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 15 }
]),
AnalyticsEvent.aggregate([
{ $match: { ...match, os: { $ne: null } } },
{ $group: { _id: '$os', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 15 }
]),
AnalyticsEvent.aggregate([
{ $match: { ...match, type: 'session_start' } },
{ $group: { _id: '$platform', count: { $sum: 1 } } }
])
])
res.status(200).json({
devices: devices.map((d) => ({ name: d._id, count: d.count })),
browsers: browsers.map((b) => ({ name: b._id, count: b.count })),
os: os.map((o) => ({ name: o._id, count: o.count })),
platforms: platforms.reduce(
(acc, p) => ({ ...acc, [p._id]: p.count }),
{ web: 0, android: 0, ios: 0 }
)
})
} catch (error) {
next(error)
}
}
// پربازدیدترین صفحات + میانگین زمان موندگاری روی هرکدوم
async function getPageStats(req, res, next) {
try {
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
const pages = await AnalyticsEvent.aggregate([
{ $match: { type: 'page_view', createdAt: { $gte: since }, path: { $ne: null } } },
{
$group: {
_id: '$path',
views: { $sum: 1 },
avgDurationMs: { $avg: '$durationMs' }
}
},
{ $sort: { views: -1 } },
{ $limit: 30 }
])
res.status(200).json({
pages: pages.map((p) => ({
path: p._id,
views: p.views,
avgDurationMs: Math.round(p.avgDurationMs || 0)
}))
})
} catch (error) {
next(error)
}
}
// پرکلیک‌ترین عناصر (label ارسال‌شده از کلاینت — data-analytics-id یا متن دکمه)
async function getClickStats(req, res, next) {
try {
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
const clicks = await AnalyticsEvent.aggregate([
{ $match: { type: 'click', createdAt: { $gte: since }, label: { $ne: null } } },
{ $group: { _id: { label: '$label', path: '$path' }, count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 30 }
])
res.status(200).json({
clicks: clicks.map((c) => ({
label: c._id.label,
path: c._id.path,
count: c.count
}))
})
} catch (error) {
next(error)
}
}
// نرخ بازگشت — بر اساس last_online کاربر نسبت به createdAt؛ کاربری که last_online‌ش
// خیلی نزدیک به createdAt‌شه (یا اصلاً last_online نداره) یعنی بعد از ثبت‌نام دیگه
// برنگشته
async function getRetentionStats(req, res, next) {
try {
const now = Date.now()
const day7 = new Date(now - 7 * 24 * 60 * 60 * 1000)
const day30 = new Date(now - 30 * 24 * 60 * 60 * 1000)
const [totalUsers, activeLast7Days, activeLast30Days, neverReturned] =
await Promise.all([
UserModel.countDocuments({}),
UserModel.countDocuments({ last_online: { $gte: day7 } }),
UserModel.countDocuments({ last_online: { $gte: day30 } }),
UserModel.countDocuments({
$or: [
{ last_online: null },
{ $expr: { $lte: [{ $subtract: ['$last_online', '$createdAt'] }, 60000] } }
]
})
])
res.status(200).json({
totalUsers,
activeLast7Days,
activeLast30Days,
neverReturned,
returningRate:
totalUsers > 0
? Math.round(((totalUsers - neverReturned) / totalUsers) * 100)
: 0
})
} catch (error) {
next(error)
}
}
// خلاصه‌ی کلی نشست‌ها — تعداد نشست، میانگین صفحاتِ هر نشست، میانگین زمانِ هر نشست
// (مجموع durationMsِ page_viewهای همون sessionId)
async function getSessionOverview(req, res, next) {
try {
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
const [sessionCountAgg, sessionDurationAgg] = await Promise.all([
AnalyticsEvent.aggregate([
{ $match: { type: 'session_start', createdAt: { $gte: since } } },
{ $group: { _id: null, count: { $sum: 1 } } }
]),
AnalyticsEvent.aggregate([
{
$match: {
type: 'page_view',
createdAt: { $gte: since },
durationMs: { $ne: null }
}
},
{
$group: {
_id: '$sessionId',
pages: { $sum: 1 },
totalDurationMs: { $sum: '$durationMs' }
}
},
{
$group: {
_id: null,
avgPagesPerSession: { $avg: '$pages' },
avgSessionDurationMs: { $avg: '$totalDurationMs' },
sessionsWithDuration: { $sum: 1 }
}
}
])
])
res.status(200).json({
totalSessions: sessionCountAgg[0]?.count || 0,
avgPagesPerSession: Math.round(sessionDurationAgg[0]?.avgPagesPerSession || 0),
avgSessionDurationMs: Math.round(sessionDurationAgg[0]?.avgSessionDurationMs || 0)
})
} catch (error) {
next(error)
}
}
module.exports = {
getDemographics,
getDeviceStats,
getPageStats,
getClickStats,
getRetentionStats,
getSessionOverview
}

View File

@@ -0,0 +1,39 @@
const AuditLog = require('../../../models/AuditLogModel')
const AdminModel = require('../../../models/AdminModel')
const listAuditLog = async (req, res, next) => {
try {
const page = Math.max(parseInt(req.query.page, 10) || 1, 1)
const limit = Math.min(parseInt(req.query.limit, 10) || 30, 100)
const filter = {}
if (req.query.adminId) filter.adminId = req.query.adminId
const [logs, total] = await Promise.all([
AuditLog.find(filter)
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean(),
AuditLog.countDocuments(filter)
])
res.status(200).json({
logs,
totalPages: Math.ceil(total / limit) || 1,
totalItems: total
})
} catch (error) {
next(error)
}
}
const listAdminsForFilter = async (req, res, next) => {
try {
const admins = await AdminModel.find({}).select('user_name first_name last_name').lean()
res.status(200).json({ admins })
} catch (error) {
next(error)
}
}
module.exports = { listAuditLog, listAdminsForFilter }

View File

@@ -42,7 +42,9 @@ const adminLogin = async (req, res, next) => {
return res.json({
message: 'کد تایید صحیح بود',
token,
id: user._id
id: user._id,
role: user.role || 'super_admin',
permissions: user.permissions || []
})
} catch (error) {
next(error)

View File

@@ -0,0 +1,73 @@
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const CommentModel = require('../../../models/CommentModel')
const AdvertisingCommentModel = require('../../../models/AdvertisingCommentModel')
const CourseCommentModel = require('../../../models/CourseComentModel')
const AdvertisingModel = require('../../../models/AdvertisingModel')
const ProjectModel = require('../../../models/ProjectModel')
const CourseModel = require('../../../models/CourseModel')
const AcademyContentModel = require('../../../models/AcademyContentModel')
const ShopModel = require('../../../models/ShopModel')
const ShopProductListingModel = require('../../../models/ShopProductListingModel')
const TicketModel = require('../../../models/TicketModel')
const StoryModel = require('../../../models/StoryModel')
// عین moderationStatsController ولی برخلاف اون (که فقط موارد در انتظار/تایید موقت رو
// می‌شمره)، اینجا مجموع کلِ هر مدل از ابتدای راه‌اندازی سایته — برای بخش «آمار کلی»
// بالای داشبورد
const getOverallStats = async (req, res, next) => {
try {
const token = req.header('Authorization')?.split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const [
users,
posts,
comments,
vitrineComments,
academyComments,
billboards,
projects,
courses,
academyVideos,
shops,
shopProducts,
tickets,
stories,
] = await Promise.all([
UserModel.countDocuments({}),
PostModel.countDocuments({}),
CommentModel.countDocuments({}),
AdvertisingCommentModel.countDocuments({}),
CourseCommentModel.countDocuments({}),
AdvertisingModel.countDocuments({}),
ProjectModel.countDocuments({}),
CourseModel.countDocuments({}),
AcademyContentModel.countDocuments({}),
ShopModel.countDocuments({}),
ShopProductListingModel.countDocuments({}),
TicketModel.countDocuments({}),
StoryModel.countDocuments({}),
])
res.status(200).json({
users,
posts,
comments,
vitrineComments,
academyComments,
billboards,
projects,
courses,
academyVideos,
shops,
shopProducts,
tickets,
stories,
})
} catch (error) {
next(error)
}
}
module.exports = { getOverallStats }