Joywork/engine/helpers/contacts.php

381 lines
18 KiB
PHP
Raw Normal View History

2026-05-22 20:21:54 +02:00
<?php
/**
* Helper-функции для маскирования контактных данных клиента.
*
* Применяются во всех точках выдачи phone/email клиента из бэкенда, если
* у текущего пользователя включено право `user_permissions.hide_client_contacts`.
*
* Подключается из config_common.php после engine/functions.php.
*/
/**
* FEATURE FLAG: список users.company_id, для которых фича "скрытие контактов" активна.
*
* - массив целых ID фича работает ТОЛЬКО для юзеров с company_id из списка
* - null фича работает для ВСЕХ агентств (полная раскатка / GA)
*
* Чтобы перейти в GA поменять на null и сбросить opcache. БД и история
* не затрагиваются: у агентств вне allowlist маска просто не активируется.
*/
if (!defined('HIDE_CLIENT_CONTACTS_ALLOWED_COMPANIES')) {
define('HIDE_CLIENT_CONTACTS_ALLOWED_COMPANIES', [4, 117, 23015]);
}
if (!function_exists('hide_client_contacts_company_allowed')) {
/**
* Проверяет, разрешена ли фича hide_client_contacts для company_id.
* Если allowlist === null разрешено всем (GA). Иначе только если company_id в списке.
*
* @param int|string|null $companyId
* @return bool
*/
function hide_client_contacts_company_allowed($companyId) {
$allowlist = defined('HIDE_CLIENT_CONTACTS_ALLOWED_COMPANIES')
? HIDE_CLIENT_CONTACTS_ALLOWED_COMPANIES
: null;
if ($allowlist === null) return true; // GA / константа не определена → открыто
if (empty($companyId)) return false; // company_id неизвестен → закрыто (fail-closed)
return in_array((int)$companyId, (array)$allowlist, true);
}
}
if (!function_exists('mask_client_phone')) {
/**
* Маскирует телефон клиента, сохраняя ПЕРВУЮ цифру после "+" (код страны/первая цифра кода).
* Примеры:
* "79991234567" "+7 *** *** ** **" (российский)
* "+12025551234" "+1 *** *** ** **" (США/Канада)
* "+491701234567" "+4 *** *** ** **" (Германия)
* "+861812345678" "+8 *** *** ** **" (Китай)
* Если в строке вообще нет цифр возвращаем "+ *** *** ** **".
*
* @param string|null $phone
* @return string|null Маска, либо исходное значение если оно пустое.
*/
function mask_client_phone($phone) {
if (empty($phone)) return $phone;
$digits = preg_replace('/[^0-9]/', '', $phone);
if ($digits === '') return '+ *** *** ** **';
return '+' . substr($digits, 0, 1) . ' *** *** ** **';
}
}
if (!function_exists('mask_client_email')) {
/**
* Маскирует email клиента.
* Пример: "ivan@mail.ru" "i***@m***.ru".
* Если email невалидный возвращает "***@***".
*
* @param string|null $email
* @return string|null
*/
function mask_client_email($email) {
if (empty($email)) return $email;
if (!preg_match('/^(.)[^@]*@(.)[^.]*(\..+)$/u', $email, $m)) {
return '***@***';
}
return $m[1] . '***@' . $m[2] . '***' . $m[3];
}
}
if (!function_exists('should_hide_contacts_for_current_user')) {
/**
* Должны ли скрываться контакты клиентов для текущего пользователя (из сессии).
* Кэшируется через static в рамках одного PHP-запроса.
*
* Логика (две проверки идут И):
* 1) Гейт раскатки: company_id текущего юзера должен быть в HIDE_CLIENT_CONTACTS_ALLOWED_COMPANIES
* (либо allowlist = null пропускаем всех).
* 2) Право: user_permissions.hide_client_contacts = 1.
*
* Если первое условие не выполнено даже при включённом праве маска не активируется.
*
* @return bool
*/
function should_hide_contacts_for_current_user() {
static $cached = null;
if ($cached === null) {
if (empty($_SESSION['id'])) {
$cached = false;
} else {
// Гейт раскатки + право берём одним запросом, чтобы не делать два хождения в БД.
$userId = (int)$_SESSION['id'];
$sql = "SELECT u.company_id, p.hide_client_contacts
FROM users u
LEFT JOIN user_permissions p ON p.user_id = u.id
WHERE u.id = $userId
LIMIT 1";
$res = mysql_query($sql);
$row = $res ? mysql_fetch_assoc($res) : null;
if (!$row) {
$cached = false;
} elseif (!hide_client_contacts_company_allowed($row['company_id'])) {
// Агентство не в allowlist — фича для него выключена даже если право взведено в БД.
$cached = false;
} else {
$cached = !empty($row['hide_client_contacts']);
}
}
}
return $cached;
}
}
if (!function_exists('should_hide_contacts_for_user_id')) {
/**
* Должны ли скрываться контакты для указанного user_id.
* Для использования в cron-скриптах, где нет сессии.
* Кэшируется per-userId в рамках запроса.
*
* Логика та же что и в should_hide_contacts_for_current_user гейт по
* users.company_id + проверка user_permissions.hide_client_contacts.
*
* @param int|string|null $userId
* @return bool
*/
function should_hide_contacts_for_user_id($userId) {
if (empty($userId)) return false;
static $cache = [];
$userId = (int)$userId;
if (!isset($cache[$userId])) {
$sql = "SELECT u.company_id, p.hide_client_contacts
FROM users u
LEFT JOIN user_permissions p ON p.user_id = u.id
WHERE u.id = $userId
LIMIT 1";
$res = mysql_query($sql);
$row = $res ? mysql_fetch_assoc($res) : null;
if (!$row) {
$cache[$userId] = false;
} elseif (!hide_client_contacts_company_allowed($row['company_id'])) {
$cache[$userId] = false;
} else {
$cache[$userId] = !empty($row['hide_client_contacts']);
}
}
return $cache[$userId];
}
}
if (!function_exists('mask_fio_if_contains_phone')) {
/**
* Если fio имеет вид "Новый клиент …+7…" (с возможным оператором между,
* напр. "Новый клиент Мегафон +79...") и у текущего пользователя включено
* скрытие контактов возвращает "Новый клиент" (без номера и оператора).
*
* Если в fio есть произвольный embedded-номер (6+ подряд цифр) тоже
* вырезаем его, оставляя текст до числа.
*/
function mask_fio_if_contains_phone($fio) {
if (empty($fio)) return $fio;
if (!should_hide_contacts_for_current_user()) return $fio;
// Базовый кейс из CallEvents: "Новый клиент [оператор] +79..."
if (preg_match('/^Новый\s+клиент\b.*?\d{6,}/u', $fio)) {
return 'Новый клиент';
}
// Универсальный: вырезаем длинный набор цифр (телефон) из произвольного fio.
if (preg_match('/\d{6,}/', $fio)) {
$cleaned = preg_replace('/[+\(\)\-\s]*\d[\d\-\(\)\s]{5,}/u', '', $fio);
$cleaned = trim($cleaned);
if ($cleaned !== '') return $cleaned;
}
return $fio;
}
}
if (!function_exists('mask_fio_for_user_id')) {
/**
* Per-user-id вариант mask_fio_if_contains_phone() для cron-скриптов,
* где сессии нет, а получатель уведомления известен по user_id.
*
* Если у указанного $userId включено скрытие контактов и в строке fio
* есть встроенный телефон (e.g. "Новый клиент +79..." или вообще
* любой 6+ значный набор цифр) возвращает fio без номера.
*/
function mask_fio_for_user_id($fio, $userId) {
if (empty($fio)) return $fio;
if (!should_hide_contacts_for_user_id($userId)) return $fio;
if (preg_match('/^Новый\s+клиент\b.*?\d{6,}/u', $fio)) {
return 'Новый клиент';
}
if (preg_match('/\d{6,}/', $fio)) {
$cleaned = preg_replace('/[+\(\)\-\s]*\d[\d\-\(\)\s]{5,}/u', '', $fio);
$cleaned = trim($cleaned);
if ($cleaned !== '') return $cleaned;
}
return $fio;
}
}
if (!function_exists('mask_client_contacts_inplace')) {
/**
* Маскирует контактные данные клиента в assoc-массиве (по ссылке).
* Обрабатывает: phone, email, phones (массив), phones_dop (массив),
* phone_email (склейка для tooltip), fio (если содержит номер).
* Если права на скрытие нет ничего не делает.
*
* @param array $client Массив клиента, изменяется по ссылке.
*/
function mask_client_contacts_inplace(array &$client) {
if (!should_hide_contacts_for_current_user()) return;
if (isset($client['phone'])) {
$client['phone'] = mask_client_phone($client['phone']);
}
if (isset($client['email'])) {
$client['email'] = mask_client_email($client['email']);
}
// Признак «зарубежный телефон» — это метаданные о номере; раз сам номер
// скрыт, не отдаём и его «национальность». Чтобы переключатель в формах
// не показывал реальное состояние, форсим 0.
if (isset($client['national_phone'])) {
$client['national_phone'] = 0;
}
if (isset($client['phones']) && is_array($client['phones'])) {
foreach ($client['phones'] as &$p) {
$p = mask_client_phone($p);
}
unset($p);
}
if (isset($client['phones_dop']) && is_array($client['phones_dop'])) {
foreach ($client['phones_dop'] as &$p) {
$p = mask_client_phone($p);
}
unset($p);
}
// ПК хранит дополнительные телефоны в поле clients.phone2 как JSON-строку
// (например, ["+79991234567","+12025551234"]). Маскируем каждый элемент,
// сохраняя ту же JSON-строку для совместимости с JS, который делает JSON.parse.
if (isset($client['phone2']) && is_string($client['phone2'])
&& $client['phone2'] !== '' && $client['phone2'] !== 'null'
) {
$decoded = json_decode($client['phone2'], true);
if (is_array($decoded) && !empty($decoded)) {
$masked = array_map('mask_client_phone', $decoded);
$client['phone2'] = json_encode($masked);
}
}
if (isset($client['phone_email'])) {
$parts = [];
if (!empty($client['phone'])) $parts[] = $client['phone'];
if (!empty($client['email'])) $parts[] = $client['email'];
$client['phone_email'] = implode(', ', $parts);
}
if (isset($client['fio'])) {
$client['fio'] = mask_fio_if_contains_phone($client['fio']);
}
// В API клиент часто отдаётся с ключом 'name' (алиас fio) — маскируем тоже.
if (isset($client['name'])) {
$client['name'] = mask_fio_if_contains_phone($client['name']);
}
}
}
if (!function_exists('mask_text_for_hide_user')) {
/**
* Маскирует исторические события (events_clients.dop_text), которые содержат
* фразы вида "Изменен телефон клиента с «+79...» на «+79...»" или
* "Изменен email клиента с «mail@x.ru» на «...»".
*
* Контекстная маскировка: ищет в строке маркеры "телефон"/"email"/"почта" и
* маскирует значения внутри «...» только для тех строк. Бюджет/ФИО/прочие
* поля с цифрами остаются нетронутыми.
*
* Безопасно при повторном применении: уже замаскированные значения (со
* звёздочками внутри) пропускаются без изменений.
*
* @param string $text
* @return string
*/
function mask_text_for_hide_user($text) {
if (empty($text)) return $text;
if (!should_hide_contacts_for_current_user()) return $text;
// dop_text в БД пишется с HTML-entity-кавычками "&laquo; … &raquo;"
// (см. Clients::addEdit). В тексте «телефон» и «email» — наоборот,
// обычные UTF-8 символы. Декодируем entities в самом начале, чтобы
// regex ниже работал и с прежними записями (entity-форма), и с
// потенциальными будущими (с обычными « »).
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
$lines = preg_split('/(<br\s*\/?>)/i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
foreach ($lines as &$line) {
if ($line === '' || preg_match('/^<br/i', $line)) continue;
$lower = mb_strtolower($line, 'UTF-8');
$isPhoneLine = (mb_strpos($lower, 'телефон') !== false);
$isEmailLine = (mb_strpos($lower, 'email') !== false) || (mb_strpos($lower, 'почта') !== false);
if ($isPhoneLine) {
$line = preg_replace_callback(
'/«([^»]*\d[^»]*)»/u',
function($m) {
if (strpos($m[1], '*') !== false) return $m[0];
return '«' . mask_client_phone($m[1]) . '»';
},
$line
);
}
if ($isEmailLine) {
$line = preg_replace_callback(
'/«([^»]*@[^»]*)»/u',
function($m) {
if (strpos($m[1], '*') !== false) return $m[0];
return '«' . mask_client_email($m[1]) . '»';
},
$line
);
}
}
unset($line);
return implode('', $lines);
}
}
if (!function_exists('mask_phone_value_if_needed')) {
/**
* Условная маскировка одного телефона (для скаляров вне массивов).
* Применяется в местах, где phone клиента приходит без обёртки в массив.
*/
function mask_phone_value_if_needed($phone) {
if (should_hide_contacts_for_current_user()) {
return mask_client_phone($phone);
}
return $phone;
}
}
if (!function_exists('mask_email_value_if_needed')) {
/**
* Условная маскировка одного email (для скаляров вне массивов).
*/
function mask_email_value_if_needed($email) {
if (should_hide_contacts_for_current_user()) {
return mask_client_email($email);
}
return $email;
}
}
if (!function_exists('mask_phone_for_user_id')) {
/**
* Маскировка телефона для конкретного user_id (для cron-скриптов).
*/
function mask_phone_for_user_id($phone, $userId) {
if (should_hide_contacts_for_user_id($userId)) {
return mask_client_phone($phone);
}
return $phone;
}
}
if (!function_exists('mask_email_for_user_id')) {
/**
* Маскировка email для конкретного user_id (для cron-скриптов).
*/
function mask_email_for_user_id($email, $userId) {
if (should_hide_contacts_for_user_id($userId)) {
return mask_client_email($email);
}
return $email;
}
}