diff --git a/backend/controllers/chatController.js b/backend/controllers/chatController.js index db6dddd..2d4d03c 100644 --- a/backend/controllers/chatController.js +++ b/backend/controllers/chatController.js @@ -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; diff --git a/backend/routers/chatRouter.js b/backend/routers/chatRouter.js index 40acac9..5e5f774 100644 --- a/backend/routers/chatRouter.js +++ b/backend/routers/chatRouter.js @@ -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; diff --git a/backend/services/adminService.js b/backend/services/adminService.js index f37d2e4..ebdc1e7 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -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 } = {}) { diff --git a/backend/services/chatService.js b/backend/services/chatService.js index d129425..5b26144 100644 --- a/backend/services/chatService.js +++ b/backend/services/chatService.js @@ -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() { diff --git a/frontend/src/api/chatApi.js b/frontend/src/api/chatApi.js index 3882531..44d82a0 100644 --- a/frontend/src/api/chatApi.js +++ b/frontend/src/api/chatApi.js @@ -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; +}; diff --git a/frontend/src/dialogues/chat/MultiChatDialog.vue b/frontend/src/dialogues/chat/MultiChatDialog.vue index 53143cd..0197ef9 100644 --- a/frontend/src/dialogues/chat/MultiChatDialog.vue +++ b/frontend/src/dialogues/chat/MultiChatDialog.vue @@ -197,6 +197,9 @@ + Würfeln @@ -236,7 +239,7 @@