4.1.3
This commit is contained in:
107
controllers/panel/adminUsers/adminUsersController.js
Normal file
107
controllers/panel/adminUsers/adminUsersController.js
Normal 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 }
|
||||
247
controllers/panel/analytics/analyticsPanelController.js
Normal file
247
controllers/panel/analytics/analyticsPanelController.js
Normal 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
|
||||
}
|
||||
39
controllers/panel/auditLog/auditLogController.js
Normal file
39
controllers/panel/auditLog/auditLogController.js
Normal 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 }
|
||||
@@ -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)
|
||||
|
||||
73
controllers/panel/moderation/overallStatsController.js
Normal file
73
controllers/panel/moderation/overallStatsController.js
Normal 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 }
|
||||
Reference in New Issue
Block a user