feat(Moderation): implement moderation reports feature
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:
Torsten Schulz (local)
2026-04-27 14:52:19 +02:00
parent 7fc9b55b59
commit a02fe1f008
36 changed files with 1162 additions and 17 deletions

View File

@@ -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; // 5585
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');

View 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();