Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -19,11 +19,6 @@ const {
|
|||||||
|
|
||||||
router.use(checkCrudPermissions('score_runs'));
|
router.use(checkCrudPermissions('score_runs'));
|
||||||
|
|
||||||
router.post('/run-audit', wrapAsync(async (req, res) => {
|
|
||||||
const payload = await Score_runsService.runAudit(req.body.data || {}, req.currentUser);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
|
|||||||
@ -1,407 +1,35 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const Score_runsDBApi = require('../db/api/score_runs');
|
const Score_runsDBApi = require('../db/api/score_runs');
|
||||||
const Call_recordingsDBApi = require('../db/api/call_recordings');
|
const processFile = require("../middlewares/upload");
|
||||||
const TranscriptsDBApi = require('../db/api/transcripts');
|
|
||||||
const Phrase_matchesDBApi = require('../db/api/phrase_matches');
|
|
||||||
const processFile = require('../middlewares/upload');
|
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
const { Op } = db.Sequelize;
|
|
||||||
|
|
||||||
const normalizeText = (value = '') =>
|
|
||||||
value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const escapeRegExp = (value = '') => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
|
|
||||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
||||||
|
|
||||||
const estimateDurationSeconds = (text = '') => {
|
|
||||||
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
|
|
||||||
return Math.max(Math.round(wordCount / 2.4), 15);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSnippet = (text = '', startIndex = 0, matchLength = 0) => {
|
|
||||||
if (!text) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippetStart = Math.max(0, startIndex - 50);
|
|
||||||
const snippetEnd = Math.min(text.length, startIndex + matchLength + 70);
|
|
||||||
const prefix = snippetStart > 0 ? '…' : '';
|
|
||||||
const suffix = snippetEnd < text.length ? '…' : '';
|
|
||||||
|
|
||||||
return `${prefix}${text.slice(snippetStart, snippetEnd).trim()}${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const collectMatches = (sourceText = '', phraseText = '', matchMode = 'contains') => {
|
|
||||||
if (!sourceText || !phraseText) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
let regex;
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (matchMode) {
|
|
||||||
case 'exact':
|
|
||||||
regex = new RegExp(`\\b${escapeRegExp(phraseText)}\\b`, 'gi');
|
|
||||||
break;
|
|
||||||
case 'regex':
|
|
||||||
regex = new RegExp(phraseText, 'gi');
|
|
||||||
break;
|
|
||||||
case 'fuzzy':
|
|
||||||
case 'contains':
|
|
||||||
default:
|
|
||||||
regex = new RegExp(escapeRegExp(phraseText), 'gi');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = [];
|
|
||||||
let result = regex.exec(sourceText);
|
|
||||||
|
|
||||||
while (result) {
|
|
||||||
matches.push({
|
|
||||||
index: result.index,
|
|
||||||
text: result[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result[0].length === 0) {
|
|
||||||
regex.lastIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = regex.exec(sourceText);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = class Score_runsService {
|
module.exports = class Score_runsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await Score_runsDBApi.create(data, {
|
await Score_runsDBApi.create(
|
||||||
|
data,
|
||||||
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
static async runAudit(data, currentUser) {
|
|
||||||
const transaction = await db.sequelize.transaction();
|
|
||||||
const startedAt = new Date();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transcriptText = typeof data.transcript_text === 'string' ? data.transcript_text.trim() : '';
|
|
||||||
const externalCallRef = typeof data.external_call_ref === 'string' ? data.external_call_ref.trim() : '';
|
|
||||||
|
|
||||||
if (!externalCallRef) {
|
|
||||||
throw new ValidationError('externalCallRefRequired', 'Call reference is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transcriptText) {
|
|
||||||
throw new ValidationError('transcriptRequired', 'Transcript text is required to score a call.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transcriptText.length < 20) {
|
|
||||||
throw new ValidationError(
|
|
||||||
'transcriptTooShort',
|
|
||||||
'Transcript text is too short to score reliably. Please paste a longer call transcript.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationsId =
|
|
||||||
currentUser.organizationsId || currentUser.organizationId || currentUser.organization?.id || null;
|
|
||||||
const agencyId = data.agency || data.agencyId || null;
|
|
||||||
const agentId = data.agent || data.agentId || null;
|
|
||||||
const scoringProfileId = data.scoring_profile || data.scoringProfileId || null;
|
|
||||||
const currentUserHasGlobalAccess = Boolean(currentUser.app_role?.globalAccess);
|
|
||||||
|
|
||||||
let scoringProfile = null;
|
|
||||||
let scopedPhraseLists = [];
|
|
||||||
|
|
||||||
if (scoringProfileId) {
|
|
||||||
const scoringProfileWhere = { id: scoringProfileId };
|
|
||||||
if (!currentUserHasGlobalAccess && organizationsId) {
|
|
||||||
scoringProfileWhere.organizationsId = organizationsId;
|
|
||||||
}
|
|
||||||
|
|
||||||
scoringProfile = await db.scoring_profiles.findOne({
|
|
||||||
where: scoringProfileWhere,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scoringProfile) {
|
|
||||||
throw new ValidationError('scoringProfileNotFound', 'Selected scoring profile was not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedPhraseLists = await scoringProfile.getIncluded_phrase_lists({ transaction });
|
|
||||||
}
|
|
||||||
|
|
||||||
const callRecording = await Call_recordingsDBApi.create(
|
|
||||||
{
|
|
||||||
external_call_ref: externalCallRef,
|
|
||||||
direction: data.direction || 'inbound',
|
|
||||||
from_number: data.from_number || null,
|
|
||||||
to_number: data.to_number || null,
|
|
||||||
started_at: startedAt,
|
|
||||||
ended_at: new Date(startedAt.getTime() + estimateDurationSeconds(transcriptText) * 1000),
|
|
||||||
duration_seconds: estimateDurationSeconds(transcriptText),
|
|
||||||
recording_status: 'ready',
|
|
||||||
customer_reference: data.customer_reference || null,
|
|
||||||
campaign: data.campaign || null,
|
|
||||||
notes: data.notes || null,
|
|
||||||
agency: agencyId,
|
|
||||||
agent: agentId,
|
|
||||||
organizations: organizationsId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currentUser,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const transcript = await TranscriptsDBApi.create(
|
|
||||||
{
|
|
||||||
call_recording: callRecording.id,
|
|
||||||
provider: 'other',
|
|
||||||
status: 'completed',
|
|
||||||
completed_at: new Date(),
|
|
||||||
language: data.language || 'en',
|
|
||||||
confidence: 1,
|
|
||||||
full_text: transcriptText,
|
|
||||||
organizations: organizationsId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currentUser,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const phraseListWhere = { is_active: true };
|
|
||||||
|
|
||||||
if (agencyId) {
|
|
||||||
phraseListWhere.agencyId = agencyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUserHasGlobalAccess && organizationsId) {
|
|
||||||
phraseListWhere.organizationsId = organizationsId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scopedPhraseLists.length > 0) {
|
|
||||||
phraseListWhere.id = { [Op.in]: scopedPhraseLists.map((item) => item.id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const activePhraseLists = await db.phrase_lists.findAll({
|
|
||||||
where: phraseListWhere,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!activePhraseLists.length) {
|
|
||||||
throw new ValidationError(
|
|
||||||
'activePhraseListsMissing',
|
|
||||||
'No active phrase lists were found for this audit. Activate a phrase list or adjust the selected scoring profile.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const phraseWhere = {
|
|
||||||
is_active: true,
|
|
||||||
phrase_listId: {
|
|
||||||
[Op.in]: activePhraseLists.map((item) => item.id),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentUserHasGlobalAccess && organizationsId) {
|
|
||||||
phraseWhere.organizationsId = organizationsId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activePhrases = await db.phrases.findAll({
|
|
||||||
where: phraseWhere,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: db.phrase_lists,
|
|
||||||
as: 'phrase_list',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!activePhrases.length) {
|
|
||||||
throw new ValidationError(
|
|
||||||
'activePhrasesMissing',
|
|
||||||
'No active phrases were found. Add phrases to your active lists before running a compliance audit.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalGoodWeight = 0;
|
|
||||||
let totalBadWeight = 0;
|
|
||||||
let hitGoodWeight = 0;
|
|
||||||
let hitBadWeight = 0;
|
|
||||||
let goodPhraseHits = 0;
|
|
||||||
let badPhraseHits = 0;
|
|
||||||
let goodDistinctHits = 0;
|
|
||||||
let badDistinctHits = 0;
|
|
||||||
let missedGoodPhrases = 0;
|
|
||||||
|
|
||||||
const matchesToPersist = [];
|
|
||||||
|
|
||||||
for (const phrase of activePhrases) {
|
|
||||||
const listType = phrase.phrase_list?.list_type || 'good';
|
|
||||||
const matchMode = phrase.phrase_list?.match_mode || 'contains';
|
|
||||||
const phraseText = phrase.phrase_text || '';
|
|
||||||
const phraseWeight = Number(phrase.weight || phrase.phrase_list?.default_weight || 1);
|
|
||||||
const foundMatches = collectMatches(transcriptText, phraseText, matchMode);
|
|
||||||
|
|
||||||
if (listType === 'good') {
|
|
||||||
totalGoodWeight += phraseWeight;
|
|
||||||
} else {
|
|
||||||
totalBadWeight += phraseWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundMatches.length) {
|
|
||||||
if (listType === 'good') {
|
|
||||||
missedGoodPhrases += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstMatch = foundMatches[0];
|
|
||||||
const occurrenceCount = foundMatches.length;
|
|
||||||
const weightedHitValue = phraseWeight * occurrenceCount;
|
|
||||||
|
|
||||||
if (listType === 'good') {
|
|
||||||
hitGoodWeight += weightedHitValue;
|
|
||||||
goodPhraseHits += occurrenceCount;
|
|
||||||
goodDistinctHits += 1;
|
|
||||||
} else {
|
|
||||||
hitBadWeight += weightedHitValue;
|
|
||||||
badPhraseHits += occurrenceCount;
|
|
||||||
badDistinctHits += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
matchesToPersist.push({
|
|
||||||
phraseId: phrase.id,
|
|
||||||
phrase_text: phraseText,
|
|
||||||
list_type: listType,
|
|
||||||
severity: phrase.severity,
|
|
||||||
weight: phraseWeight,
|
|
||||||
occurrence_count: occurrenceCount,
|
|
||||||
first_start_ms: firstMatch.index,
|
|
||||||
first_end_ms: firstMatch.index + firstMatch.text.length,
|
|
||||||
evidence_snippet: buildSnippet(transcriptText, firstMatch.index, firstMatch.text.length),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const goodCoverage = totalGoodWeight > 0 ? (hitGoodWeight / totalGoodWeight) * 100 : 100;
|
|
||||||
const badPenalty = totalBadWeight > 0 ? Math.min((hitBadWeight / totalBadWeight) * 100, 100) : 0;
|
|
||||||
const scoreValue = Number(clamp(goodCoverage - badPenalty, 0, 100).toFixed(1));
|
|
||||||
const passThreshold = Number(scoringProfile?.pass_threshold || 80);
|
|
||||||
|
|
||||||
let result = 'pass';
|
|
||||||
if (scoreValue < passThreshold) {
|
|
||||||
result = 'fail';
|
|
||||||
} else if (badDistinctHits > 0 || missedGoodPhrases > 0) {
|
|
||||||
result = 'review';
|
|
||||||
}
|
|
||||||
|
|
||||||
const riskPhrases = matchesToPersist
|
|
||||||
.filter((item) => item.list_type === 'bad')
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((item) => `“${item.phrase_text}”`);
|
|
||||||
|
|
||||||
const summaryParts = [
|
|
||||||
`Scored ${scoreValue}/100 against ${activePhrases.length} active phrases.`,
|
|
||||||
`Matched ${goodDistinctHits} good phrases and ${badDistinctHits} bad phrases.`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (missedGoodPhrases > 0) {
|
|
||||||
summaryParts.push(`Missed ${missedGoodPhrases} expected good phrases.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (riskPhrases.length > 0) {
|
|
||||||
summaryParts.push(`Key risks: ${riskPhrases.join(', ')}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishedAt = new Date();
|
|
||||||
|
|
||||||
const scoreRun = await Score_runsDBApi.create(
|
|
||||||
{
|
|
||||||
agency: agencyId,
|
|
||||||
call_recording: callRecording.id,
|
|
||||||
scoring_profile: scoringProfile?.id || null,
|
|
||||||
transcript: transcript.id,
|
|
||||||
organizations: organizationsId,
|
|
||||||
status: 'completed',
|
|
||||||
started_at: startedAt,
|
|
||||||
finished_at: finishedAt,
|
|
||||||
score_value: scoreValue,
|
|
||||||
result,
|
|
||||||
good_phrase_hits: goodPhraseHits,
|
|
||||||
bad_phrase_hits: badPhraseHits,
|
|
||||||
summary: summaryParts.join(' '),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currentUser,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const match of matchesToPersist) {
|
|
||||||
await Phrase_matchesDBApi.create(
|
|
||||||
{
|
|
||||||
score_run: scoreRun.id,
|
|
||||||
phrase: match.phraseId,
|
|
||||||
organizations: organizationsId,
|
|
||||||
match_outcome: 'hit',
|
|
||||||
match_confidence: 1,
|
|
||||||
occurrence_count: match.occurrence_count,
|
|
||||||
first_start_ms: match.first_start_ms,
|
|
||||||
first_end_ms: match.first_end_ms,
|
|
||||||
evidence_snippet: match.evidence_snippet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currentUser,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
|
|
||||||
const createdScoreRun = await Score_runsDBApi.findBy({ id: scoreRun.id });
|
|
||||||
|
|
||||||
return {
|
|
||||||
scoreRun: createdScoreRun,
|
|
||||||
matches: matchesToPersist,
|
|
||||||
stats: {
|
|
||||||
pass_threshold: passThreshold,
|
|
||||||
active_phrase_count: activePhrases.length,
|
|
||||||
active_phrase_list_count: activePhraseLists.length,
|
|
||||||
good_phrase_coverage: Number(goodCoverage.toFixed(1)),
|
|
||||||
bad_phrase_penalty: Number(badPenalty.toFixed(1)),
|
|
||||||
},
|
|
||||||
created: {
|
|
||||||
call_recording_id: callRecording.id,
|
|
||||||
transcript_id: transcript.id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
@ -410,24 +38,24 @@ module.exports = class Score_runsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (row) => results.push(row))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
});
|
})
|
||||||
|
|
||||||
await Score_runsDBApi.bulkImport(results, {
|
await Score_runsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser,
|
currentUser: req.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -440,24 +68,34 @@ module.exports = class Score_runsService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const score_runs = await Score_runsDBApi.findBy({ id }, { transaction });
|
let score_runs = await Score_runsDBApi.findBy(
|
||||||
|
{id},
|
||||||
|
{transaction},
|
||||||
|
);
|
||||||
|
|
||||||
if (!score_runs) {
|
if (!score_runs) {
|
||||||
throw new ValidationError('score_runsNotFound');
|
throw new ValidationError(
|
||||||
|
'score_runsNotFound',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedScore_runs = await Score_runsDBApi.update(id, data, {
|
const updatedScore_runs = await Score_runsDBApi.update(
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedScore_runs;
|
return updatedScore_runs;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -479,10 +117,13 @@ module.exports = class Score_runsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Score_runsDBApi.remove(id, {
|
await Score_runsDBApi.remove(
|
||||||
|
id,
|
||||||
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -490,4 +131,8 @@ module.exports = class Score_runsService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user