feat(Moderation): implement moderation reports feature
All checks were successful
Deploy to production / deploy (push) Successful in 2m1s
All checks were successful
Deploy to production / deploy (push) Successful in 2m1s
- Added moderationRouter to handle moderation-related API routes. - Introduced new methods in AdminController for fetching all regions, region types, and creating regions. - Enhanced adminRouter with routes for moderation reports and status updates. - Updated navigationController to include moderation reports in the admin menu. - Implemented frontend components for reporting messages in the forum and managing moderation reports. - Added internationalization support for moderation-related texts in multiple languages.
This commit is contained in:
@@ -23,6 +23,7 @@ import vocabRouter from './routers/vocabRouter.js';
|
||||
import dashboardRouter from './routers/dashboardRouter.js';
|
||||
import newsRouter from './routers/newsRouter.js';
|
||||
import calendarRouter from './routers/calendarRouter.js';
|
||||
import moderationRouter from './routers/moderationRouter.js';
|
||||
import cors from 'cors';
|
||||
import './jobs/sessionCleanup.js';
|
||||
|
||||
@@ -105,6 +106,7 @@ app.use('/api/contact', contactRouter);
|
||||
app.use('/api/socialnetwork', socialnetworkRouter);
|
||||
app.use('/api/vocab', vocabRouter);
|
||||
app.use('/api/forum', forumRouter);
|
||||
app.use('/api/moderation', moderationRouter);
|
||||
app.use('/api/falukant', falukantRouter);
|
||||
app.use('/api/friendships', friendshipRouter);
|
||||
app.use('/api/models', modelsProxyRouter);
|
||||
|
||||
@@ -54,6 +54,9 @@ class AdminController {
|
||||
// Statistics
|
||||
this.getUserStatistics = this.getUserStatistics.bind(this);
|
||||
this.getFalukantRegions = this.getFalukantRegions.bind(this);
|
||||
this.getFalukantAllRegions = this.getFalukantAllRegions.bind(this);
|
||||
this.getFalukantRegionTypes = this.getFalukantRegionTypes.bind(this);
|
||||
this.createFalukantRegion = this.createFalukantRegion.bind(this);
|
||||
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
|
||||
this.getRegionDistances = this.getRegionDistances.bind(this);
|
||||
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
|
||||
@@ -583,6 +586,42 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async getFalukantAllRegions(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const regions = await AdminService.getFalukantAllRegions(userId);
|
||||
res.status(200).json(regions);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getFalukantRegionTypes(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const types = await AdminService.getFalukantRegionTypes(userId);
|
||||
res.status(200).json(types);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createFalukantRegion(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const created = await AdminService.createFalukantRegion(userId, req.body || {});
|
||||
res.status(200).json(created);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 400;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async updateFalukantRegionMap(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
|
||||
57
backend/controllers/moderationController.js
Normal file
57
backend/controllers/moderationController.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import Joi from 'joi';
|
||||
import moderationService from '../services/moderationService.js';
|
||||
|
||||
const moderationController = {
|
||||
async createReport(req, res) {
|
||||
const schema = Joi.object({
|
||||
targetType: Joi.string().valid('forum_message').required(),
|
||||
targetId: Joi.number().integer().min(1).required(),
|
||||
reason: Joi.string().trim().min(3).max(120).required(),
|
||||
details: Joi.string().allow('').max(2000).optional()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const result = await moderationService.createReport(userId, value);
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in createReport:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async listReports(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const result = await moderationService.listReports(userId, req.query || {});
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in listReports:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async updateReportStatus(req, res) {
|
||||
const schema = Joi.object({
|
||||
status: Joi.string().valid('open', 'in_review', 'resolved', 'rejected').required(),
|
||||
reviewerNote: Joi.string().allow('').max(2000).optional()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const result = await moderationService.updateReportStatus(userId, req.params.reportId, value);
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in updateReportStatus:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default moderationController;
|
||||
@@ -278,6 +278,10 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "forum"],
|
||||
path: "/admin/forum"
|
||||
},
|
||||
moderationReports: {
|
||||
visible: ["mainadmin", "forum"],
|
||||
path: "/admin/moderation/reports"
|
||||
},
|
||||
chatrooms: {
|
||||
visible: ["mainadmin", "chatrooms"],
|
||||
path: "/admin/chatrooms"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import AdminController from '../controllers/adminController.js';
|
||||
import moderationController from '../controllers/moderationController.js';
|
||||
|
||||
const router = Router();
|
||||
const adminController = new AdminController();
|
||||
@@ -60,11 +61,16 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
|
||||
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
||||
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
||||
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
||||
router.get('/falukant/region-types', authenticate, adminController.getFalukantRegionTypes);
|
||||
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
|
||||
router.get('/falukant/regions/all', authenticate, adminController.getFalukantAllRegions);
|
||||
router.post('/falukant/regions', authenticate, adminController.createFalukantRegion);
|
||||
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
|
||||
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
|
||||
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
|
||||
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
|
||||
router.get('/moderation/reports', authenticate, moderationController.listReports);
|
||||
router.post('/moderation/reports/:reportId/status', authenticate, moderationController.updateReportStatus);
|
||||
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
|
||||
router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus);
|
||||
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
|
||||
|
||||
10
backend/routers/moderationRouter.js
Normal file
10
backend/routers/moderationRouter.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import moderationController from '../controllers/moderationController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.post('/reports', moderationController.createReport);
|
||||
|
||||
export default router;
|
||||
@@ -22,6 +22,10 @@ import RegionData from "../models/falukant/data/region.js";
|
||||
import RegionType from "../models/falukant/type/region.js";
|
||||
import BranchType from "../models/falukant/type/branch.js";
|
||||
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||
import ProductType from "../models/falukant/type/product.js";
|
||||
import TownProductWorth from "../models/falukant/data/town_product_worth.js";
|
||||
import Weather from "../models/falukant/data/weather.js";
|
||||
import WeatherType from "../models/falukant/type/weather.js";
|
||||
import Room from '../models/chat/room.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
import Image from '../models/community/image.js';
|
||||
@@ -753,6 +757,129 @@ class AdminService {
|
||||
return regions;
|
||||
}
|
||||
|
||||
async getFalukantAllRegions(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const regions = await RegionData.findAll({
|
||||
attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'],
|
||||
include: [
|
||||
{
|
||||
model: RegionType,
|
||||
as: 'regionType',
|
||||
attributes: ['id', 'labelTr', 'parentId'],
|
||||
},
|
||||
],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
async getFalukantRegionTypes(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const types = await RegionType.findAll({
|
||||
attributes: ['id', 'labelTr', 'parentId'],
|
||||
order: [['labelTr', 'ASC']],
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
async createFalukantRegion(userId, { name, regionTypeId, parentId } = {}) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const regionName = String(name || '').trim();
|
||||
const typeId = Number(regionTypeId);
|
||||
const parentRegionId = parentId == null || parentId === '' ? null : Number(parentId);
|
||||
|
||||
if (!regionName) {
|
||||
throw new Error('missingName');
|
||||
}
|
||||
if (!Number.isInteger(typeId) || typeId <= 0) {
|
||||
throw new Error('missingRegionType');
|
||||
}
|
||||
if (parentRegionId != null && (!Number.isInteger(parentRegionId) || parentRegionId <= 0)) {
|
||||
throw new Error('invalidParent');
|
||||
}
|
||||
|
||||
const regionType = await RegionType.findByPk(typeId, { attributes: ['id', 'labelTr', 'parentId'] });
|
||||
if (!regionType) {
|
||||
throw new Error('regionTypeNotFound');
|
||||
}
|
||||
|
||||
let parentRegion = null;
|
||||
if (parentRegionId != null) {
|
||||
parentRegion = await RegionData.findByPk(parentRegionId, { attributes: ['id', 'regionTypeId'] });
|
||||
if (!parentRegion) {
|
||||
throw new Error('parentRegionNotFound');
|
||||
}
|
||||
}
|
||||
|
||||
if (regionType.parentId != null) {
|
||||
if (parentRegionId == null) {
|
||||
throw new Error('missingParent');
|
||||
}
|
||||
if (!parentRegion || parentRegion.regionTypeId !== regionType.parentId) {
|
||||
throw new Error('invalidParentRegionType');
|
||||
}
|
||||
} else if (parentRegionId != null) {
|
||||
throw new Error('parentNotAllowedForType');
|
||||
}
|
||||
|
||||
const randomWorthPercent = () => Math.floor(Math.random() * 31) + 55; // 55–85
|
||||
|
||||
const createdId = await sequelize.transaction(async (t) => {
|
||||
const region = await RegionData.create(
|
||||
{
|
||||
name: regionName,
|
||||
regionTypeId: regionType.id,
|
||||
parentId: parentRegionId,
|
||||
map: {},
|
||||
},
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
const products = await ProductType.findAll({ attributes: ['id'], transaction: t });
|
||||
if (products.length > 0) {
|
||||
await TownProductWorth.bulkCreate(
|
||||
products.map((p) => ({
|
||||
productId: p.id,
|
||||
regionId: region.id,
|
||||
worthPercent: randomWorthPercent(),
|
||||
})),
|
||||
{ transaction: t, ignoreDuplicates: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (regionType.labelTr === 'city') {
|
||||
const weatherTypes = await WeatherType.findAll({ attributes: ['id'], transaction: t });
|
||||
if (weatherTypes.length > 0) {
|
||||
const randomWeatherType = weatherTypes[Math.floor(Math.random() * weatherTypes.length)];
|
||||
await Weather.findOrCreate({
|
||||
where: { regionId: region.id },
|
||||
defaults: { weatherTypeId: randomWeatherType.id },
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return region.id;
|
||||
});
|
||||
|
||||
const created = await RegionData.findByPk(createdId, {
|
||||
attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'],
|
||||
include: [{ model: RegionType, as: 'regionType', attributes: ['id', 'labelTr', 'parentId'] }],
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async getTitlesOfNobility(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
|
||||
168
backend/services/moderationService.js
Normal file
168
backend/services/moderationService.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import BaseService from './BaseService.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
|
||||
const ALLOWED_TARGET_TYPES = new Set(['forum_message']);
|
||||
const ALLOWED_STATUS = new Set(['open', 'in_review', 'resolved', 'rejected']);
|
||||
|
||||
class ModerationService extends BaseService {
|
||||
constructor() {
|
||||
super();
|
||||
this._tableEnsured = false;
|
||||
}
|
||||
|
||||
async _ensureTable() {
|
||||
if (this._tableEnsured) return;
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.moderation_report (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_type varchar(64) NOT NULL,
|
||||
target_id bigint NOT NULL,
|
||||
reason varchar(120) NOT NULL,
|
||||
details text NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'open',
|
||||
reporter_user_id bigint NOT NULL REFERENCES community.user(id) ON DELETE CASCADE,
|
||||
reviewer_user_id bigint NULL REFERENCES community.user(id) ON DELETE SET NULL,
|
||||
reviewer_note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS moderation_report_target_idx
|
||||
ON community.moderation_report (target_type, target_id);
|
||||
`);
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS moderation_report_status_idx
|
||||
ON community.moderation_report (status, created_at DESC);
|
||||
`);
|
||||
this._tableEnsured = true;
|
||||
}
|
||||
|
||||
async createReport(hashedUserId, { targetType, targetId, reason, details }) {
|
||||
await this._ensureTable();
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const normalizedTargetType = String(targetType || '').trim();
|
||||
if (!ALLOWED_TARGET_TYPES.has(normalizedTargetType)) {
|
||||
throw new Error('Unsupported target type');
|
||||
}
|
||||
const numericTargetId = Number(targetId);
|
||||
if (!Number.isFinite(numericTargetId) || numericTargetId < 1) {
|
||||
throw new Error('Invalid target id');
|
||||
}
|
||||
const normalizedReason = String(reason || '').trim().slice(0, 120);
|
||||
if (!normalizedReason) {
|
||||
throw new Error('Missing reason');
|
||||
}
|
||||
const normalizedDetails = String(details || '').trim().slice(0, 2000);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.moderation_report
|
||||
(target_type, target_id, reason, details, status, reporter_user_id)
|
||||
VALUES
|
||||
(:targetType, :targetId, :reason, :details, 'open', :reporterUserId)
|
||||
RETURNING id, target_type AS "targetType", target_id AS "targetId", reason, details, status, created_at AS "createdAt"
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
targetType: normalizedTargetType,
|
||||
targetId: numericTargetId,
|
||||
reason: normalizedReason,
|
||||
details: normalizedDetails || null,
|
||||
reporterUserId: user.id
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async listReports(hashedUserId, { status = 'open', limit = 50 } = {}) {
|
||||
await this._ensureTable();
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']);
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
const normalizedStatus = String(status || 'open').trim();
|
||||
const effectiveStatus = ALLOWED_STATUS.has(normalizedStatus) ? normalizedStatus : 'open';
|
||||
const effectiveLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.target_type AS "targetType",
|
||||
r.target_id AS "targetId",
|
||||
r.reason,
|
||||
r.details,
|
||||
r.status,
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
reporter.username AS "reporterUsername",
|
||||
reviewer.username AS "reviewerUsername",
|
||||
r.reviewer_note AS "reviewerNote"
|
||||
FROM community.moderation_report r
|
||||
JOIN community.user reporter ON reporter.id = r.reporter_user_id
|
||||
LEFT JOIN community.user reviewer ON reviewer.id = r.reviewer_user_id
|
||||
WHERE r.status = :status
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT :limit
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
status: effectiveStatus,
|
||||
limit: effectiveLimit
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async updateReportStatus(hashedUserId, reportId, { status, reviewerNote } = {}) {
|
||||
await this._ensureTable();
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']);
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
const numericReportId = Number(reportId);
|
||||
if (!Number.isFinite(numericReportId) || numericReportId < 1) {
|
||||
throw new Error('Invalid report id');
|
||||
}
|
||||
const normalizedStatus = String(status || '').trim();
|
||||
if (!ALLOWED_STATUS.has(normalizedStatus)) {
|
||||
throw new Error('Invalid status');
|
||||
}
|
||||
const note = String(reviewerNote || '').trim().slice(0, 2000);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
UPDATE community.moderation_report
|
||||
SET
|
||||
status = :status,
|
||||
reviewer_user_id = :reviewerUserId,
|
||||
reviewer_note = :reviewerNote,
|
||||
updated_at = NOW()
|
||||
WHERE id = :reportId
|
||||
RETURNING id, status, reviewer_note AS "reviewerNote", updated_at AS "updatedAt"
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
status: normalizedStatus,
|
||||
reviewerUserId: user.id,
|
||||
reviewerNote: note || null,
|
||||
reportId: numericReportId
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
if (!rows.length) {
|
||||
throw new Error('Report not found');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ModerationService();
|
||||
Reference in New Issue
Block a user