feat(Moderation): enhance moderation reporting and user feedback
All checks were successful
Deploy to production / deploy (push) Successful in 1m55s

- Added user blocking checks in authentication and reporting processes, returning appropriate error responses.
- Expanded moderation report functionality to include new target types and optional fields for reports.
- Implemented a new API endpoint to retrieve the count of open moderation reports.
- Enhanced frontend components to allow users to report profiles, images, and guestbook entries, with corresponding UI updates.
- Updated internationalization files to include new strings for reporting features in both German and English.
This commit is contained in:
Torsten Schulz (local)
2026-04-27 15:57:02 +02:00
parent e94ae4350d
commit 530855e26e
16 changed files with 417 additions and 20 deletions

View File

@@ -29,6 +29,8 @@ class AuthController {
} catch (error) {
if (error.message === 'credentialsinvalid') {
res.status(404).json({ error: error.message });
} else if (error.message === 'userblocked') {
res.status(403).json({ error: error.message });
} else {
res.status(500).json({ error: error.message });
}

View File

@@ -3,12 +3,23 @@ import moderationService from '../services/moderationService.js';
const moderationController = {
async createReport(req, res) {
const allowedTargetTypes = [
'forum_message',
'gallery_image',
'guestbook_entry',
'one_to_one_message',
'diary_entry',
'user_profile',
'blog',
'blog_post'
];
const schema = Joi.object({
targetType: Joi.string().valid('forum_message').required(),
targetId: Joi.number().integer().min(1).required(),
targetType: Joi.string().valid(...allowedTargetTypes).required(),
targetId: Joi.number().integer().min(1).optional(),
targetRef: Joi.string().trim().max(255).allow('').optional(),
reason: Joi.string().trim().min(3).max(120).required(),
details: Joi.string().allow('').max(2000).optional()
});
}).or('targetId', 'targetRef');
const { error, value } = schema.validate(req.body || {});
if (error) {
return res.status(400).json({ error: error.details[0].message });
@@ -51,6 +62,17 @@ const moderationController = {
console.error('Error in updateReportStatus:', err);
return res.status(400).json({ error: err.message });
}
},
async getOpenReportCount(req, res) {
try {
const { userid: userId } = req.headers;
const result = await moderationService.getOpenReportCount(userId);
return res.status(200).json(result);
} catch (err) {
console.error('Error in getOpenReportCount:', err);
return res.status(400).json({ error: err.message });
}
}
};

View File

@@ -10,6 +10,9 @@ export const authenticate = async (req, res, next) => {
if (!user) {
return res.status(401).json({ error: 'Unauthorized: Invalid credentials' });
}
if (!user.active) {
return res.status(403).json({ error: 'Unauthorized: User blocked' });
}
try {
await updateUserTimestamp(user.id);
} catch (error) {

View File

@@ -70,6 +70,7 @@ router.get('/falukant/region-distances', authenticate, adminController.getRegion
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.get('/moderation/reports/open-count', authenticate, moderationController.getOpenReportCount);
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);

View File

@@ -1663,9 +1663,15 @@ class AdminService {
}
if (typeof data.active === 'boolean') {
updates.active = data.active;
if (data.active === false) {
updates.authCode = null;
}
}
if (Object.keys(updates).length === 0) return { id: user.hashedId, username: user.username, active: user.active };
await user.update(updates);
if (typeof data.active === 'boolean') {
await notifyUser(user.hashedId, 'userAccessChanged', { active: !!data.active });
}
return { id: user.hashedId, username: user.username, active: user.active };
}

View File

@@ -105,6 +105,9 @@ export const loginUser = async ({ username, password }) => {
if (!user) {
throw new Error('credentialsinvalid');
}
if (!user.active) {
throw new Error('userblocked');
}
const match = await bcrypt.compare(password, user.password);
if (!match) {
throw new Error('credentialsinvalid');

View File

@@ -1,7 +1,17 @@
import BaseService from './BaseService.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
const ALLOWED_TARGET_TYPES = new Set(['forum_message']);
const ALLOWED_TARGET_TYPES = new Set([
'forum_message',
'gallery_image',
'guestbook_entry',
'one_to_one_message',
'diary_entry',
'user_profile',
'blog',
'blog_post'
]);
const ALLOWED_STATUS = new Set(['open', 'in_review', 'resolved', 'rejected']);
class ModerationService extends BaseService {
@@ -16,7 +26,8 @@ class ModerationService extends BaseService {
CREATE TABLE IF NOT EXISTS community.moderation_report (
id bigserial PRIMARY KEY,
target_type varchar(64) NOT NULL,
target_id bigint NOT NULL,
target_id bigint NULL,
target_ref varchar(255) NULL,
reason varchar(120) NOT NULL,
details text NULL,
status varchar(32) NOT NULL DEFAULT 'open',
@@ -31,6 +42,14 @@ class ModerationService extends BaseService {
CREATE INDEX IF NOT EXISTS moderation_report_target_idx
ON community.moderation_report (target_type, target_id);
`);
await sequelize.query(`
ALTER TABLE community.moderation_report
ADD COLUMN IF NOT EXISTS target_ref varchar(255) NULL;
`);
await sequelize.query(`
ALTER TABLE community.moderation_report
ALTER COLUMN target_id DROP NOT NULL;
`);
await sequelize.query(`
CREATE INDEX IF NOT EXISTS moderation_report_status_idx
ON community.moderation_report (status, created_at DESC);
@@ -38,7 +57,7 @@ class ModerationService extends BaseService {
this._tableEnsured = true;
}
async createReport(hashedUserId, { targetType, targetId, reason, details }) {
async createReport(hashedUserId, { targetType, targetId, targetRef, reason, details }) {
await this._ensureTable();
const user = await this.getUserByHashedId(hashedUserId);
const normalizedTargetType = String(targetType || '').trim();
@@ -46,8 +65,10 @@ class ModerationService extends BaseService {
throw new Error('Unsupported target type');
}
const numericTargetId = Number(targetId);
if (!Number.isFinite(numericTargetId) || numericTargetId < 1) {
throw new Error('Invalid target id');
const hasTargetId = Number.isFinite(numericTargetId) && numericTargetId >= 1;
const normalizedTargetRef = String(targetRef || '').trim().slice(0, 255);
if (!hasTargetId && !normalizedTargetRef) {
throw new Error('Invalid target');
}
const normalizedReason = String(reason || '').trim().slice(0, 120);
if (!normalizedReason) {
@@ -58,15 +79,16 @@ class ModerationService extends BaseService {
const rows = await sequelize.query(
`
INSERT INTO community.moderation_report
(target_type, target_id, reason, details, status, reporter_user_id)
(target_type, target_id, target_ref, 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"
(:targetType, :targetId, :targetRef, :reason, :details, 'open', :reporterUserId)
RETURNING id, target_type AS "targetType", target_id AS "targetId", target_ref AS "targetRef", reason, details, status, created_at AS "createdAt"
`,
{
replacements: {
targetType: normalizedTargetType,
targetId: numericTargetId,
targetId: hasTargetId ? numericTargetId : null,
targetRef: normalizedTargetRef || null,
reason: normalizedReason,
details: normalizedDetails || null,
reporterUserId: user.id
@@ -75,7 +97,9 @@ class ModerationService extends BaseService {
}
);
return rows[0];
const created = rows[0];
await this._notifyModeratorsAboutChange('created', created.id);
return created;
}
async listReports(hashedUserId, { status = 'open', limit = 50 } = {}) {
@@ -95,6 +119,7 @@ class ModerationService extends BaseService {
r.id,
r.target_type AS "targetType",
r.target_id AS "targetId",
r.target_ref AS "targetRef",
fm.title_id AS "topicId",
ft.forum_id AS "forumId",
r.reason,
@@ -167,7 +192,66 @@ class ModerationService extends BaseService {
if (!rows.length) {
throw new Error('Report not found');
}
return rows[0];
const updated = rows[0];
await this._notifyModeratorsAboutChange('status_changed', numericReportId);
return updated;
}
async getOpenReportCount(hashedUserId) {
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 rows = await sequelize.query(
`
SELECT COUNT(*)::int AS count
FROM community.moderation_report
WHERE status = 'open'
`,
{ type: sequelize.QueryTypes.SELECT }
);
return { openCount: Number(rows?.[0]?.count || 0) };
}
async _notifyModeratorsAboutChange(kind, reportId) {
try {
const moderatorRows = await sequelize.query(
`
SELECT DISTINCT u.hashed_id AS "hashedId"
FROM community.user u
JOIN community.user_right ur ON ur.user_id = u.id
JOIN type.user_right urt ON urt.id = ur.right_type_id
WHERE u.active = true
AND urt.title IN ('mainadmin', 'forum')
`,
{ type: sequelize.QueryTypes.SELECT }
);
const countRows = await sequelize.query(
`
SELECT COUNT(*)::int AS count
FROM community.moderation_report
WHERE status = 'open'
`,
{ type: sequelize.QueryTypes.SELECT }
);
const openCount = Number(countRows?.[0]?.count || 0);
await Promise.all(
(moderatorRows || [])
.map((row) => row?.hashedId)
.filter(Boolean)
.map((hashedId) =>
notifyUser(hashedId, 'moderationReportChanged', {
kind,
reportId,
openCount
})
)
);
} catch (error) {
console.error('Failed to notify moderators about moderation report change:', error);
}
}
}