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 @@
+
@@ -236,7 +239,7 @@