'use strict' /** * equipeController.js — CRUD para Equipe Virtual (Fase 12) * * Recursos: * accounts — colaboradores internos * sectors — setores operacionais * team_members — vínculo conta ↔ setor * sector_brains — cérebro de conhecimento por setor * inbox_interna — mensagens internas (Human API, escalações, colaboração) */ const bcrypt = require('bcryptjs') const { query, queryOne, execute } = require('../database/postgres') // ─── helpers ─────────────────────────────────────────────────────────────── function tid(req) { return req.tenantId || 1 } function ok(res, data) { return res.json(data) } function err(res, status, msg) { return res.status(status).json({ error: msg }) } function serverErr(res, e, tag) { console.error(`[equipe] ${tag}:`, e) return res.status(500).json({ error: 'Erro interno' }) } // ─── ACCOUNTS ─────────────────────────────────────────────────────────────── async function listAccounts(req, res) { try { const { role, active } = req.query let sql = ` SELECT a.id, a.name, a.email, a.role, a.phone, a.phone_connected, a.availability, a.avatar_url, a.active, a.created_at, a.nw_team_id, a.nw_sector_id, COALESCE( json_agg( json_build_object( 'sector_id', tm.sector_id, 'sector_name', s.name, 'function', tm.function, 'is_primary', tm.is_primary, 'on_duty', tm.on_duty ) ) FILTER (WHERE tm.id IS NOT NULL), '[]' ) AS sectors FROM accounts a LEFT JOIN team_members tm ON tm.account_id = a.id LEFT JOIN sectors s ON s.id = tm.sector_id WHERE a.tenant_id = $1 ` const params = [tid(req)] if (role) { params.push(role); sql += ` AND a.role = $${params.length}` } if (active !== undefined) { params.push(active === 'true'); sql += ` AND a.active = $${params.length}` } sql += ` GROUP BY a.id ORDER BY a.name` const rows = await query(sql, params) ok(res, rows) } catch (e) { serverErr(res, e, 'listAccounts') } } async function getAccount(req, res) { try { const row = await queryOne( `SELECT id, name, email, role, phone, phone_connected, availability, avatar_url, active, created_at, nw_team_id, nw_sector_id FROM accounts WHERE id = $1 AND tenant_id = $2`, [req.params.id, tid(req)] ) if (!row) return err(res, 404, 'Conta não encontrada') ok(res, row) } catch (e) { serverErr(res, e, 'getAccount') } } async function createAccount(req, res) { try { const { name, email, password, role, phone, avatar_url } = req.body if (!name) return err(res, 400, 'name obrigatório') const validRoles = ['owner','manager','supervisor','secretary','agent','viewer'] if (role && !validRoles.includes(role)) return err(res, 400, 'role inválido') let hash = null if (password) hash = await bcrypt.hash(password, 10) const row = await queryOne( `INSERT INTO accounts (tenant_id, name, email, password_hash, role, phone, avatar_url) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, name, email, role, phone, availability, active`, [tid(req), name, email||null, hash, role||'agent', phone||null, avatar_url||null] ) ok(res, row) } catch (e) { if (e.code === '23505') return err(res, 409, 'E-mail já cadastrado neste tenant') serverErr(res, e, 'createAccount') } } async function updateAccount(req, res) { try { const { name, email, password, role, phone, availability, avatar_url, active, phone_connected, nw_team_id, nw_sector_id } = req.body const id = req.params.id const existing = await queryOne('SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2', [id, tid(req)]) if (!existing) return err(res, 404, 'Conta não encontrada') const sets = [] const params = [] if (name !== undefined) { params.push(name); sets.push(`name=$${params.length}`) } if (email !== undefined) { params.push(email); sets.push(`email=$${params.length}`) } if (role !== undefined) { params.push(role); sets.push(`role=$${params.length}`) } if (phone !== undefined) { params.push(phone); sets.push(`phone=$${params.length}`) } if (availability !== undefined) { params.push(availability); sets.push(`availability=$${params.length}`) } if (avatar_url !== undefined) { params.push(avatar_url); sets.push(`avatar_url=$${params.length}`) } if (active !== undefined) { params.push(active); sets.push(`active=$${params.length}`) } if (phone_connected !== undefined) { params.push(phone_connected); sets.push(`phone_connected=$${params.length}`) } if (nw_team_id !== undefined) { params.push(nw_team_id); sets.push(`nw_team_id=$${params.length}`) } if (nw_sector_id !== undefined) { params.push(nw_sector_id); sets.push(`nw_sector_id=$${params.length}`) } if (password) { const hash = await bcrypt.hash(password, 10) params.push(hash); sets.push(`password_hash=$${params.length}`) } if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar') params.push(id, tid(req)) const row = await queryOne( `UPDATE accounts SET ${sets.join(', ')} WHERE id=$${params.length-1} AND tenant_id=$${params.length} RETURNING id, name, email, role, phone, phone_connected, availability, active`, params ) ok(res, row) } catch (e) { if (e.code === '23505') return err(res, 409, 'E-mail já cadastrado neste tenant') serverErr(res, e, 'updateAccount') } } async function deleteAccount(req, res) { try { const result = await execute( 'DELETE FROM accounts WHERE id=$1 AND tenant_id=$2', [req.params.id, tid(req)] ) if (result.rowCount === 0) return err(res, 404, 'Conta não encontrada') ok(res, { deleted: true }) } catch (e) { serverErr(res, e, 'deleteAccount') } } // ─── SECTORS ──────────────────────────────────────────────────────────────── async function listSectors(req, res) { try { const rows = await query( `SELECT s.id, s.name, s.description, s.icon, s.color, s.sla_minutes, s.sort_order, s.active, s.created_at, COUNT(tm.id)::int AS member_count FROM sectors s LEFT JOIN team_members tm ON tm.sector_id = s.id WHERE s.tenant_id = $1 GROUP BY s.id ORDER BY s.sort_order, s.name`, [tid(req)] ) ok(res, rows) } catch (e) { serverErr(res, e, 'listSectors') } } async function getSector(req, res) { try { const sector = await queryOne( 'SELECT * FROM sectors WHERE id=$1 AND tenant_id=$2', [req.params.id, tid(req)] ) if (!sector) return err(res, 404, 'Setor não encontrado') const members = await query( `SELECT a.id, a.name, a.role, a.availability, a.phone_connected, tm.function, tm.is_primary, tm.on_duty, tm.notify_escalation, tm.notify_human_api FROM team_members tm JOIN accounts a ON a.id = tm.account_id WHERE tm.sector_id = $1`, [req.params.id] ) ok(res, { ...sector, members }) } catch (e) { serverErr(res, e, 'getSector') } } async function createSector(req, res) { try { const { name, description, icon, color, sla_minutes, sort_order } = req.body if (!name) return err(res, 400, 'name obrigatório') const row = await queryOne( `INSERT INTO sectors (tenant_id, name, description, icon, color, sla_minutes, sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [tid(req), name, description||null, icon||'Briefcase', color||'#6366f1', sla_minutes||60, sort_order||0] ) ok(res, row) } catch (e) { serverErr(res, e, 'createSector') } } async function updateSector(req, res) { try { const { name, description, icon, color, sla_minutes, sort_order, active } = req.body const id = req.params.id const existing = await queryOne('SELECT id FROM sectors WHERE id=$1 AND tenant_id=$2', [id, tid(req)]) if (!existing) return err(res, 404, 'Setor não encontrado') const sets = []; const params = [] if (name !== undefined) { params.push(name); sets.push(`name=$${params.length}`) } if (description !== undefined) { params.push(description); sets.push(`description=$${params.length}`) } if (icon !== undefined) { params.push(icon); sets.push(`icon=$${params.length}`) } if (color !== undefined) { params.push(color); sets.push(`color=$${params.length}`) } if (sla_minutes !== undefined) { params.push(sla_minutes); sets.push(`sla_minutes=$${params.length}`) } if (sort_order !== undefined) { params.push(sort_order); sets.push(`sort_order=$${params.length}`) } if (active !== undefined) { params.push(active); sets.push(`active=$${params.length}`) } if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar') params.push(id, tid(req)) const row = await queryOne( `UPDATE sectors SET ${sets.join(', ')} WHERE id=$${params.length-1} AND tenant_id=$${params.length} RETURNING *`, params ) ok(res, row) } catch (e) { serverErr(res, e, 'updateSector') } } async function deleteSector(req, res) { try { const result = await execute('DELETE FROM sectors WHERE id=$1 AND tenant_id=$2', [req.params.id, tid(req)]) if (result.rowCount === 0) return err(res, 404, 'Setor não encontrado') ok(res, { deleted: true }) } catch (e) { serverErr(res, e, 'deleteSector') } } // ─── TEAM MEMBERS ──────────────────────────────────────────────────────────── async function listTeamMembers(req, res) { try { const { sector_id } = req.query let sql = ` SELECT tm.id, tm.account_id, tm.sector_id, tm.function, tm.is_primary, tm.on_duty, tm.notify_escalation, tm.notify_human_api, tm.created_at, a.name AS account_name, a.email AS account_email, a.role AS account_role, a.availability, a.phone_connected, s.name AS sector_name, s.color AS sector_color, s.icon AS sector_icon FROM team_members tm JOIN accounts a ON a.id = tm.account_id JOIN sectors s ON s.id = tm.sector_id WHERE a.tenant_id = $1 ` const params = [tid(req)] if (sector_id) { params.push(sector_id); sql += ` AND tm.sector_id = $${params.length}` } sql += ' ORDER BY s.name, a.name' const rows = await query(sql, params) ok(res, rows) } catch (e) { serverErr(res, e, 'listTeamMembers') } } async function addTeamMember(req, res) { try { const { account_id, sector_id, function: fn, is_primary, notify_escalation, notify_human_api } = req.body if (!account_id || !sector_id) return err(res, 400, 'account_id e sector_id obrigatórios') // verifica que a conta pertence ao tenant const acc = await queryOne('SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2', [account_id, tid(req)]) if (!acc) return err(res, 404, 'Conta não encontrada') const sec = await queryOne('SELECT id FROM sectors WHERE id=$1 AND tenant_id=$2', [sector_id, tid(req)]) if (!sec) return err(res, 404, 'Setor não encontrado') const row = await queryOne( `INSERT INTO team_members (account_id, sector_id, function, is_primary, notify_escalation, notify_human_api) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [account_id, sector_id, fn||null, is_primary !== undefined ? is_primary : false, notify_escalation !== undefined ? notify_escalation : true, notify_human_api !== undefined ? notify_human_api : true] ) ok(res, row) } catch (e) { if (e.code === '23505') return err(res, 409, 'Membro já vinculado a este setor') serverErr(res, e, 'addTeamMember') } } async function updateTeamMember(req, res) { try { const { function: fn, is_primary, on_duty, notify_escalation, notify_human_api } = req.body const id = req.params.id const sets = []; const params = [] if (fn !== undefined) { params.push(fn); sets.push(`function=$${params.length}`) } if (is_primary !== undefined) { params.push(is_primary); sets.push(`is_primary=$${params.length}`) } if (on_duty !== undefined) { params.push(on_duty); sets.push(`on_duty=$${params.length}`) } if (notify_escalation !== undefined) { params.push(notify_escalation); sets.push(`notify_escalation=$${params.length}`) } if (notify_human_api !== undefined) { params.push(notify_human_api); sets.push(`notify_human_api=$${params.length}`) } if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar') params.push(id) const row = await queryOne(`UPDATE team_members SET ${sets.join(', ')} WHERE id=$${params.length} RETURNING *`, params) if (!row) return err(res, 404, 'Membro não encontrado') ok(res, row) } catch (e) { serverErr(res, e, 'updateTeamMember') } } async function removeTeamMember(req, res) { try { const result = await execute('DELETE FROM team_members WHERE id=$1', [req.params.id]) if (result.rowCount === 0) return err(res, 404, 'Membro não encontrado') ok(res, { deleted: true }) } catch (e) { serverErr(res, e, 'removeTeamMember') } } // ─── SECTOR BRAIN NODES ───────────────────────────────────────────────────── async function listSectorBrains(req, res) { try { const rows = await query( `SELECT * FROM sector_brain_nodes WHERE sector_id=$1 ORDER BY sort_order, id`, [req.params.sectorId] ) ok(res, rows) } catch (e) { serverErr(res, e, 'listSectorBrains') } } async function createSectorBrain(req, res) { try { const { type, content, sort_order } = req.body if (!content) return err(res, 400, 'content obrigatório') const row = await queryOne( `INSERT INTO sector_brain_nodes (sector_id, type, content, sort_order) VALUES ($1,$2,$3,$4) RETURNING *`, [req.params.sectorId, type||'knowledge', content, sort_order||0] ) ok(res, row) } catch (e) { serverErr(res, e, 'createSectorBrain') } } async function updateSectorBrain(req, res) { try { const { type, content, sort_order, active } = req.body const sets = []; const params = [] if (type !== undefined) { params.push(type); sets.push(`type=$${params.length}`) } if (content !== undefined) { params.push(content); sets.push(`content=$${params.length}`) } if (sort_order !== undefined) { params.push(sort_order); sets.push(`sort_order=$${params.length}`) } if (active !== undefined) { params.push(active); sets.push(`active=$${params.length}`) } if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar') params.push(req.params.id) const row = await queryOne(`UPDATE sector_brain_nodes SET ${sets.join(', ')} WHERE id=$${params.length} RETURNING *`, params) if (!row) return err(res, 404, 'Node não encontrado') ok(res, row) } catch (e) { serverErr(res, e, 'updateSectorBrain') } } async function deleteSectorBrain(req, res) { try { const result = await execute('DELETE FROM sector_brain_nodes WHERE id=$1', [req.params.id]) if (result.rowCount === 0) return err(res, 404, 'Node não encontrado') ok(res, { deleted: true }) } catch (e) { serverErr(res, e, 'deleteSectorBrain') } } // ─── PERFIL DO COLABORADOR LOGADO ──────────────────────────────────────────── async function me(req, res) { try { const accountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.teamAccount?.tenantId || req.tenantId || 1 if (!accountId) return err(res, 401, 'Não autenticado') const row = await queryOne(` SELECT a.id, a.name, a.email, a.role, a.phone, a.phone_connected, a.availability, a.avatar_url, a.active, a.nw_team_id, a.nw_sector_id, COALESCE( json_agg(json_build_object( 'sector_id', s.id, 'sector_name', s.name, 'function', tm.function, 'is_primary', tm.is_primary, 'on_duty', tm.on_duty )) FILTER (WHERE tm.id IS NOT NULL), '[]' ) AS sectors FROM accounts a LEFT JOIN team_members tm ON tm.account_id = a.id LEFT JOIN sectors s ON s.id = tm.sector_id WHERE a.id = $1 AND a.tenant_id = $2 GROUP BY a.id `, [accountId, tenantId]) if (!row) return err(res, 404, 'Conta não encontrada') ok(res, row) } catch (e) { serverErr(res, e, 'me') } } // ─── INBOX INTERNA ─────────────────────────────────────────────────────────── /** Lista threads únicas com última mensagem + contador de não lidas */ async function listThreads(req, res) { try { // Segurança: colaborador só pode ver sua própria inbox. // Admin pode passar account_id na query para inspecionar qualquer conta. const isAdmin = !!req.admin // queriedId pode ser NaN se não for passado — trata como null const queriedId = req.query.account_id ? parseInt(req.query.account_id) : null const ownId = req.teamAccount?.accountId if (!isAdmin && queriedId && queriedId !== ownId) { return err(res, 403, 'Acesso negado: você só pode ver sua própria inbox') } // Colaborador sem account_id na query usa o próprio id do token const account_id = isAdmin ? (queriedId || null) : (queriedId || ownId) if (!account_id) return ok(res, []) const rows = await query(` SELECT DISTINCT ON (im.thread_id) im.thread_id, im.type, im.sector_id, s.name AS sector_name, im.content AS last_message, im.created_at AS last_at, im.ref_id, im.chat_id, COUNT(im2.id) FILTER (WHERE im2.read_at IS NULL AND im2.to_account = $2)::int AS unread FROM internal_messages im LEFT JOIN sectors s ON s.id = im.sector_id LEFT JOIN internal_messages im2 ON im2.thread_id = im.thread_id WHERE im.tenant_id = $1 AND (im.to_account = $2 OR im.sector_id IN ( SELECT sector_id FROM team_members WHERE account_id = $2 )) GROUP BY im.thread_id, im.type, im.sector_id, s.name, im.content, im.created_at, im.ref_id, im.chat_id ORDER BY im.thread_id, im.created_at DESC `, [tid(req), account_id]) ok(res, rows) } catch (e) { serverErr(res, e, 'listThreads') } } /** Lista mensagens de uma thread */ async function getThread(req, res) { try { const rows = await query( `SELECT im.*, a.name AS from_name, a.avatar_url AS from_avatar FROM internal_messages im LEFT JOIN accounts a ON a.id = im.from_account WHERE im.thread_id = $1 AND im.tenant_id = $2 ORDER BY im.created_at`, [req.params.threadId, tid(req)] ) ok(res, rows) } catch (e) { serverErr(res, e, 'getThread') } } /** Envia mensagem em uma thread (ou cria nova thread) */ async function postMessage(req, res) { try { const { thread_id, from_account, to_account, sector_id, type, content, ref_id } = req.body if (!content) return err(res, 400, 'content obrigatório') const threadId = thread_id || `t-${Date.now()}-${Math.random().toString(36).slice(2,8)}` const row = await queryOne( `INSERT INTO internal_messages (tenant_id, thread_id, from_account, to_account, sector_id, type, content, ref_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [tid(req), threadId, from_account||null, to_account||null, sector_id||null, type||'collaboration', content, ref_id||null] ) ok(res, row) } catch (e) { serverErr(res, e, 'postMessage') } } /** Marca mensagens de uma thread como lidas */ async function markRead(req, res) { try { // Colaborador só pode marcar suas próprias mensagens como lidas const isAdmin = !!req.admin const ownId = req.teamAccount?.accountId const bodyId = parseInt(req.body.account_id) const account_id = isAdmin ? bodyId : ownId if (!isAdmin && bodyId && bodyId !== ownId) { return err(res, 403, 'Acesso negado') } if (!account_id) return ok(res, { ok: true }) await execute( `UPDATE internal_messages SET read_at = NOW() WHERE thread_id = $1 AND to_account = $2 AND read_at IS NULL`, [req.params.threadId, account_id] ) ok(res, { ok: true }) } catch (e) { serverErr(res, e, 'markRead') } } /** Conta não lidas de uma conta */ async function unreadCount(req, res) { try { const isAdmin = !!req.admin const queriedId = parseInt(req.query.account_id) const ownId = req.teamAccount?.accountId if (!isAdmin && queriedId && queriedId !== ownId) { return err(res, 403, 'Acesso negado') } const account_id = isAdmin ? queriedId : ownId if (!account_id) return ok(res, { count: 0 }) const row = await queryOne( `SELECT COUNT(*)::int AS count FROM internal_messages WHERE tenant_id=$1 AND to_account=$2 AND read_at IS NULL`, [tid(req), account_id] ) ok(res, row) } catch (e) { serverErr(res, e, 'unreadCount') } } // ─── EXPORTS ───────────────────────────────────────────────────────────────── module.exports = { // perfil do colaborador logado me, // accounts listAccounts, getAccount, createAccount, updateAccount, deleteAccount, // sectors listSectors, getSector, createSector, updateSector, deleteSector, // team members listTeamMembers, addTeamMember, updateTeamMember, removeTeamMember, // sector brains listSectorBrains, createSectorBrain, updateSectorBrain, deleteSectorBrain, // inbox interna listThreads, getThread, postMessage, markRead, unreadCount, }