feat(Chat): implement chat incident reporting feature
All checks were successful
Deploy to production / deploy (push) Successful in 1m57s
All checks were successful
Deploy to production / deploy (push) Successful in 1m57s
- Added reportChatIncident method in ChatController to handle reporting of chat incidents. - Introduced a new API route for reporting incidents in chatRouter. - Implemented chatService methods to ensure the chat report table is created and to handle incident data storage. - Enhanced frontend components to allow users to report incidents in both multi and random chat dialogs. - Updated internationalization files to include new strings for reporting functionality in multiple languages.
This commit is contained in:
@@ -16,6 +16,7 @@ class ChatController {
|
||||
this.getRoomCreateOptions = this.getRoomCreateOptions.bind(this);
|
||||
this.getOwnRooms = this.getOwnRooms.bind(this);
|
||||
this.deleteOwnRoom = this.deleteOwnRoom.bind(this);
|
||||
this.reportChatIncident = this.reportChatIncident.bind(this);
|
||||
}
|
||||
|
||||
async getMessages(req, res) {
|
||||
@@ -215,6 +216,32 @@ class ChatController {
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async reportChatIncident(req, res) {
|
||||
const schema = Joi.object({
|
||||
context: Joi.string().valid('random_chat', 'multi_chat', 'one_to_one').required(),
|
||||
reporterHashedId: Joi.string().allow('', null),
|
||||
reporterRandomId: Joi.string().allow('', null),
|
||||
reporterUsername: Joi.string().allow('', null),
|
||||
offenderHashedId: Joi.string().allow('', null),
|
||||
offenderRandomId: Joi.string().allow('', null),
|
||||
offenderUsername: Joi.string().allow('', null),
|
||||
incidentAt: Joi.date().iso().required(),
|
||||
chatHistory: Joi.array().min(1).required(),
|
||||
metadata: Joi.object().unknown(true).optional()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
const result = await chatService.reportChatIncident(value);
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in reportChatIncident:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatController;
|
||||
|
||||
@@ -18,5 +18,6 @@ router.get('/rooms', authenticate, chatController.getRoomList);
|
||||
router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions);
|
||||
router.get('/my-rooms', authenticate, chatController.getOwnRooms);
|
||||
router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom);
|
||||
router.post('/report', chatController.reportChatIncident);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -784,9 +784,36 @@ class AdminService {
|
||||
|
||||
const types = await RegionType.findAll({
|
||||
attributes: ['id', 'labelTr', 'parentId'],
|
||||
order: [['labelTr', 'ASC']],
|
||||
});
|
||||
return types;
|
||||
|
||||
const byId = new Map(types.map((t) => [t.id, t]));
|
||||
const depthCache = new Map();
|
||||
const computeDepth = (typeId, visiting = new Set()) => {
|
||||
if (depthCache.has(typeId)) return depthCache.get(typeId);
|
||||
if (visiting.has(typeId)) {
|
||||
// Cycle guard: treat as root-ish to avoid infinite recursion
|
||||
depthCache.set(typeId, 0);
|
||||
return 0;
|
||||
}
|
||||
visiting.add(typeId);
|
||||
const t = byId.get(typeId);
|
||||
const parentId = t?.parentId ?? null;
|
||||
const depth = parentId ? (computeDepth(parentId, visiting) + 1) : 0;
|
||||
visiting.delete(typeId);
|
||||
depthCache.set(typeId, depth);
|
||||
return depth;
|
||||
};
|
||||
|
||||
// Sort by hierarchical level: roots (no parent) first, then children.
|
||||
// Tie-breaker: labelTr for stable ordering.
|
||||
const sorted = [...types].sort((a, b) => {
|
||||
const da = computeDepth(a.id);
|
||||
const db = computeDepth(b.id);
|
||||
if (da !== db) return da - db;
|
||||
return String(a.labelTr).localeCompare(String(b.labelTr));
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
async createFalukantRegion(userId, { name, regionTypeId, parentId } = {}) {
|
||||
|
||||
@@ -18,6 +18,108 @@ class ChatService {
|
||||
this.channel = null;
|
||||
this.amqpAvailable = false;
|
||||
this.initRabbitMq();
|
||||
this._chatReportTableEnsured = false;
|
||||
}
|
||||
|
||||
async _ensureChatReportTable() {
|
||||
if (this._chatReportTableEnsured) return;
|
||||
await Room.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.chat_report (
|
||||
id bigserial PRIMARY KEY,
|
||||
context varchar(40) NOT NULL,
|
||||
reporter_hashed_id varchar(255) NULL,
|
||||
reporter_random_id varchar(255) NULL,
|
||||
reporter_username varchar(255) NULL,
|
||||
offender_hashed_id varchar(255) NULL,
|
||||
offender_random_id varchar(255) NULL,
|
||||
offender_username varchar(255) NULL,
|
||||
incident_at timestamptz NOT NULL,
|
||||
chat_history jsonb NOT NULL,
|
||||
metadata jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
await Room.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS chat_report_created_idx
|
||||
ON community.chat_report (created_at DESC);
|
||||
`);
|
||||
this._chatReportTableEnsured = true;
|
||||
}
|
||||
|
||||
_normalizeChatHistory(history) {
|
||||
if (!Array.isArray(history)) return [];
|
||||
return history.slice(0, 1500).map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return { text: String(entry || '').slice(0, 3000) };
|
||||
}
|
||||
return {
|
||||
type: String(entry.type || '').slice(0, 40),
|
||||
user: String(entry.user || '').slice(0, 255),
|
||||
text: String(entry.text || '').slice(0, 3000),
|
||||
timestamp: entry.timestamp || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async reportChatIncident(payload) {
|
||||
await this._ensureChatReportTable();
|
||||
const context = String(payload.context || '').trim();
|
||||
if (!['random_chat', 'multi_chat', 'one_to_one'].includes(context)) {
|
||||
throw new Error('invalid_context');
|
||||
}
|
||||
const incidentAt = payload.incidentAt ? new Date(payload.incidentAt) : new Date();
|
||||
if (Number.isNaN(incidentAt.getTime())) {
|
||||
throw new Error('invalid_incident_at');
|
||||
}
|
||||
const chatHistory = this._normalizeChatHistory(payload.chatHistory);
|
||||
if (!chatHistory.length) {
|
||||
throw new Error('empty_chat_history');
|
||||
}
|
||||
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : null;
|
||||
const rows = await Room.sequelize.query(
|
||||
`
|
||||
INSERT INTO community.chat_report (
|
||||
context,
|
||||
reporter_hashed_id,
|
||||
reporter_random_id,
|
||||
reporter_username,
|
||||
offender_hashed_id,
|
||||
offender_random_id,
|
||||
offender_username,
|
||||
incident_at,
|
||||
chat_history,
|
||||
metadata
|
||||
) VALUES (
|
||||
:context,
|
||||
:reporterHashedId,
|
||||
:reporterRandomId,
|
||||
:reporterUsername,
|
||||
:offenderHashedId,
|
||||
:offenderRandomId,
|
||||
:offenderUsername,
|
||||
:incidentAt,
|
||||
CAST(:chatHistory AS jsonb),
|
||||
CAST(:metadata AS jsonb)
|
||||
)
|
||||
RETURNING id, context, incident_at AS "incidentAt", created_at AS "createdAt"
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
context,
|
||||
reporterHashedId: payload.reporterHashedId || null,
|
||||
reporterRandomId: payload.reporterRandomId || null,
|
||||
reporterUsername: payload.reporterUsername || null,
|
||||
offenderHashedId: payload.offenderHashedId || null,
|
||||
offenderRandomId: payload.offenderRandomId || null,
|
||||
offenderUsername: payload.offenderUsername || null,
|
||||
incidentAt: incidentAt.toISOString(),
|
||||
chatHistory: JSON.stringify(chatHistory),
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
},
|
||||
type: Room.sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
initRabbitMq() {
|
||||
|
||||
@@ -18,3 +18,8 @@ export const fetchOwnRooms = async () => {
|
||||
export const deleteOwnRoom = async (roomId) => {
|
||||
await apiClient.delete(`/api/chat/my-rooms/${roomId}`);
|
||||
};
|
||||
|
||||
export const reportChatIncident = async (payload) => {
|
||||
const response = await apiClient.post("/api/chat/report", payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -197,6 +197,9 @@
|
||||
<img @click="action" src="/images/icons/activity.png" class="icon-btn"
|
||||
:class="{ disabled: !selectedTargetUser }" :alt="$t('chat.multichat.action')"
|
||||
:title="selectedTargetUser ? $t('chat.multichat.action_to', { to: selectedTargetUser }) : $t('chat.multichat.action_select_user')" />
|
||||
<button type="button" class="send-btn report-btn" :disabled="!selectedTargetUser" @click="reportSelectedUser">
|
||||
{{ $t('chat.multichat.report') }}
|
||||
</button>
|
||||
<img @click="roll" src="/images/icons/dice24.png" class="icon-btn" alt="Würfeln" title="Würfeln" />
|
||||
<img @click="openColorPicker" src="/images/icons/colorpicker.png" class="icon-btn"
|
||||
:alt="$t('chat.multichat.colorpicker')" :title="$t('chat.multichat.colorpicker')" />
|
||||
@@ -236,7 +239,7 @@
|
||||
|
||||
<script>
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import { fetchPublicRooms, fetchRoomCreateOptions, fetchOwnRooms } from '@/api/chatApi.js';
|
||||
import { fetchPublicRooms, fetchRoomCreateOptions, fetchOwnRooms, reportChatIncident } from '@/api/chatApi.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js';
|
||||
import { confirmAction } from '@/utils/feedback.js';
|
||||
@@ -1305,6 +1308,41 @@ export default {
|
||||
this.sendWithToken(payload);
|
||||
this.input = '';
|
||||
},
|
||||
async reportSelectedUser() {
|
||||
if (!this.selectedTargetUser || !this.messages.length) return;
|
||||
const confirmed = await confirmAction(this, {
|
||||
title: this.$t('chat.multichat.reportConfirmTitle'),
|
||||
message: this.$t('chat.multichat.reportConfirmMessage', { user: this.selectedTargetUser })
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const payload = {
|
||||
context: 'multi_chat',
|
||||
reporterHashedId: this.user?.hashedId || null,
|
||||
reporterUsername: this.user?.username || null,
|
||||
offenderUsername: this.selectedTargetUser,
|
||||
incidentAt: new Date().toISOString(),
|
||||
chatHistory: this.messages.map((entry) => ({
|
||||
type: entry?.type || 'message',
|
||||
user: entry?.user || '',
|
||||
text: entry?.action
|
||||
? `${entry.action} ${entry.to || ''}`.trim()
|
||||
: (entry?.text || ''),
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
metadata: {
|
||||
roomId: this.selectedRoom || null,
|
||||
roomName: this.getSelectedRoomName() || null,
|
||||
usersInRoom: this.usersInRoom.map((u) => u.name),
|
||||
}
|
||||
};
|
||||
try {
|
||||
await reportChatIncident(payload);
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.reportSent') });
|
||||
} catch (_) {
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.reportError') });
|
||||
}
|
||||
},
|
||||
roll() {
|
||||
const payload = { type: 'dice', message: '' };
|
||||
if (this.debug) console.log('[Chat WS >>]', payload);
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
<input type="text" v-model="inputtext" @keyup.enter="sendMessage" />
|
||||
</label>
|
||||
<img src="/images/icons/enter16.png" @click="sendMessage" />
|
||||
<button
|
||||
v-if="partner"
|
||||
type="button"
|
||||
class="report-btn"
|
||||
@click="reportCurrentChat"
|
||||
>
|
||||
{{ $t('chat.randomchat.report') }}
|
||||
</button>
|
||||
<img src="/images/icons/dice16.png" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,6 +69,7 @@ import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import axios from 'axios';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { reportChatIncident } from '@/api/chatApi.js';
|
||||
|
||||
export default {
|
||||
name: 'RandomChatDialog',
|
||||
@@ -245,6 +254,37 @@ export default {
|
||||
this.messages.push({ type: 'system', tr: 'chat.randomchat.waitingForMatch' });
|
||||
}
|
||||
},
|
||||
async reportCurrentChat() {
|
||||
if (!this.partner || !this.messages.length) return;
|
||||
const confirmed = window.confirm(this.$t('chat.randomchat.reportConfirm'));
|
||||
if (!confirmed) return;
|
||||
const payload = {
|
||||
context: 'random_chat',
|
||||
reporterHashedId: this.user?.hashedId || null,
|
||||
reporterRandomId: this.userId || null,
|
||||
reporterUsername: this.user?.username || null,
|
||||
offenderRandomId: this.partner?.id || null,
|
||||
offenderUsername: this.$t('chat.randomchat.partner'),
|
||||
incidentAt: new Date().toISOString(),
|
||||
chatHistory: this.messages.map((entry) => ({
|
||||
type: entry?.type || 'message',
|
||||
user: entry?.type === 'self' ? (this.user?.username || 'self') : (entry?.user || 'partner'),
|
||||
text: entry?.tr ? this.$t(entry.tr) : (entry?.text || ''),
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
metadata: {
|
||||
mode: 'random_chat',
|
||||
partnerAge: this.partner?.age || null,
|
||||
partnerGender: this.partner?.gender || null
|
||||
}
|
||||
};
|
||||
try {
|
||||
await reportChatIncident(payload);
|
||||
this.messages.push({ type: 'system', text: this.$t('chat.randomchat.reportSent') });
|
||||
} catch (_) {
|
||||
this.messages.push({ type: 'system', text: this.$t('chat.randomchat.reportError') });
|
||||
}
|
||||
},
|
||||
|
||||
renderMessage(message) {
|
||||
if (message.type === 'system') {
|
||||
@@ -318,4 +358,8 @@ export default {
|
||||
.inputline>label>input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
"participantsWithCount": "Mga partisipante ({count})",
|
||||
"clickToSelectUser": "I-klik aron pilion",
|
||||
"screamColon": " misinggit:",
|
||||
"report": "I-report",
|
||||
"reportConfirmTitle": "I-report ang chat",
|
||||
"reportConfirmMessage": "Sigurado ka nga i-report nimo ang user nga {user}? Ang tibuok chat history i-save.",
|
||||
"reportSent": "Malampuson nga na-save ang report.",
|
||||
"reportError": "Napakyas ang pag-save sa report.",
|
||||
"commandPreviewFallback": "/cr <ngalan sa lawak>",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Ipakita ang chat",
|
||||
@@ -156,7 +161,11 @@
|
||||
"jumptonext": "Tapusa kining chat",
|
||||
"userleftchat": "Mibiya na ang ka-chat sa panag-istorya.",
|
||||
"startsearch": "Pangita sa sunod nga panag-istorya",
|
||||
"selfstopped": "Mibiya ka sa panag-istorya."
|
||||
"selfstopped": "Mibiya ka sa panag-istorya.",
|
||||
"report": "I-report",
|
||||
"reportConfirm": "Sigurado ka nga i-report nimo kining chat? Ang tibuok chat history i-save.",
|
||||
"reportSent": "Malampuson nga na-save ang report.",
|
||||
"reportError": "Napakyas ang pag-save sa report."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
"participantsWithCount": "Teilnehmer ({count})",
|
||||
"clickToSelectUser": "Zum Auswählen klicken",
|
||||
"screamColon": " schreit:",
|
||||
"report": "Melden",
|
||||
"reportConfirmTitle": "Chat melden",
|
||||
"reportConfirmMessage": "Möchtest du den Nutzer {user} wirklich melden? Der komplette Chatverlauf wird gespeichert.",
|
||||
"reportSent": "Meldung wurde erfolgreich gespeichert.",
|
||||
"reportError": "Meldung konnte nicht gespeichert werden.",
|
||||
"commandPreviewFallback": "/cr <raumname>",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Chat anzeigen",
|
||||
@@ -157,7 +162,11 @@
|
||||
"jumptonext": "Diesen Chat beenden",
|
||||
"userleftchat": "Der Gesprächstpartner hat den Chat verlassen.",
|
||||
"startsearch": "Suche nächstes Gespräch",
|
||||
"selfstopped": "Du hast das Gespräch verlassen."
|
||||
"selfstopped": "Du hast das Gespräch verlassen.",
|
||||
"report": "Melden",
|
||||
"reportConfirm": "Möchtest du diesen Chat wirklich melden? Der komplette Chatverlauf wird gespeichert.",
|
||||
"reportSent": "Meldung wurde erfolgreich gespeichert.",
|
||||
"reportError": "Meldung konnte nicht gespeichert werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
"participantsWithCount": "Participants ({count})",
|
||||
"clickToSelectUser": "Click to select",
|
||||
"screamColon": " shouts:",
|
||||
"report": "Report",
|
||||
"reportConfirmTitle": "Report chat",
|
||||
"reportConfirmMessage": "Do you really want to report user {user}? The full chat history will be stored.",
|
||||
"reportSent": "Report was saved successfully.",
|
||||
"reportError": "Report could not be saved.",
|
||||
"commandPreviewFallback": "/cr <roomname>",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Show chat",
|
||||
@@ -157,7 +162,11 @@
|
||||
"jumptonext": "End this chat",
|
||||
"userleftchat": "The chat partner has left the chat.",
|
||||
"startsearch": "Search next conversation",
|
||||
"selfstopped": "You left the conversation."
|
||||
"selfstopped": "You left the conversation.",
|
||||
"report": "Report",
|
||||
"reportConfirm": "Do you really want to report this chat? The full chat history will be stored.",
|
||||
"reportSent": "Report was saved successfully.",
|
||||
"reportError": "Report could not be saved."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
"participantsWithCount": "Participantes ({count})",
|
||||
"clickToSelectUser": "Clic para seleccionar",
|
||||
"screamColon": " grita:",
|
||||
"report": "Reportar",
|
||||
"reportConfirmTitle": "Reportar chat",
|
||||
"reportConfirmMessage": "¿Quieres reportar de verdad al usuario {user}? Se guardará todo el historial del chat.",
|
||||
"reportSent": "El reporte se guardó correctamente.",
|
||||
"reportError": "No se pudo guardar el reporte.",
|
||||
"commandPreviewFallback": "/cr <nombre de sala>",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Mostrar chat",
|
||||
@@ -156,7 +161,11 @@
|
||||
"jumptonext": "Finalizar este chat",
|
||||
"userleftchat": "La otra persona ha salido del chat.",
|
||||
"startsearch": "Buscar la siguiente charla",
|
||||
"selfstopped": "Has salido de la conversación."
|
||||
"selfstopped": "Has salido de la conversación.",
|
||||
"report": "Reportar",
|
||||
"reportConfirm": "¿Quieres reportar de verdad este chat? Se guardará todo el historial del chat.",
|
||||
"reportSent": "El reporte se guardó correctamente.",
|
||||
"reportError": "No se pudo guardar el reporte."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
"participantsWithCount": "Participants ({count})",
|
||||
"clickToSelectUser": "Cliquer pour sélectionner",
|
||||
"screamColon": " crie :",
|
||||
"report": "Signaler",
|
||||
"reportConfirmTitle": "Signaler le chat",
|
||||
"reportConfirmMessage": "Voulez-vous vraiment signaler l'utilisateur {user} ? L'historique complet du chat sera enregistré.",
|
||||
"reportSent": "Le signalement a été enregistré avec succès.",
|
||||
"reportError": "Le signalement n'a pas pu être enregistré.",
|
||||
"commandPreviewFallback": "/cr <nom de la salle>",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Chat anzeigen",
|
||||
@@ -156,7 +161,11 @@
|
||||
"jumptonext": "Diesen Chat beenden",
|
||||
"userleftchat": "Der Gesprächstpartner hat den Chat verlassen.",
|
||||
"startsearch": "Suche nächstes Gespräch",
|
||||
"selfstopped": "Du hast das Gespräch verlassen."
|
||||
"selfstopped": "Du hast das Gespräch verlassen.",
|
||||
"report": "Signaler",
|
||||
"reportConfirm": "Voulez-vous vraiment signaler ce chat ? L'historique complet du chat sera enregistré.",
|
||||
"reportSent": "Le signalement a été enregistré avec succès.",
|
||||
"reportError": "Le signalement n'a pas pu être enregistré."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user