Files
clube67_newwhats.local/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts
T
VPS 4 Deploy Agent 0dc5eefa06
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore missing root files in newwhats.clube67.com
2026-05-18 03:27:25 +02:00

172 lines
5.8 KiB
TypeScript

import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { normalizeJid } from '../../shared/utils/whatsapp'
import { invalidateChatListCache } from '../chats/chat.cache'
export function buildContactRoutes(): Router {
const router = Router()
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
router.get('/stats/overview', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.query['instanceId'] as string | undefined
const where: Record<string, unknown> = { tenantId }
if (instanceId) where['instanceId'] = instanceId
const [total, blocked, flagged] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.count({ where: { ...where, isBlocked: true } }),
prisma.contact.count({ where: { ...where, flagRestricao: true } }),
])
res.json({ total, blocked, flagged, active: total - blocked })
})
// ── GET /api/contacts — lista paginada ────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.query['instanceId'] as string | undefined
const search = req.query['search'] as string | undefined
const blocked = req.query['blocked'] as string | undefined
const limit = Math.min(Number(req.query['limit'] ?? 50), 200)
const offset = Number(req.query['offset'] ?? 0)
const where: Record<string, unknown> = { tenantId }
if (instanceId) where['instanceId'] = instanceId
if (blocked === 'true') where['isBlocked'] = true
if (blocked === 'false') where['isBlocked'] = false
if (search) {
where['OR'] = [
{ name: { contains: search, mode: 'insensitive' } },
{ verifiedName: { contains: search, mode: 'insensitive' } },
{ notify: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search } },
{ jid: { contains: search } },
]
}
const [total, contacts] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
// Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo
// updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET
// devolve linhas sobrepostas entre páginas — quebra "carregar mais".
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
take: limit,
skip: offset,
select: {
id: true,
instanceId: true,
jid: true,
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
isBlocked: true,
createdAt: true,
updatedAt: true,
},
}),
])
res.json({
total,
contacts: contacts.map(c => ({
...c,
displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : normalizeJid(c.jid).split('@')[0])
}))
})
})
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const contact = await prisma.contact.findFirst({
where: { id, tenantId },
include: {
chats: {
orderBy: { lastMessageAt: 'desc' },
take: 1,
select: { id: true, unreadCount: true, lastMessageAt: true },
},
},
})
if (!contact) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
res.json(contact)
})
// ── PATCH /api/contacts/:id — editar nome / bloquear ─────────────────────
router.patch('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const schema = z.object({
name: z.string().min(1).max(255).optional(),
isBlocked: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try {
body = schema.parse(req.body)
} catch (err: any) {
res.status(400).json({ error: err.errors ?? err.message })
return
}
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
const updated = await prisma.contact.update({ where: { id }, data: body })
if (body.name && existing.instanceId) {
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
}
res.json(updated)
})
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
// Desvincular chats antes de deletar o contato
await prisma.chat.updateMany({
where: { contactId: id },
data: { contactId: null },
})
await prisma.contact.delete({ where: { id } })
if (existing.instanceId) {
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
}
res.status(204).send()
})
return router
}