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