Files
mercado.clube67.com/backend/controllers/equipeController.js
T

529 lines
22 KiB
JavaScript

'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,
}