feat: SSE para chat desktop, rate limiting, retry, testes e atualizacao de stack

- Implementa Server-Sent Events (SSE) para chat no desktop com fallback HTTP
- Adiciona rate limiting nas APIs de chat (poll, messages, sessions)
- Adiciona retry com backoff exponencial para mutations
- Cria testes para modulo liveChat (20 testes)
- Corrige testes de SMTP (unit tests para extractEnvelopeAddress)
- Adiciona indice by_status_lastActivity para cron de sessoes inativas
- Atualiza stack: Bun 1.3.4, React 19, recharts 3, noble/hashes 2, etc

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 16:29:18 -03:00
parent 0e0bd9a49c
commit d01c37522f
19 changed files with 1465 additions and 443 deletions

View file

@ -36,7 +36,18 @@ export function createCorsPreflight(origin: string | null, methods = "POST, OPTI
return applyCorsHeaders(response, origin, methods)
}
export function jsonWithCors<T>(data: T, init: number | ResponseInit, origin: string | null, methods = "POST, OPTIONS") {
export function jsonWithCors<T>(
data: T,
init: number | ResponseInit,
origin: string | null,
methods = "POST, OPTIONS",
extraHeaders?: Record<string, string>
) {
const response = NextResponse.json(data, typeof init === "number" ? { status: init } : init)
if (extraHeaders) {
for (const [key, value] of Object.entries(extraHeaders)) {
response.headers.set(key, value)
}
}
return applyCorsHeaders(response, origin, methods)
}

105
src/server/rate-limit.ts Normal file
View file

@ -0,0 +1,105 @@
/**
* Rate Limiting simples em memoria para APIs de maquina.
* Adequado para VPS single-node. Para escalar horizontalmente,
* considerar usar Redis ou outro store distribuido.
*/
type RateLimitEntry = {
count: number
resetAt: number
}
// Store em memoria - limpo automaticamente
const store = new Map<string, RateLimitEntry>()
export type RateLimitResult = {
allowed: boolean
remaining: number
resetAt: number
retryAfterMs: number
}
/**
* Verifica se uma requisicao deve ser permitida baseado no rate limit.
*
* @param key - Identificador unico (ex: `chat-poll:${token}`)
* @param maxRequests - Numero maximo de requisicoes permitidas na janela
* @param windowMs - Tamanho da janela em milissegundos
* @returns Resultado com status e informacoes de limite
*/
export function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number
): RateLimitResult {
const now = Date.now()
const entry = store.get(key)
// Se nao existe entrada ou expirou, criar nova
if (!entry || entry.resetAt <= now) {
const resetAt = now + windowMs
store.set(key, { count: 1, resetAt })
return {
allowed: true,
remaining: maxRequests - 1,
resetAt,
retryAfterMs: 0,
}
}
// Se atingiu o limite
if (entry.count >= maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
retryAfterMs: entry.resetAt - now,
}
}
// Incrementar contador
entry.count++
return {
allowed: true,
remaining: maxRequests - entry.count,
resetAt: entry.resetAt,
retryAfterMs: 0,
}
}
/**
* Limites pre-definidos para APIs de maquina
*/
export const RATE_LIMITS = {
// Polling: 60 req/min (permite polling a cada 1s)
CHAT_POLL: { maxRequests: 60, windowMs: 60_000 },
// Mensagens: 30 req/min
CHAT_MESSAGES: { maxRequests: 30, windowMs: 60_000 },
// Sessoes: 30 req/min
CHAT_SESSIONS: { maxRequests: 30, windowMs: 60_000 },
// Upload: 10 req/min
CHAT_UPLOAD: { maxRequests: 10, windowMs: 60_000 },
} as const
/**
* Gera headers de rate limit para a resposta HTTP
*/
export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
return {
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1000)),
...(result.allowed ? {} : { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
}
}
// Limpar entradas expiradas a cada 60 segundos
if (typeof setInterval !== "undefined") {
setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
if (entry.resetAt <= now) {
store.delete(key)
}
}
}, 60_000)
}

84
src/server/retry.ts Normal file
View file

@ -0,0 +1,84 @@
/**
* Retry com backoff exponencial para operacoes transientes.
* Util para mutations do Convex que podem falhar temporariamente.
*/
export type RetryOptions = {
/** Numero maximo de tentativas (default: 3) */
maxRetries?: number
/** Delay base em ms (default: 100) */
baseDelayMs?: number
/** Delay maximo em ms (default: 2000) */
maxDelayMs?: number
/** Funcao para determinar se erro e retryable (default: true para todos exceto validacao) */
isRetryable?: (error: unknown) => boolean
}
const DEFAULT_OPTIONS: Required<RetryOptions> = {
maxRetries: 3,
baseDelayMs: 100,
maxDelayMs: 2000,
isRetryable: (error: unknown) => {
// Nao retry em erros de validacao
if (error instanceof Error) {
const msg = error.message.toLowerCase()
if (
msg.includes("invalido") ||
msg.includes("invalid") ||
msg.includes("not found") ||
msg.includes("unauthorized") ||
msg.includes("forbidden")
) {
return false
}
}
return true
},
}
/**
* Executa uma funcao com retry e backoff exponencial.
*
* @example
* ```ts
* const result = await withRetry(
* () => client.mutation(api.liveChat.postMachineMessage, { ... }),
* { maxRetries: 3, baseDelayMs: 100 }
* )
* ```
*/
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const opts = { ...DEFAULT_OPTIONS, ...options }
let lastError: unknown
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
// Nao retry se erro nao for retryable
if (!opts.isRetryable(error)) {
throw error
}
// Ultima tentativa - nao esperar, apenas lancar
if (attempt >= opts.maxRetries) {
break
}
// Calcular delay com backoff exponencial + jitter
const exponentialDelay = opts.baseDelayMs * Math.pow(2, attempt)
const jitter = Math.random() * opts.baseDelayMs
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs)
await sleep(delay)
}
}
throw lastError
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}