chore(ops): restore missing root files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-18 03:27:25 +02:00
parent 5ec6bd6354
commit 0dc5eefa06
406 changed files with 237013 additions and 0 deletions
@@ -0,0 +1,123 @@
/**
* authStore.ts — Estado de autenticação global via Zustand.
* O AuthContext (Next.js) permanece como provider de redirect guard.
* Este store é usado pelos demais stores (chatStore, etc.) sem hook drilling.
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { authApi } from '../services/chatApiService'
import { socketService } from '../services/socketService'
export interface AuthUser {
id: string
email: string
name: string | null
role: 'USER' | 'ADMIN'
planId: string | null
daysRemaining: number | null
}
interface AuthState {
user: AuthUser | null
token: string | null
refreshToken: string | null
isHydrated: boolean
// Actions
setAuth: (token: string, refreshToken: string, user: AuthUser) => void
clearAuth: () => void
refreshSession: () => Promise<boolean>
fetchMe: () => Promise<void>
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
refreshToken: null,
isHydrated: false,
setAuth: (token, refreshToken, user) => {
localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken)
if (typeof window !== 'undefined') {
document.cookie = `accessToken=${token}; path=/; SameSite=Lax; Secure`
}
socketService.connect(token)
set({ token, refreshToken, user })
},
clearAuth: () => {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
if (typeof window !== 'undefined') {
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
}
socketService.disconnect()
set({ token: null, refreshToken: null, user: null })
},
refreshSession: async () => {
const { refreshToken } = get()
if (!refreshToken) return false
try {
const { data } = await import('axios').then(({ default: axios }) =>
axios.post(
`${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8008'}/api/auth/refresh`,
{ refreshToken }
)
)
get().setAuth(data.accessToken, data.refreshToken, {
id: data.user.id,
email: data.user.email,
name: data.user.name ?? null,
role: data.user.role as 'USER' | 'ADMIN',
planId: data.user.planId ?? null,
daysRemaining: data.user.daysRemaining ?? null,
})
return true
} catch {
get().clearAuth()
return false
}
},
fetchMe: async () => {
try {
const me = await authApi.me()
if (me?.id) {
set({
user: {
id: me.id,
email: me.email,
name: me.name ?? null,
role: (me.role ?? 'USER') as 'USER' | 'ADMIN',
planId: me.planId ?? null,
daysRemaining: me.daysRemaining ?? null,
},
})
}
} catch {
// token inválido — clearAuth será chamado pelo interceptor 401
}
},
}),
{
name: 'newwhats:auth',
partialize: (s) => ({ token: s.token, refreshToken: s.refreshToken, user: s.user }),
onRehydrateStorage: () => (state) => {
if (state) {
state.isHydrated = true
// Reconecta socket ao restaurar sessão do localStorage
if (state.token) {
socketService.connect(state.token)
if (typeof window !== 'undefined') {
document.cookie = `accessToken=${state.token}; path=/; SameSite=Lax; Secure`
}
}
}
},
}
)
)
@@ -0,0 +1,443 @@
/**
* chatStore.ts — Store de Inbox: chats e mensagens.
* Instâncias foram extraídas para instanceStore.ts.
* Usa Zustand + Immer para mutações diretas e legíveis.
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { socketService } from '../services/socketService'
import { chatApi, messageApi } from '../services/chatApiService'
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
function mapBackendChat(raw: any): Chat {
return {
id: raw.id,
tenantId: raw.tenantId ?? '',
instanceId: raw.instanceId,
jid: raw.jid,
name: raw.name ?? null,
unreadCount: raw.unreadCount ?? 0,
lastMessageAt: raw.lastMessageAt ?? null,
createdAt: raw.createdAt ?? null,
isPinned: raw.isPinned ?? false,
isArchived: raw.isArchived ?? false,
contactName: raw.contact?.name ?? raw.contactName ?? null,
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false,
lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null,
lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null,
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
botPaused: raw.botPaused ?? false,
}
}
// ─── Tipos ────────────────────────────────────────────────────────────────────
export interface Chat {
id: string
tenantId: string
instanceId: string
jid: string
name: string | null // subject do grupo; null para individuais
unreadCount: number
lastMessageAt: string | null
createdAt: string | null // quando o chat foi registrado (fallback de ordenação)
isPinned: boolean
isArchived: boolean
contactName: string | null
contactAvatarUrl: string | null
contactAvatarVersion: string | null
contactPhone: string | null
scoreReputacao: number
flagRestricao: boolean
lastMessageBody: string | null
lastMessageFromMe: boolean
lastMessageStatus: string | null
lastMessageType: string | null
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
protocolNumber: string | null
botPaused: boolean
}
export interface Message {
id: string
chatId: string
messageId: string
fromMe: boolean
type: string
body: string | null
mediaUrl: string | null
mimeType: string | null
fileName: string | null
replyToId: string | null
replyTo?: {
id: string
messageId: string
body: string | null
fromMe: boolean
type: string
mediaUrl: string | null
} | null
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
timestamp: string
pushName?: string | null
senderJid?: string | null
}
// ─── Estado ───────────────────────────────────────────────────────────────────
interface ChatState {
chats: Chat[]
chatsLoading: boolean
activeChatId: string | null
messages: Record<string, Message[]>
messagesLoading: boolean
// Sync
setChats: (chats: Chat[]) => void
setChatsLoading: (v: boolean) => void
upsertChat: (chat: Chat) => void
patchChat: (chatId: string, patch: Partial<Chat>) => void
setActiveChat: (chatId: string | null) => void
clearUnread: (chatId: string) => void
setMessages: (chatId: string, msgs: Message[]) => void
setMessagesLoading: (v: boolean) => void
appendMessage: (msg: Message) => void
patchMessage: (msgId: string, patch: Partial<Message>) => void
updateMediaReady: (msgId: string, mediaUrl: string) => void
// Async
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
selectChat: (chatId: string) => Promise<void>
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
// Socket
initSocketListeners: () => void
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function chatSortKey(c: Chat): number {
// WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat
if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime()
if (c.createdAt) return new Date(c.createdAt).getTime()
return 0
}
function sortChats(chats: Chat[]): Chat[] {
return [...chats].sort((a, b) => {
// Fixados sempre primeiro (mesma lógica do WhatsApp oficial)
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
return chatSortKey(b) - chatSortKey(a)
})
}
// ─── Store ────────────────────────────────────────────────────────────────────
// ─── Cache local (localStorage) — UX instantâneo no reload ─────────────────────
// Persiste os primeiros N chats por instância para hidratar a UI antes do fetch.
// Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}`
// TTL implícito: validamos pela data salva e descartamos se > 7 dias.
const INBOX_CACHE_PREFIX = 'inboxCache:v1'
const INBOX_CACHE_LIMIT = 10
const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
interface InboxCacheEntry {
savedAt: number
chats: Chat[]
}
function inboxCacheKey(instanceId: string, archived: boolean): string {
return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}`
}
function readInboxCache(instanceId: string, archived: boolean): Chat[] | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(inboxCacheKey(instanceId, archived))
if (!raw) return null
const parsed = JSON.parse(raw) as InboxCacheEntry
if (!parsed || !Array.isArray(parsed.chats)) return null
if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null
return parsed.chats
} catch { return null }
}
function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void {
if (typeof window === 'undefined') return
try {
const slim = chats.slice(0, INBOX_CACHE_LIMIT)
const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim }
localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry))
} catch { /* quota exceeded — ignora */ }
}
export const useChatStore = create<ChatState>()(
immer((set, get) => ({
chats: [],
chatsLoading: false,
activeChatId: null,
messages: {},
messagesLoading: false,
// ── Chats ─────────────────────────────────────────────────────────────────
setChats: (chats) =>
set((s) => { s.chats = sortChats(chats) }),
setChatsLoading: (v) =>
set((s) => { s.chatsLoading = v }),
upsertChat: (chat) =>
set((s) => {
const idx = s.chats.findIndex((c) => c.id === chat.id)
if (idx !== -1) s.chats[idx] = chat
else s.chats.unshift(chat)
s.chats = sortChats(s.chats as Chat[])
}),
patchChat: (chatId, patch) =>
set((s) => {
const idx = s.chats.findIndex((c) => c.id === chatId)
if (idx !== -1) {
Object.assign(s.chats[idx], patch)
s.chats = sortChats(s.chats as Chat[])
}
}),
setActiveChat: (chatId) =>
set((s) => { s.activeChatId = chatId }),
clearUnread: (chatId) =>
set((s) => {
const c = s.chats.find((c) => c.id === chatId)
if (c) c.unreadCount = 0
}),
// ── Mensagens ─────────────────────────────────────────────────────────────
setMessages: (chatId, msgs) =>
set((s) => { s.messages[chatId] = msgs }),
setMessagesLoading: (v) =>
set((s) => { s.messagesLoading = v }),
appendMessage: (msg) =>
set((s) => {
const list = s.messages[msg.chatId]
if (!list) { s.messages[msg.chatId] = [msg]; return }
const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId)
if (!exists) list.push(msg)
}),
patchMessage: (msgId, patch) =>
set((s) => {
for (const list of Object.values(s.messages)) {
const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId)
if (idx !== -1) { Object.assign(list[idx], patch); break }
}
}),
updateMediaReady: (msgId, mediaUrl) =>
set((s) => {
for (const list of Object.values(s.messages)) {
const msg = list.find((m) => m.id === msgId)
if (msg) { msg.mediaUrl = mediaUrl; break }
}
}),
// ── Async ─────────────────────────────────────────────────────────────────
loadChats: async (instanceId, opts = {}) => {
// Hidratação instantânea: se temos cache local válido para esta instância,
// mostra os 10 chats salvos imediatamente enquanto a request real roda.
// Evita tela em branco ou skeleton de 15s+ em redes lentas.
const archivedFlag = opts.archived ?? false
const hasSearch = !!opts.search
const current = get().chats
if (!hasSearch) {
const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId
if (!sameInstance) {
const cached = readInboxCache(instanceId, archivedFlag)
if (cached && cached.length > 0) {
set((s) => { s.chats = sortChats(cached); s.chatsLoading = true })
} else {
set((s) => { s.chatsLoading = true })
}
} else {
set((s) => { s.chatsLoading = true })
}
} else {
set((s) => { s.chatsLoading = true })
}
try {
const raw = await chatApi.list(instanceId, opts)
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
const sorted = sortChats(data)
set((s) => { s.chats = sorted; s.chatsLoading = false })
if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted)
} catch {
set((s) => { s.chatsLoading = false })
}
},
loadMessages: async (chatId, opts = {}) => {
console.log('[chatStore] loadMessages chamado:', chatId, opts)
set((s) => { s.messagesLoading = true })
try {
const res = await messageApi.list(chatId, opts)
console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res })
const { messages, hasMore } = res
if (!Array.isArray(messages)) {
console.error('[chatStore] messages NÃO é array!', messages)
set((s) => { s.messagesLoading = false })
return { hasMore: false }
}
set((s) => {
if (opts.before) {
const existing = s.messages[chatId] ?? []
const ids = new Set(existing.map((m) => m.id))
s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing]
} else {
s.messages[chatId] = messages
}
s.messagesLoading = false
})
console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length)
return { hasMore }
} catch (err) {
console.error('[chatStore] loadMessages ERRO:', err)
set((s) => { s.messagesLoading = false })
return { hasMore: false }
}
},
selectChat: async (chatId) => {
console.log('[chatStore] selectChat:', chatId)
const prev = get().activeChatId
if (prev && prev !== chatId) socketService.leaveChat(prev)
set((s) => { s.activeChatId = chatId })
socketService.joinChat(chatId)
get().clearUnread(chatId)
chatApi.markRead(chatId).catch(() => {})
await get().loadMessages(chatId)
console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length)
},
sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => {
const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id
if (!chatId) return
// Resolve replyTo a partir das mensagens já no store
const replyToMsg = replyToMessageId
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
: null
const optimistic: Message = {
id: `opt-${Date.now()}`,
chatId,
messageId: `opt-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: text,
mediaUrl: null,
mimeType: null,
fileName: null,
replyToId: replyToMsg?.id ?? null,
replyTo: replyToMsg
? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl }
: null,
status: 'PENDING',
timestamp: new Date().toISOString(),
}
get().appendMessage(optimistic)
try {
const saved = await messageApi.send(instanceId, jid, text, replyToMessageId, mentions)
get().patchMessage(optimistic.id, { id: saved.id, messageId: saved.messageId, status: 'SENT' })
get().patchChat(chatId, {
lastMessageBody: text,
lastMessageFromMe: true,
lastMessageAt: saved.timestamp,
lastMessageStatus: 'SENT',
lastMessageType: 'TEXT',
})
} catch {
get().patchMessage(optimistic.id, { status: 'FAILED' })
}
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
// Capturar `const store = get()` fora criaria uma closure stale.
socketService.on('message:new', (msg: Message) => {
console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe })
get().appendMessage(msg)
const current = get()
const isActive = current.activeChatId === msg.chatId
current.patchChat(msg.chatId, {
lastMessageBody: msg.body,
lastMessageFromMe: msg.fromMe,
lastMessageAt: msg.timestamp,
lastMessageStatus: msg.status,
lastMessageType: msg.type,
lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null),
unreadCount: msg.fromMe || isActive
? 0
: (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1,
})
})
socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => {
get().updateMediaReady(messageId, mediaUrl)
})
socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => {
get().patchMessage(messageId, { status })
})
socketService.on('chat:upsert', (raw: any) => {
get().upsertChat(mapBackendChat(raw))
})
socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => {
get().patchChat(chatId, { botPaused: true })
})
socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => {
set((s) => {
const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid)
if (chat) chat.contactAvatarUrl = avatarUrl
})
})
// Reconciliação de nomes concluída — re-busca lista de chats
socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => {
const chats = get().chats
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
get().loadChats(instanceId)
}
})
// Reconciliação via ContactHandler (pós-sync)
socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => {
const chats = get().chats
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
get().loadChats(instanceId)
}
})
},
}))
)
@@ -0,0 +1,186 @@
/**
* instanceStore.ts — Gerencia instâncias WhatsApp e QR Codes.
* Separado do chatStore para responsabilidade única.
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { instanceApi } from '../services/chatApiService'
import { socketService } from '../services/socketService'
import { notify } from './notificationStore'
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
export interface Instance {
id: string
name: string
status: InstanceStatus
phone: string | null
avatar: string | null
}
interface InstanceState {
instances: Instance[]
activeInstanceId: string | null
qrByInstance: Record<string, string>
loading: boolean
// Sync actions
setInstances: (instances: Instance[]) => void
setActiveInstance: (id: string | null) => void
patchInstance: (id: string, patch: Partial<Instance>) => void
setQr: (instanceId: string, qrBase64: string) => void
clearQr: (instanceId: string) => void
removeInstance: (id: string) => void
// Async actions
loadInstances: () => Promise<void>
connectInstance: (id: string) => Promise<void>
disconnectInstance: (id: string) => Promise<void>
createInstance: (name: string) => Promise<Instance>
deleteInstance: (id: string) => Promise<void>
// Socket
initSocketListeners: () => void
}
export const useInstanceStore = create<InstanceState>()(
immer((set, get) => ({
instances: [],
// Persiste a seleção ativa no localStorage para sobreviver a reloads
activeInstanceId: typeof window !== 'undefined' ? localStorage.getItem('activeInstanceId') : null,
qrByInstance: {},
loading: false,
// ── Sync ─────────────────────────────────────────────────────────────────
setInstances: (instances) =>
set((s) => { s.instances = instances }),
setActiveInstance: (id) => {
if (typeof window !== 'undefined') {
if (id) localStorage.setItem('activeInstanceId', id)
else localStorage.removeItem('activeInstanceId')
}
set((s) => { s.activeInstanceId = id })
},
patchInstance: (id, patch) =>
set((s) => {
const idx = s.instances.findIndex((i) => i.id === id)
if (idx !== -1) Object.assign(s.instances[idx], patch)
else s.instances.push({ id, name: '', phone: null, status: 'CONNECTING', ...patch } as Instance)
}),
setQr: (instanceId, qrBase64) =>
set((s) => { s.qrByInstance[instanceId] = qrBase64 }),
clearQr: (instanceId) =>
set((s) => { delete s.qrByInstance[instanceId] }),
removeInstance: (id) =>
set((s) => { s.instances = s.instances.filter((i) => i.id !== id) }),
// ── Async ─────────────────────────────────────────────────────────────────
loadInstances: async () => {
set((s) => { s.loading = true })
try {
const data = await instanceApi.list()
const list: Instance[] = (Array.isArray(data) ? data : (data.instances ?? [])).map((i: any) => ({
...i,
avatar: i.avatar ?? null,
}))
set((s) => {
s.loading = false
// Mescla em vez de substituir: preserva status CONNECTED que veio via socket
// (socket events são mais recentes que leituras REST — o banco pode estar
// desatualizado se a resposta da API chegou antes do DB ser gravado pelo backend)
const memMap = new Map(s.instances.map((i) => [i.id, i]))
s.instances = list.map((fromDb) => {
const inMem = memMap.get(fromDb.id)
if (inMem?.status === 'CONNECTED' && fromDb.status !== 'CONNECTED') {
// Socket já confirmou CONNECTED — mantém, não deixa REST stale sobrescrever
return { ...fromDb, status: 'CONNECTED' as InstanceStatus }
}
return fromDb
})
})
} catch {
set((s) => { s.loading = false })
}
},
connectInstance: async (id) => {
get().patchInstance(id, { status: 'CONNECTING' })
socketService.joinInstance(id)
await instanceApi.connect(id)
},
disconnectInstance: async (id) => {
await instanceApi.disconnect(id)
get().patchInstance(id, { status: 'DISCONNECTED' })
get().clearQr(id)
},
createInstance: async (name) => {
const created: Instance = await instanceApi.create(name)
set((s) => { s.instances.push(created) })
return created
},
deleteInstance: async (id) => {
await instanceApi.delete(id)
get().removeInstance(id)
if (get().activeInstanceId === id) {
set((s) => { s.activeInstanceId = null })
}
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
socketService.on('instance:status', ({
instanceId,
status,
qrBase64,
}: {
instanceId: string
status: InstanceStatus
qrBase64?: string
}) => {
get().patchInstance(instanceId, { status })
if (qrBase64) get().setQr(instanceId, qrBase64)
if (status === 'CONNECTED') get().clearQr(instanceId)
})
socketService.on('qr', ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
get().setQr(instanceId, qrBase64)
get().patchInstance(instanceId, { status: 'QR_PENDING' })
})
socketService.on('instance:avatar', ({ instanceId, avatar }: { instanceId: string; avatar: string }) => {
get().patchInstance(instanceId, { avatar })
})
socketService.on('instance:syncing', ({ instanceId, gapMinutes }: { instanceId: string; gapMinutes: number }) => {
const inst = get().instances.find((i) => i.id === instanceId)
const label = inst?.name ?? instanceId.slice(0, 8)
if (gapMinutes >= 60) {
notify(
`📥 ${label}: sincronizando mensagens dos últimos ${Math.round(gapMinutes / 60)}h`,
'warning',
8000,
'syncing',
)
} else if (gapMinutes > 0) {
notify(
`📥 ${label}: buscando mensagens perdidas (${gapMinutes} min offline)`,
'info',
6000,
'syncing',
)
}
})
},
}))
)
@@ -0,0 +1,72 @@
import { create } from 'zustand'
export type NotificationType = 'info' | 'warning' | 'success' | 'error'
export interface Notification {
id: string
tag?: string
message: string
type: NotificationType
durationMs?: number
}
interface NotificationState {
notifications: Notification[]
timers: Record<string, ReturnType<typeof setTimeout>>
add: (msg: string, type?: NotificationType, durationMs?: number, tag?: string) => void
remove: (id: string) => void
}
export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
timers: {},
add(msg, type = 'info', durationMs = 5000, tag) {
const { timers } = get()
// Se tem tag, remove a notificação anterior com a mesma tag (sem animação de saída)
// e cancela o timer dela, para não empilhar
if (tag) {
const existing = get().notifications.find((n) => n.tag === tag)
if (existing) {
if (timers[existing.id]) {
clearTimeout(timers[existing.id])
set((s) => {
const { [existing.id]: _, ...rest } = s.timers
return { timers: rest }
})
}
set((s) => ({ notifications: s.notifications.filter((n) => n.tag !== tag) }))
}
}
const id = tag ?? `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
set((s) => ({ notifications: [...s.notifications, { id, tag, message: msg, type, durationMs }] }))
if (durationMs > 0) {
const timer = setTimeout(() => {
set((s) => ({
notifications: s.notifications.filter((n) => n.id !== id),
timers: Object.fromEntries(Object.entries(s.timers).filter(([k]) => k !== id)),
}))
}, durationMs)
set((s) => ({ timers: { ...s.timers, [id]: timer } }))
}
},
remove(id) {
const { timers } = get()
if (timers[id]) {
clearTimeout(timers[id])
set((s) => {
const { [id]: _, ...rest } = s.timers
return { timers: rest }
})
}
set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) }))
},
}))
/** Atalho para usar fora de componentes React (stores, services, etc.) */
export const notify = (msg: string, type?: NotificationType, durationMs?: number, tag?: string) =>
useNotificationStore.getState().add(msg, type, durationMs, tag)
@@ -0,0 +1,279 @@
import { create } from 'zustand'
import {
secAgentApi, secNodeApi, secConvApi, secCalApi, secNumberApi,
type SecAgent, type BrainNode, type SecConversation, type SecMessage, type CalendarSlot, type SecNumber,
} from '../services/secretariaApi'
interface SecretariaState {
// Data
agents: SecAgent[]
selectedAgent: SecAgent | null
nodes: BrainNode[]
conversations: SecConversation[]
selectedConversation: SecConversation | null
messages: SecMessage[]
calendarSlots: CalendarSlot[]
// Loading flags
loadingAgents: boolean
loadingNodes: boolean
loadingConversations: boolean
loadingMessages: boolean
sendingMessage: boolean
loadingCalendar: boolean
// ── Agents ────────────────────────────────────────────────────────────────
loadAgents: () => Promise<void>
selectAgent: (a: SecAgent | null) => void
createAgent: (d: Partial<SecAgent>) => Promise<SecAgent>
updateAgent: (id: string, d: Partial<SecAgent>) => Promise<void>
deleteAgent: (id: string) => Promise<void>
// ── Brain Nodes ───────────────────────────────────────────────────────────
loadNodes: (agentId: string) => Promise<void>
createNode: (agentId: string, d: Partial<BrainNode>) => Promise<void>
updateNode: (id: string, d: Partial<BrainNode>) => Promise<void>
deleteNode: (id: string) => Promise<void>
// ── Conversations ─────────────────────────────────────────────────────────
loadConversations: (agentId?: string) => Promise<void>
selectConversation: (c: SecConversation | null) => void
createConversation: (agentId: string, contactName: string) => Promise<SecConversation>
deleteConversation: (id: string) => Promise<void>
updateConversationStatus: (id: string, status: SecConversation['status']) => Promise<void>
// ── Messages ──────────────────────────────────────────────────────────────
loadMessages: (convId: string) => Promise<void>
sendMessage: (convId: string, text: string) => Promise<void>
// ── Calendar ──────────────────────────────────────────────────────────────
loadCalendar: (params?: { from?: string; to?: string; status?: string }) => Promise<void>
createCalendarSlot: (d: Partial<CalendarSlot>) => Promise<void>
updateCalendarSlot: (id: string, d: Partial<CalendarSlot>) => Promise<void>
deleteCalendarSlot: (id: string) => Promise<void>
// ── Numbers ───────────────────────────────────────────────────────────────
numbers: SecNumber[]
loadingNumbers: boolean
loadNumbers: () => Promise<void>
createNumber: (d: Partial<SecNumber>) => Promise<void>
updateNumber: (id: string, d: Partial<SecNumber>) => Promise<void>
deleteNumber: (id: string) => Promise<void>
}
export const useSecretariaStore = create<SecretariaState>((set, get) => ({
agents: [], selectedAgent: null, nodes: [],
conversations: [], selectedConversation: null, messages: [],
calendarSlots: [], numbers: [],
loadingAgents: false, loadingNodes: false, loadingConversations: false,
loadingMessages: false, sendingMessage: false, loadingCalendar: false, loadingNumbers: false,
// ── Agents ────────────────────────────────────────────────────────────────
loadAgents: async () => {
set({ loadingAgents: true })
try {
const agents = await secAgentApi.list()
set({ agents })
// Auto-seleciona o primeiro agente se nenhum selecionado
if (!get().selectedAgent && agents.length > 0) {
get().selectAgent(agents[0])
}
} finally {
set({ loadingAgents: false })
}
},
selectAgent: (a) => {
set({ selectedAgent: a, nodes: [], conversations: [], selectedConversation: null, messages: [] })
if (a) {
get().loadNodes(a.id)
get().loadConversations(a.id)
}
},
createAgent: async (d) => {
const agent = await secAgentApi.create(d)
set((s) => ({ agents: [...s.agents, agent] }))
return agent
},
updateAgent: async (id, d) => {
const updated = await secAgentApi.update(id, d)
set((s) => ({
agents: s.agents.map((a) => (a.id === id ? updated : a)),
selectedAgent: s.selectedAgent?.id === id ? updated : s.selectedAgent,
}))
},
deleteAgent: async (id) => {
await secAgentApi.delete(id)
set((s) => ({
agents: s.agents.filter((a) => a.id !== id),
selectedAgent: s.selectedAgent?.id === id ? null : s.selectedAgent,
}))
},
// ── Brain Nodes ───────────────────────────────────────────────────────────
loadNodes: async (agentId) => {
set({ loadingNodes: true })
try {
const nodes = await secNodeApi.list(agentId)
set({ nodes })
} finally {
set({ loadingNodes: false })
}
},
createNode: async (agentId, d) => {
const node = await secNodeApi.create(agentId, d)
set((s) => ({ nodes: [...s.nodes, node] }))
},
updateNode: async (id, d) => {
const updated = await secNodeApi.update(id, d)
set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? updated : n)) }))
},
deleteNode: async (id) => {
await secNodeApi.delete(id)
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
},
// ── Conversations ─────────────────────────────────────────────────────────
loadConversations: async (agentId) => {
set({ loadingConversations: true })
try {
const conversations = await secConvApi.list(agentId)
set({ conversations })
} finally {
set({ loadingConversations: false })
}
},
selectConversation: (c) => {
set({ selectedConversation: c, messages: [] })
if (c) get().loadMessages(c.id)
},
createConversation: async (agentId, contactName) => {
const conv = await secConvApi.create(agentId, contactName)
set((s) => ({ conversations: [conv, ...s.conversations] }))
return conv
},
deleteConversation: async (id) => {
await secConvApi.delete(id)
set((s) => ({
conversations: s.conversations.filter((c) => c.id !== id),
selectedConversation: s.selectedConversation?.id === id ? null : s.selectedConversation,
}))
},
updateConversationStatus: async (id, status) => {
const updated = await secConvApi.patch(id, { status })
set((s) => ({
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
selectedConversation: s.selectedConversation?.id === id ? updated : s.selectedConversation,
}))
},
// ── Messages ──────────────────────────────────────────────────────────────
loadMessages: async (convId) => {
set({ loadingMessages: true })
try {
const messages = await secConvApi.messages(convId)
set({ messages })
} finally {
set({ loadingMessages: false })
}
},
sendMessage: async (convId, text) => {
// Adiciona mensagem do usuário imediatamente (optimistic)
const tempId = `temp-${Date.now()}`
const userMsg: SecMessage = {
id: tempId, conversation_id: convId, role: 'user', content: text, created_at: new Date().toISOString(),
}
set((s) => ({ messages: [...s.messages, userMsg], sendingMessage: true }))
try {
const { reply } = await secConvApi.chat(convId, text)
const aiMsg: SecMessage = {
id: `ai-${Date.now()}`, conversation_id: convId, role: 'assistant', content: reply,
created_at: new Date().toISOString(),
}
set((s) => ({ messages: [...s.messages, aiMsg] }))
// Atualiza updated_at da conversa na lista
set((s) => ({
conversations: s.conversations.map((c) =>
c.id === convId ? { ...c, updated_at: new Date().toISOString() } : c,
),
}))
} finally {
set({ sendingMessage: false })
}
},
// ── Calendar ──────────────────────────────────────────────────────────────
loadCalendar: async (params) => {
set({ loadingCalendar: true })
try {
const calendarSlots = await secCalApi.list(params)
set({ calendarSlots })
} finally {
set({ loadingCalendar: false })
}
},
createCalendarSlot: async (d) => {
const slot = await secCalApi.create(d)
set((s) => ({ calendarSlots: [...s.calendarSlots, slot].sort((a, b) => a.date.localeCompare(b.date)) }))
},
updateCalendarSlot: async (id, d) => {
const updated = await secCalApi.update(id, d)
set((s) => ({ calendarSlots: s.calendarSlots.map((sl) => (sl.id === id ? updated : sl)) }))
},
deleteCalendarSlot: async (id) => {
await secCalApi.delete(id)
set((s) => ({ calendarSlots: s.calendarSlots.filter((sl) => sl.id !== id) }))
},
// ── Numbers ───────────────────────────────────────────────────────────────
loadNumbers: async () => {
set({ loadingNumbers: true })
try {
const numbers = await secNumberApi.list()
set({ numbers })
} finally {
set({ loadingNumbers: false })
}
},
createNumber: async (d) => {
const num = await secNumberApi.assign(d)
set((s) => ({
// upsert: substitui se já existia o mesmo instance_id
numbers: [...s.numbers.filter((n) => n.instance_id !== num.instance_id), num]
.sort((a, b) => a.priority - b.priority),
}))
},
updateNumber: async (id, d) => {
const updated = await secNumberApi.update(id, d)
set((s) => ({ numbers: s.numbers.map((n) => (n.id === id ? updated : n)).sort((a, b) => a.priority - b.priority) }))
},
deleteNumber: async (id) => {
await secNumberApi.delete(id)
set((s) => ({ numbers: s.numbers.filter((n) => n.id !== id) }))
},
}))
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface UIState {
isSidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (value: boolean) => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
isSidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })),
setSidebarCollapsed: (value: boolean) => set({ isSidebarCollapsed: value }),
}),
{
name: 'ui-storage',
storage: createJSONStorage(() => localStorage),
}
)
);