feat(Moderation): enhance moderation reporting and user feedback
All checks were successful
Deploy to production / deploy (push) Successful in 1m55s
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user