ваше сообщение коммита
This commit is contained in:
3
backend/db/migrations/044_add_is_blocked_to_users.sql
Normal file
3
backend/db/migrations/044_add_is_blocked_to_users.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Добавление поля is_blocked и blocked_at для блокировки пользователя
|
||||||
|
ALTER TABLE users ADD COLUMN is_blocked boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE users ADD COLUMN blocked_at timestamp;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2030,3 +2030,89 @@
|
|||||||
{"level":"error","message":"Unhandled Rejection: Cannot use a pool after calling end on the pool","stack":"Error: Cannot use a pool after calling end on the pool\n at /app/node_modules/pg-pool/index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async /app/app.js:103:20","timestamp":"2025-07-03T21:56:00.872Z"}
|
{"level":"error","message":"Unhandled Rejection: Cannot use a pool after calling end on the pool","stack":"Error: Cannot use a pool after calling end on the pool\n at /app/node_modules/pg-pool/index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async /app/app.js:103:20","timestamp":"2025-07-03T21:56:00.872Z"}
|
||||||
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-03T22:06:50.565Z"}
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-03T22:06:50.565Z"}
|
||||||
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-03T22:06:50.566Z"}
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-03T22:06:50.566Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.815Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.816Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.818Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.818Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.027Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.028Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.028Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.028Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:36.420Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.267Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:55.822Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:55.824Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.103Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.103Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.103Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.058Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.060Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.060Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.060Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.061Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.061Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.061Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.490Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.492Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.492Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.492Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.494Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:55:28.951Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:55:45.515Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.159Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.159Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:57.234Z"}
|
||||||
|
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:40:16.048Z"}
|
||||||
|
{"level":"error","message":"Uncaught Exception: Not authenticated","stack":"Error: Not authenticated\n at Connection.openBox (/app/node_modules/imap/lib/Connection.js:409:11)\n at Connection.<anonymous> (/app/services/emailBot.js:105:19)\n at Object.onceWrapper (node:events:638:28)\n at Connection.emit (node:events:536:35)\n at Connection.<anonymous> (/app/node_modules/imap/lib/Connection.js:1623:12)\n at Connection._resTagged (/app/node_modules/imap/lib/Connection.js:1535:22)\n at Parser.<anonymous> (/app/node_modules/imap/lib/Connection.js:194:10)\n at Parser.emit (node:events:524:28)\n at Parser._resTagged (/app/node_modules/imap/lib/Parser.js:175:10)\n at Parser._parse (/app/node_modules/imap/lib/Parser.js:139:16)","timestamp":"2025-07-04T13:40:45.693Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.331Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
|
||||||
|
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.333Z"}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ router.get('/', requireAuth, async (req, res, next) => {
|
|||||||
dateTo = '',
|
dateTo = '',
|
||||||
contactType = 'all',
|
contactType = 'all',
|
||||||
search = '',
|
search = '',
|
||||||
newMessages = ''
|
newMessages = '',
|
||||||
|
blocked = 'all'
|
||||||
} = req.query;
|
} = req.query;
|
||||||
const adminId = req.user && req.user.id;
|
const adminId = req.user && req.user.id;
|
||||||
|
|
||||||
@@ -100,9 +101,16 @@ router.get('/', requireAuth, async (req, res, next) => {
|
|||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Фильтр по блокировке
|
||||||
|
if (blocked === 'blocked') {
|
||||||
|
where.push(`u.is_blocked = true`);
|
||||||
|
} else if (blocked === 'unblocked') {
|
||||||
|
where.push(`u.is_blocked = false`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Основной SQL ---
|
// --- Основной SQL ---
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT u.id, u.first_name, u.last_name, u.created_at, u.preferred_language,
|
SELECT u.id, u.first_name, u.last_name, u.created_at, u.preferred_language, u.is_blocked,
|
||||||
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'email' LIMIT 1) AS email,
|
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'email' LIMIT 1) AS email,
|
||||||
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'telegram' LIMIT 1) AS telegram,
|
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'telegram' LIMIT 1) AS telegram,
|
||||||
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'wallet' LIMIT 1) AS wallet
|
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'wallet' LIMIT 1) AS wallet
|
||||||
@@ -169,7 +177,8 @@ router.get('/', requireAuth, async (req, res, next) => {
|
|||||||
telegram: u.telegram || null,
|
telegram: u.telegram || null,
|
||||||
wallet: u.wallet || null,
|
wallet: u.wallet || null,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
preferred_language: u.preferred_language || []
|
preferred_language: u.preferred_language || [],
|
||||||
|
is_blocked: u.is_blocked || false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ success: true, contacts });
|
res.json({ success: true, contacts });
|
||||||
@@ -232,34 +241,58 @@ router.post('/mark-contact-read', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/users/:id — обновить имя и язык
|
// Заблокировать пользователя
|
||||||
router.patch('/:id', async (req, res) => {
|
router.patch('/:id/block', requireAuth, async (req, res) => {
|
||||||
const userId = req.params.id;
|
|
||||||
const { name, language } = req.body;
|
|
||||||
if (!name && !language) return res.status(400).json({ error: 'Nothing to update' });
|
|
||||||
try {
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
await db.query('UPDATE users SET is_blocked = true, blocked_at = NOW() WHERE id = $1', [userId]);
|
||||||
|
res.json({ success: true, message: 'Пользователь заблокирован' });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Ошибка блокировки пользователя:', e);
|
||||||
|
res.status(500).json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Разблокировать пользователя
|
||||||
|
router.patch('/:id/unblock', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
await db.query('UPDATE users SET is_blocked = false, blocked_at = NULL WHERE id = $1', [userId]);
|
||||||
|
res.json({ success: true, message: 'Пользователь разблокирован' });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Ошибка разблокировки пользователя:', e);
|
||||||
|
res.status(500).json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить пользователя (в том числе is_blocked)
|
||||||
|
router.patch('/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { first_name, last_name, preferred_language, is_blocked } = req.body;
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
if (name !== undefined) {
|
if (first_name !== undefined) { fields.push(`first_name = $${idx++}`); values.push(first_name); }
|
||||||
// Разделяем имя на first_name и last_name (по пробелу)
|
if (last_name !== undefined) { fields.push(`last_name = $${idx++}`); values.push(last_name); }
|
||||||
const [firstName, ...lastNameArr] = name.split(' ');
|
if (preferred_language !== undefined) { fields.push(`preferred_language = $${idx++}`); values.push(JSON.stringify(preferred_language)); }
|
||||||
fields.push(`first_name = $${idx++}`);
|
if (is_blocked !== undefined) {
|
||||||
values.push(firstName);
|
fields.push(`is_blocked = $${idx++}`);
|
||||||
fields.push(`last_name = $${idx++}`);
|
values.push(is_blocked);
|
||||||
values.push(lastNameArr.join(' ') || null);
|
if (is_blocked) {
|
||||||
|
fields.push(`blocked_at = NOW()`);
|
||||||
|
} else {
|
||||||
|
fields.push(`blocked_at = NULL`);
|
||||||
}
|
}
|
||||||
if (language !== undefined) {
|
|
||||||
fields.push(`preferred_language = $${idx++}`);
|
|
||||||
values.push(JSON.stringify(language));
|
|
||||||
}
|
}
|
||||||
|
if (!fields.length) return res.status(400).json({ success: false, error: 'Нет данных для обновления' });
|
||||||
|
const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = $${idx}`;
|
||||||
values.push(userId);
|
values.push(userId);
|
||||||
const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`;
|
await db.query(sql, values);
|
||||||
const result = await db.getQuery()(sql, values);
|
res.json({ success: true, message: 'Пользователь обновлен' });
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('PATCH /api/users/:id error', { error: e, body: req.body, stack: e.stack });
|
logger.error('Ошибка обновления пользователя:', e);
|
||||||
res.status(500).json({ error: 'DB error', details: e.message });
|
res.status(500).json({ success: false, error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,4 +362,74 @@ router.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Массовый импорт контактов
|
||||||
|
router.post('/import', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contacts = req.body;
|
||||||
|
if (!Array.isArray(contacts)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Ожидается массив контактов' });
|
||||||
|
}
|
||||||
|
const dbq = db.getQuery();
|
||||||
|
let added = 0, updated = 0, errors = [];
|
||||||
|
for (const [i, c] of contacts.entries()) {
|
||||||
|
try {
|
||||||
|
// Имя
|
||||||
|
let first_name = null, last_name = null;
|
||||||
|
if (c.name) {
|
||||||
|
const parts = c.name.trim().split(' ');
|
||||||
|
first_name = parts[0] || null;
|
||||||
|
last_name = parts.slice(1).join(' ') || null;
|
||||||
|
}
|
||||||
|
// Проверка на существование по email/telegram/wallet
|
||||||
|
let userId = null;
|
||||||
|
let foundUser = null;
|
||||||
|
if (c.email) {
|
||||||
|
const r = await dbq('SELECT user_id FROM user_identities WHERE provider = $1 AND provider_id = $2', ['email', c.email.toLowerCase()]);
|
||||||
|
if (r.rows.length) foundUser = r.rows[0].user_id;
|
||||||
|
}
|
||||||
|
if (!foundUser && c.telegram) {
|
||||||
|
const r = await dbq('SELECT user_id FROM user_identities WHERE provider = $1 AND provider_id = $2', ['telegram', c.telegram]);
|
||||||
|
if (r.rows.length) foundUser = r.rows[0].user_id;
|
||||||
|
}
|
||||||
|
if (!foundUser && c.wallet) {
|
||||||
|
const r = await dbq('SELECT user_id FROM user_identities WHERE provider = $1 AND provider_id = $2', ['wallet', c.wallet]);
|
||||||
|
if (r.rows.length) foundUser = r.rows[0].user_id;
|
||||||
|
}
|
||||||
|
if (foundUser) {
|
||||||
|
userId = foundUser;
|
||||||
|
updated++;
|
||||||
|
// Обновляем имя, если нужно
|
||||||
|
if (first_name || last_name) {
|
||||||
|
await dbq('UPDATE users SET first_name = COALESCE($1, first_name), last_name = COALESCE($2, last_name) WHERE id = $3', [first_name, last_name, userId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Создаём нового пользователя
|
||||||
|
const ins = await dbq('INSERT INTO users (first_name, last_name, created_at) VALUES ($1, $2, NOW()) RETURNING id', [first_name, last_name]);
|
||||||
|
userId = ins.rows[0].id;
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
// Добавляем идентификаторы (email, telegram, wallet)
|
||||||
|
const identities = [
|
||||||
|
c.email ? { provider: 'email', provider_id: c.email.toLowerCase() } : null,
|
||||||
|
c.telegram ? { provider: 'telegram', provider_id: c.telegram } : null,
|
||||||
|
c.wallet ? { provider: 'wallet', provider_id: c.wallet } : null
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const idn of identities) {
|
||||||
|
// Проверяем, есть ли уже такой идентификатор у пользователя
|
||||||
|
const exists = await dbq('SELECT 1 FROM user_identities WHERE user_id = $1 AND provider = $2 AND provider_id = $3', [userId, idn.provider, idn.provider_id]);
|
||||||
|
if (!exists.rows.length) {
|
||||||
|
await dbq('INSERT INTO user_identities (user_id, provider, provider_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [userId, idn.provider, idn.provider_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push({ row: i + 1, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcastContactsUpdate();
|
||||||
|
res.json({ success: true, added, updated, errors });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"element-plus": "^2.9.11",
|
"element-plus": "^2.9.11",
|
||||||
"ethers": "6.13.5",
|
"ethers": "6.13.5",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"siwe": "^2.1.4",
|
"siwe": "^2.1.4",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
|
|||||||
74
frontend/src/components/BroadcastModal.vue
Normal file
74
frontend/src/components/BroadcastModal.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="Массовая рассылка" width="700px" @close="$emit('close')">
|
||||||
|
<div v-if="step === 1">
|
||||||
|
<div style="margin-bottom:1em;">Вы выбрали {{userIds.length}} пользователей для рассылки.</div>
|
||||||
|
<ChatInterface
|
||||||
|
v-model:newMessage="message"
|
||||||
|
:isAdmin="true"
|
||||||
|
:messages="[]"
|
||||||
|
:attachments="attachments"
|
||||||
|
@update:attachments="val => attachments = val"
|
||||||
|
@send-message="onSend"
|
||||||
|
:showSendButton="false"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :disabled="!message.trim()" @click="sendBroadcast" :loading="loading">Отправить</el-button>
|
||||||
|
<el-button @click="$emit('close')" style="margin-left:1em;">Отмена</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 2">
|
||||||
|
<div v-if="result.success" style="color:green;">Рассылка завершена успешно!</div>
|
||||||
|
<div v-if="result.errors && result.errors.length" style="color:red;max-height:120px;overflow:auto;">
|
||||||
|
Ошибки:
|
||||||
|
<ul>
|
||||||
|
<li v-for="err in result.errors" :key="err.userId">ID {{err.userId}}: {{err.error}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="closeAndRefresh">Закрыть</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import ChatInterface from './ChatInterface.vue';
|
||||||
|
import messagesService from '../services/messagesService.js';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
const props = defineProps({ userIds: { type: Array, required: true } });
|
||||||
|
const visible = ref(true);
|
||||||
|
const message = ref('');
|
||||||
|
const attachments = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const step = ref(1);
|
||||||
|
const result = ref({});
|
||||||
|
|
||||||
|
async function sendBroadcast() {
|
||||||
|
loading.value = true;
|
||||||
|
const errors = [];
|
||||||
|
let successCount = 0;
|
||||||
|
for (const userId of props.userIds) {
|
||||||
|
try {
|
||||||
|
await messagesService.broadcastMessage({ userId, message: message.value });
|
||||||
|
successCount++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.push({ userId, error: e?.message || 'Ошибка отправки' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.value = { success: errors.length === 0, errors };
|
||||||
|
step.value = 2;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
function onSend() {
|
||||||
|
sendBroadcast();
|
||||||
|
}
|
||||||
|
function closeAndRefresh() {
|
||||||
|
visible.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
step.value = 1;
|
||||||
|
result.value = {};
|
||||||
|
message.value = '';
|
||||||
|
attachments.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
// Сообщаем родителю об успешной рассылке
|
||||||
|
emit('close');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contact-table-modal">
|
<div class="contact-table-modal">
|
||||||
<div class="contact-table-header">
|
<div class="contact-table-header">
|
||||||
<h2>Контакты</h2>
|
<el-button type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button>
|
||||||
|
<el-button type="danger" :disabled="!selectedIds.length" @click="deleteSelected" style="margin-right: 1em;">Удалить</el-button>
|
||||||
|
<el-button type="primary" @click="showImportModal = true" style="margin-right: 1em;">Импорт</el-button>
|
||||||
<button class="close-btn" @click="$emit('close')">×</button>
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<el-form :inline="true" class="filters-form" label-position="top">
|
<el-form :inline="true" class="filters-form" label-position="top">
|
||||||
@@ -28,6 +30,13 @@
|
|||||||
<el-option label="Да" value="yes" />
|
<el-option label="Да" value="yes" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="Блокировка">
|
||||||
|
<el-select v-model="filterBlocked" placeholder="Все" style="min-width:120px;" @change="onAnyFilterChange">
|
||||||
|
<el-option label="Все" value="all" />
|
||||||
|
<el-option label="Только заблокированные" value="blocked" />
|
||||||
|
<el-option label="Только не заблокированные" value="unblocked" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="Теги">
|
<el-form-item label="Теги">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="selectedTagIds"
|
v-model="selectedTagIds"
|
||||||
@@ -53,6 +62,7 @@
|
|||||||
<table class="contact-table">
|
<table class="contact-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Telegram</th>
|
<th>Telegram</th>
|
||||||
@@ -63,6 +73,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
|
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
|
||||||
|
<td><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
|
||||||
<td>{{ contact.name || '-' }}</td>
|
<td>{{ contact.name || '-' }}</td>
|
||||||
<td>{{ contact.email || '-' }}</td>
|
<td>{{ contact.email || '-' }}</td>
|
||||||
<td>{{ contact.telegram || '-' }}</td>
|
<td>{{ contact.telegram || '-' }}</td>
|
||||||
@@ -75,13 +86,17 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<ImportContactsModal v-if="showImportModal" @close="showImportModal = false" @imported="onImported" />
|
||||||
|
<BroadcastModal v-if="showBroadcastModal" :user-ids="selectedIds" @close="showBroadcastModal = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, computed, ref, onMounted, watch } from 'vue';
|
import { defineProps, computed, ref, onMounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton } from 'element-plus';
|
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton, ElMessageBox, ElMessage } from 'element-plus';
|
||||||
|
import ImportContactsModal from './ImportContactsModal.vue';
|
||||||
|
import BroadcastModal from './BroadcastModal.vue';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: { type: Array, default: () => [] },
|
contacts: { type: Array, default: () => [] },
|
||||||
newContacts: { type: Array, default: () => [] },
|
newContacts: { type: Array, default: () => [] },
|
||||||
@@ -100,11 +115,18 @@ const filterContactType = ref('all');
|
|||||||
const filterDateFrom = ref('');
|
const filterDateFrom = ref('');
|
||||||
const filterDateTo = ref('');
|
const filterDateTo = ref('');
|
||||||
const filterNewMessages = ref('');
|
const filterNewMessages = ref('');
|
||||||
|
const filterBlocked = ref('all');
|
||||||
|
|
||||||
// Теги
|
// Теги
|
||||||
const allTags = ref([]);
|
const allTags = ref([]);
|
||||||
const selectedTagIds = ref([]);
|
const selectedTagIds = ref([]);
|
||||||
|
|
||||||
|
const showImportModal = ref(false);
|
||||||
|
const showBroadcastModal = ref(false);
|
||||||
|
|
||||||
|
const selectedIds = ref([]);
|
||||||
|
const selectAll = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadTags();
|
await loadTags();
|
||||||
await fetchContacts();
|
await fetchContacts();
|
||||||
@@ -123,6 +145,7 @@ function buildQuery() {
|
|||||||
if (filterContactType.value && filterContactType.value !== 'all') params.append('contactType', filterContactType.value);
|
if (filterContactType.value && filterContactType.value !== 'all') params.append('contactType', filterContactType.value);
|
||||||
if (filterSearch.value) params.append('search', filterSearch.value);
|
if (filterSearch.value) params.append('search', filterSearch.value);
|
||||||
if (filterNewMessages.value) params.append('newMessages', filterNewMessages.value);
|
if (filterNewMessages.value) params.append('newMessages', filterNewMessages.value);
|
||||||
|
if (filterBlocked.value && filterBlocked.value !== 'all') params.append('blocked', filterBlocked.value);
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +172,7 @@ function resetFilters() {
|
|||||||
filterDateFrom.value = '';
|
filterDateFrom.value = '';
|
||||||
filterDateTo.value = '';
|
filterDateTo.value = '';
|
||||||
filterNewMessages.value = '';
|
filterNewMessages.value = '';
|
||||||
|
filterBlocked.value = 'all';
|
||||||
selectedTagIds.value = [];
|
selectedTagIds.value = [];
|
||||||
fetchContacts();
|
fetchContacts();
|
||||||
}
|
}
|
||||||
@@ -175,6 +199,45 @@ async function showDetails(contact) {
|
|||||||
}
|
}
|
||||||
router.push({ name: 'contact-details', params: { id: contact.id } });
|
router.push({ name: 'contact-details', params: { id: contact.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onImported() {
|
||||||
|
showImportModal.value = false;
|
||||||
|
fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (selectAll.value) {
|
||||||
|
selectedIds.value = contactsArray.value.map(c => c.id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(contactsArray, () => {
|
||||||
|
// Сбросить выбор при обновлении данных
|
||||||
|
selectedIds.value = [];
|
||||||
|
selectAll.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (!selectedIds.value.length) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`Вы действительно хотите удалить ${selectedIds.value.length} контакт(ов)?`,
|
||||||
|
'Подтверждение удаления',
|
||||||
|
{ type: 'warning' }
|
||||||
|
);
|
||||||
|
for (const id of selectedIds.value) {
|
||||||
|
await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
ElMessage.success('Контакты удалены');
|
||||||
|
fetchContacts();
|
||||||
|
selectedIds.value = [];
|
||||||
|
selectAll.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
// Отмена
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -190,9 +253,9 @@ async function showDetails(contact) {
|
|||||||
}
|
}
|
||||||
.contact-table-header {
|
.contact-table-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.close-btn {
|
.close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
177
frontend/src/components/ImportContactsModal.vue
Normal file
177
frontend/src/components/ImportContactsModal.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="Импорт контактов" width="800px" @close="$emit('close')">
|
||||||
|
<div v-if="step === 1">
|
||||||
|
<el-upload
|
||||||
|
drag
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
accept=".csv,.json"
|
||||||
|
@change="handleFileChange"
|
||||||
|
style="width:100%"
|
||||||
|
>
|
||||||
|
<i class="el-icon-upload"></i>
|
||||||
|
<div class="el-upload__text">Перетащите файл сюда или <em>нажмите для выбора</em></div>
|
||||||
|
<div class="el-upload__tip">Поддерживаются форматы CSV и JSON</div>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 2">
|
||||||
|
<div style="margin-bottom:1em;">Сопоставьте столбцы файла с полями контакта:</div>
|
||||||
|
<el-table :data="previewRows" border style="width:100%;margin-bottom:1em;">
|
||||||
|
<el-table-column v-for="(col, idx) in columns" :key="col" :label="col">
|
||||||
|
<template #header>
|
||||||
|
<el-select v-model="mapping[col]" placeholder="Выбрать поле" size="small">
|
||||||
|
<el-option v-for="f in fields" :key="f.value" :label="f.label" :value="f.value" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row[col] }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Удалить" width="80">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button type="danger" icon="el-icon-delete" size="small" @click="removeRow(scope.$index)" circle />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-button @click="step = 1" style="margin-right:1em;">Назад</el-button>
|
||||||
|
<el-button type="primary" @click="submitImport" :loading="loading">Импортировать</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 3">
|
||||||
|
<div v-if="result.success" style="color:green;">Импорт завершён: добавлено {{result.added}}, обновлено {{result.updated}}</div>
|
||||||
|
<div v-if="result.errors && result.errors.length" style="color:red;max-height:120px;overflow:auto;">
|
||||||
|
Ошибки:
|
||||||
|
<ul>
|
||||||
|
<li v-for="err in result.errors" :key="err.row">Строка {{err.row}}: {{err.error}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="closeAndRefresh">Закрыть</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
const visible = ref(true);
|
||||||
|
const step = ref(1);
|
||||||
|
const file = ref(null);
|
||||||
|
const rawRows = ref([]);
|
||||||
|
const columns = ref([]);
|
||||||
|
const previewRows = ref([]);
|
||||||
|
const mapping = reactive({});
|
||||||
|
const loading = ref(false);
|
||||||
|
const result = ref({});
|
||||||
|
const fields = [
|
||||||
|
{ label: 'Имя', value: 'name' },
|
||||||
|
{ label: 'Email', value: 'email' },
|
||||||
|
{ label: 'Telegram', value: 'telegram' },
|
||||||
|
{ label: 'Wallet', value: 'wallet' }
|
||||||
|
];
|
||||||
|
function handleFileChange(e) {
|
||||||
|
const f = e.raw || (e.target && e.target.files && e.target.files[0]);
|
||||||
|
if (!f) return;
|
||||||
|
file.value = f;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (evt) => {
|
||||||
|
let data = [];
|
||||||
|
if (f.name.endsWith('.csv')) {
|
||||||
|
const parsed = Papa.parse(evt.target.result, { header: true });
|
||||||
|
data = parsed.data.filter(r => Object.values(r).some(Boolean));
|
||||||
|
} else if (f.name.endsWith('.json')) {
|
||||||
|
try {
|
||||||
|
let parsed = JSON.parse(evt.target.result);
|
||||||
|
let dataCandidate = Array.isArray(parsed) ? parsed : findFirstArray(parsed);
|
||||||
|
if (!Array.isArray(dataCandidate)) {
|
||||||
|
throw new Error('JSON должен содержать массив объектов на любом уровне вложенности');
|
||||||
|
}
|
||||||
|
data = dataCandidate;
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('Ошибка парсинга JSON: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data.length) {
|
||||||
|
ElMessage.error('Файл не содержит данных');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawRows.value = data;
|
||||||
|
columns.value = Object.keys(data[0]);
|
||||||
|
previewRows.value = data.slice(0, 10);
|
||||||
|
// Автоматический маппинг по названию
|
||||||
|
for (const col of columns.value) {
|
||||||
|
const lower = col.toLowerCase();
|
||||||
|
if (lower.includes('mail')) mapping[col] = 'email';
|
||||||
|
else if (lower.includes('tele')) mapping[col] = 'telegram';
|
||||||
|
else if (lower.includes('wallet')) mapping[col] = 'wallet';
|
||||||
|
else if (lower.includes('name')) mapping[col] = 'name';
|
||||||
|
else mapping[col] = '';
|
||||||
|
}
|
||||||
|
step.value = 2;
|
||||||
|
};
|
||||||
|
reader.readAsText(f);
|
||||||
|
}
|
||||||
|
function removeRow(idx) {
|
||||||
|
rawRows.value.splice(idx, 1);
|
||||||
|
previewRows.value = rawRows.value.slice(0, 10);
|
||||||
|
}
|
||||||
|
async function submitImport() {
|
||||||
|
loading.value = true;
|
||||||
|
// Собираем данные по маппингу
|
||||||
|
const contacts = rawRows.value.map(row => {
|
||||||
|
const obj = {};
|
||||||
|
for (const col of columns.value) {
|
||||||
|
const field = mapping[col];
|
||||||
|
if (field) obj[field] = row[col];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/users/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(contacts)
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
result.value = data;
|
||||||
|
step.value = 3;
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('Ошибка импорта: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeAndRefresh() {
|
||||||
|
visible.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
step.value = 1;
|
||||||
|
result.value = {};
|
||||||
|
rawRows.value = [];
|
||||||
|
columns.value = [];
|
||||||
|
previewRows.value = [];
|
||||||
|
file.value = null;
|
||||||
|
Object.keys(mapping).forEach(k => delete mapping[k]);
|
||||||
|
loading.value = false;
|
||||||
|
// Сообщаем родителю об успешном импорте
|
||||||
|
emit('imported');
|
||||||
|
emit('close');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
function findFirstArray(obj) {
|
||||||
|
if (Array.isArray(obj)) return obj;
|
||||||
|
if (typeof obj === 'object' && obj !== null) {
|
||||||
|
for (const key in obj) {
|
||||||
|
const found = findFirstArray(obj[key]);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.el-upload {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -28,6 +28,14 @@ export default {
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
},
|
||||||
|
async blockContact(id) {
|
||||||
|
const res = await api.patch(`/api/users/${id}/block`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
async unblockContact(id) {
|
||||||
|
const res = await api.patch(`/api/users/${id}/unblock`);
|
||||||
|
return res.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,27 @@
|
|||||||
</span>
|
</span>
|
||||||
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
|
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="block-user-section">
|
||||||
|
<strong>Статус блокировки:</strong>
|
||||||
|
<span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span>
|
||||||
|
<span v-else class="unblocked-status">Не заблокирован</span>
|
||||||
|
<template v-if="isAdmin">
|
||||||
|
<el-button
|
||||||
|
v-if="!contact.is_blocked"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="blockUser"
|
||||||
|
style="margin-left: 1em;"
|
||||||
|
>Заблокировать</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="unblockUser"
|
||||||
|
style="margin-left: 1em;"
|
||||||
|
>Разблокировать</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
|
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="messages-block">
|
<div class="messages-block">
|
||||||
@@ -326,6 +347,14 @@ function goBack() {
|
|||||||
|
|
||||||
async function handleSendMessage({ message, attachments }) {
|
async function handleSendMessage({ message, attachments }) {
|
||||||
if (!contact.value || !contact.value.id) return;
|
if (!contact.value || !contact.value.id) return;
|
||||||
|
if (contact.value.is_blocked) {
|
||||||
|
if (typeof ElMessageBox === 'function') {
|
||||||
|
ElMessageBox.alert('Пользователь заблокирован. Отправка сообщений невозможна.', 'Ошибка', { type: 'error' });
|
||||||
|
} else {
|
||||||
|
alert('Пользователь заблокирован. Отправка сообщений невозможна.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Проверка наличия хотя бы одного идентификатора
|
// Проверка наличия хотя бы одного идентификатора
|
||||||
const hasAnyId = contact.value.email || contact.value.telegram || contact.value.wallet;
|
const hasAnyId = contact.value.email || contact.value.telegram || contact.value.wallet;
|
||||||
if (!hasAnyId) {
|
if (!hasAnyId) {
|
||||||
@@ -395,6 +424,36 @@ async function handleAiReply(selectedMessages = []) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showBlockStatusMessage(msg, type = 'info') {
|
||||||
|
if (typeof ElMessageBox === 'function') {
|
||||||
|
ElMessageBox.alert(msg, 'Статус блокировки', { type });
|
||||||
|
} else {
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blockUser() {
|
||||||
|
if (!contact.value) return;
|
||||||
|
try {
|
||||||
|
await contactsService.blockContact(contact.value.id);
|
||||||
|
contact.value.is_blocked = true;
|
||||||
|
showBlockStatusMessage('Пользователь заблокирован', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showBlockStatusMessage('Ошибка блокировки пользователя', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unblockUser() {
|
||||||
|
if (!contact.value) return;
|
||||||
|
try {
|
||||||
|
await contactsService.unblockContact(contact.value.id);
|
||||||
|
contact.value.is_blocked = false;
|
||||||
|
showBlockStatusMessage('Пользователь разблокирован', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showBlockStatusMessage('Ошибка разблокировки пользователя', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await reloadContact();
|
await reloadContact();
|
||||||
await loadMessages();
|
await loadMessages();
|
||||||
@@ -582,4 +641,16 @@ watch(userId, async () => {
|
|||||||
.add-tag-btn:hover {
|
.add-tag-btn:hover {
|
||||||
background: #27ae38;
|
background: #27ae38;
|
||||||
}
|
}
|
||||||
|
.block-user-section {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.blocked-status {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.unblocked-status {
|
||||||
|
color: #388e3c;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2039,6 +2039,11 @@ p-try@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
|
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
|
||||||
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
|
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
|
||||||
|
|
||||||
|
papaparse@^5.5.3:
|
||||||
|
version "5.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a"
|
||||||
|
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==
|
||||||
|
|
||||||
parent-module@^1.0.0:
|
parent-module@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||||
|
|||||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"papaparse": "^5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
yarn.lock
Normal file
8
yarn.lock
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
papaparse@^5.5.3:
|
||||||
|
version "5.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a"
|
||||||
|
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==
|
||||||
Reference in New Issue
Block a user