diff --git a/.gitignore b/.gitignore index a657c64..688a4b9 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ attached_assets/ # Package lock (regenerated on install) package-lock.json +.claude/ diff --git a/client/src/pages/AppCenter.tsx b/client/src/pages/AppCenter.tsx index a6f7e0f..ac8ef8b 100644 --- a/client/src/pages/AppCenter.tsx +++ b/client/src/pages/AppCenter.tsx @@ -1,11 +1,13 @@ import { BrowserFrame } from "@/components/Browser/BrowserFrame"; import { useState } from "react"; import { useLocation } from "wouter"; +import { useQuery } from "@tanstack/react-query"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { + Lock, Search, ShoppingCart, GraduationCap, @@ -53,6 +55,7 @@ interface AppItem { status: "active" | "coming_soon" | "beta"; featured?: boolean; color: string; + subscriptionCode?: string; // marketplace module code — if set, requires active subscription } const apps: AppItem[] = [ @@ -500,6 +503,24 @@ export default function AppCenter() { const [searchTerm, setSearchTerm] = useState(""); const [activeCategory, setActiveCategory] = useState("todos"); + const { data: myAppsData } = useQuery<{ subscribedCodes: string[] }>({ + queryKey: ["/api/marketplace/my-apps"], + staleTime: 60_000, + }); + + const subscribedCodes = new Set(myAppsData?.subscribedCodes || []); + + const isLocked = (app: AppItem) => + !!app.subscriptionCode && subscribedCodes.size > 0 && !subscribedCodes.has(app.subscriptionCode); + + const handleAppClick = (app: AppItem) => { + if (isLocked(app)) { + setLocation("/marketplace"); + } else { + setLocation(app.route); + } + }; + const filteredApps = apps.filter(app => { const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) || app.description.toLowerCase().includes(searchTerm.toLowerCase()); @@ -546,7 +567,7 @@ export default function AppCenter() { setLocation(app.route)} + onClick={() => handleAppClick(app)} data-testid={`featured-app-${app.id}`} > @@ -579,27 +600,37 @@ export default function AppCenter() {
- {filteredApps.map(app => ( + {filteredApps.map(app => { + const locked = isLocked(app); + return ( setLocation(app.route)} + className={`border-white/10 cursor-pointer transition-all group overflow-hidden ${locked ? "bg-white/2 opacity-60 hover:opacity-80" : "bg-white/5 hover:border-white/30"}`} + onClick={() => handleAppClick(app)} data-testid={`app-card-${app.id}`} >
-
+
{app.icon} + {locked && ( +
+ +
+ )}
-

+

{app.name}

- {app.status === "beta" && ( + {locked && ( + Não contratado + )} + {!locked && app.status === "beta" && ( Beta )} - {app.status === "coming_soon" && ( + {!locked && app.status === "coming_soon" && ( Em breve )}
@@ -607,13 +638,20 @@ export default function AppCenter() {
- - Abrir - + {locked ? ( + + Ver no Marketplace + + ) : ( + + Abrir + + )}
- ))} + ); + })}
{filteredApps.length === 0 && ( diff --git a/docker/python-entrypoint.sh b/docker/python-entrypoint.sh index 43f2acc..a9bf876 100644 --- a/docker/python-entrypoint.sh +++ b/docker/python-entrypoint.sh @@ -41,7 +41,10 @@ case "$SERVICE_NAME" in --workers 2 ;; embeddings) - exec python server/python/embeddings_service.py + exec python -m uvicorn server.python.embeddings_service:app \ + --host 0.0.0.0 \ + --port "$SERVICE_PORT" \ + --workers 1 ;; *) echo "[entrypoint] ERRO: SERVICE_NAME desconhecido: $SERVICE_NAME" diff --git a/migrations/0001_whatsapp_auto_reply_config.sql b/migrations/0001_whatsapp_auto_reply_config.sql new file mode 100644 index 0000000..41723b8 --- /dev/null +++ b/migrations/0001_whatsapp_auto_reply_config.sql @@ -0,0 +1 @@ +ALTER TABLE "whatsapp_sessions" ADD COLUMN IF NOT EXISTS "auto_reply_config" jsonb; diff --git a/migrations/0002_tenant_isolation.sql b/migrations/0002_tenant_isolation.sql new file mode 100644 index 0000000..e1c430c --- /dev/null +++ b/migrations/0002_tenant_isolation.sql @@ -0,0 +1,40 @@ +-- Migration 0002: Add tenantId to critical tables missing multi-tenant isolation +-- Tables: workspace_pages, quick_notes, activity_feed, conversations, +-- knowledge_base, chat_threads, manus_runs + +ALTER TABLE "workspace_pages" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +ALTER TABLE "quick_notes" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +ALTER TABLE "activity_feed" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +ALTER TABLE "conversations" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +ALTER TABLE "knowledge_base" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +ALTER TABLE "chat_threads" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +ALTER TABLE "manus_runs" + ADD COLUMN IF NOT EXISTS "tenant_id" integer + REFERENCES "tenants"("id") ON DELETE CASCADE; + +-- Indexes for query performance on tenantId lookups +CREATE INDEX IF NOT EXISTS idx_workspace_pages_tenant ON workspace_pages(tenant_id); +CREATE INDEX IF NOT EXISTS idx_quick_notes_tenant ON quick_notes(tenant_id); +CREATE INDEX IF NOT EXISTS idx_activity_feed_tenant ON activity_feed(tenant_id); +CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON conversations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_tenant ON knowledge_base(tenant_id); +CREATE INDEX IF NOT EXISTS idx_chat_threads_tenant ON chat_threads(tenant_id); +CREATE INDEX IF NOT EXISTS idx_manus_runs_tenant ON manus_runs(tenant_id); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index be75370..e332aca 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1769121114659, "tag": "0000_low_tiger_shark", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1741824000000, + "tag": "0001_whatsapp_auto_reply_config", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1741824060000, + "tag": "0002_tenant_isolation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 43653c5..4779f0c 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "drizzle-zod": "^0.7.1", "embla-carousel-react": "^8.6.0", "express": "^4.21.2", + "express-rate-limit": "^8.3.1", "express-session": "^1.18.1", "file-saver": "^2.0.5", "framer-motion": "^12.23.24", @@ -113,6 +114,7 @@ "tough-cookie": "^6.0.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", + "winston": "^3.19.0", "wouter": "^3.3.5", "ws": "^8.18.0", "xlsx": "^0.18.5", diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/auth.ts b/server/auth.ts index 1e1d7f9..76f2d37 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -30,8 +30,12 @@ async function comparePasswords(supplied: string, stored: string) { return timingSafeEqual(hashedBuf, suppliedBuf); } +if (!process.env.SESSION_SECRET) { + console.warn("[auth] WARNING: SESSION_SECRET env var not set. Using insecure fallback. Set SESSION_SECRET in production."); +} + const sessionSettings: session.SessionOptions = { - secret: process.env.SESSION_SECRET || "arcadia-browser-secret-key-2024", + secret: process.env.SESSION_SECRET || `arcadia-dev-${Math.random().toString(36)}`, resave: false, saveUninitialized: false, store: storage.sessionStore, diff --git a/server/blackboard/PipelineOrchestrator.ts b/server/blackboard/PipelineOrchestrator.ts index 17ee16f..dc46f28 100644 --- a/server/blackboard/PipelineOrchestrator.ts +++ b/server/blackboard/PipelineOrchestrator.ts @@ -58,6 +58,7 @@ const ALL_PHASES: PipelinePhase[] = ["design", "codegen", "validation", "staging class PipelineOrchestrator extends EventEmitter { private activeMonitors: Map = new Map(); + private processingMonitors: Set = new Set(); async createPipeline(prompt: string, userId: string = "system", metadata?: any): Promise { const correlationId = randomUUID(); @@ -169,10 +170,14 @@ class PipelineOrchestrator extends EventEmitter { if (this.activeMonitors.has(pipelineId)) return; const interval = setInterval(async () => { + if (this.processingMonitors.has(pipelineId)) return; + this.processingMonitors.add(pipelineId); try { await this.checkPhaseProgress(pipelineId, mainTaskId); } catch (error) { console.error(`[PipelineOrchestrator] Erro no monitor #${pipelineId}:`, error); + } finally { + this.processingMonitors.delete(pipelineId); } }, 3000); diff --git a/server/compass/routes.ts b/server/compass/routes.ts index 4621424..01cff27 100644 --- a/server/compass/routes.ts +++ b/server/compass/routes.ts @@ -2274,4 +2274,124 @@ router.put("/projects/:projectId/history", async (req: Request, res: Response) = } }); +// ========== AI BRIEFING & HEALTH SCORE ========== + +router.post("/projects/:projectId/ai-brief", async (req: Request, res: Response) => { + try { + const tenantId = await getTenantId(req); + if (!tenantId) return res.status(403).json({ error: "Tenant não encontrado" }); + const projectId = parseInt(req.params.projectId); + const hasAccess = await validateProjectAccess(projectId, tenantId); + if (!hasAccess) return res.status(403).json({ error: "Acesso negado ao projeto" }); + + const project = await compassStorage.getProject(projectId, tenantId); + const tasks = await compassStorage.getTasks(projectId); + const pdcaCycles = await compassStorage.getPdcaCycles(tenantId, projectId); + + const openTasks = tasks.filter((t: any) => t.status !== "done" && t.status !== "completed"); + const overdue = tasks.filter((t: any) => t.dueDate && new Date(t.dueDate) < new Date() && t.status !== "done"); + const pdcaOpen = pdcaCycles.filter((c: any) => c.status === "open" || c.status === "in_progress"); + + const OpenAI = (await import("openai")).default; + const openai = new OpenAI({ + apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY, + baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL, + timeout: 30000, + }); + + const prompt = `Você é um consultor de gestão de projetos. Analise o projeto abaixo e gere um briefing executivo em português. + +PROJETO: ${project?.name} +DESCRIÇÃO: ${project?.description || "Não informada"} +STATUS: ${project?.status} +FASE: ${project?.currentPhase || "Não definida"} + +RESUMO DE TAREFAS: +- Total: ${tasks.length} +- Em aberto: ${openTasks.length} +- Atrasadas: ${overdue.length} + +PDCA ATIVOS: ${pdcaOpen.length} + +${overdue.length > 0 ? `TAREFAS ATRASADAS:\n${overdue.slice(0, 5).map((t: any) => `- ${t.title} (responsável: ${t.assignedTo || "não atribuído"})`).join("\n")}` : ""} + +Gere um briefing com: +1. **Situação atual** (2-3 linhas) +2. **Principais riscos** (lista com até 3 itens) +3. **Ações prioritárias** (lista com até 5 ações, cada uma com responsável sugerido e prazo) +4. **Score de saúde** (0-100 com justificativa) + +Seja direto e objetivo.`; + + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + max_tokens: 800, + temperature: 0.3, + }); + + const briefText = response.choices[0]?.message?.content || ""; + + // Extract health score from response + const scoreMatch = briefText.match(/score[^\d]*(\d{1,3})/i) || briefText.match(/(\d{1,3})[^\d]*\//); + const healthScore = scoreMatch ? Math.min(100, Math.max(0, parseInt(scoreMatch[1]))) : null; + + res.json({ + projectId, + brief: briefText, + healthScore, + meta: { + totalTasks: tasks.length, + openTasks: openTasks.length, + overdueTasks: overdue.length, + activePdca: pdcaOpen.length, + generatedAt: new Date().toISOString(), + }, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + +router.get("/projects/:projectId/health", async (req: Request, res: Response) => { + try { + const tenantId = await getTenantId(req); + if (!tenantId) return res.status(403).json({ error: "Tenant não encontrado" }); + const projectId = parseInt(req.params.projectId); + const hasAccess = await validateProjectAccess(projectId, tenantId); + if (!hasAccess) return res.status(403).json({ error: "Acesso negado ao projeto" }); + + const tasks = await compassStorage.getTasks(projectId); + const pdcaCycles = await compassStorage.getPdcaCycles(tenantId, projectId); + + const total = tasks.length || 1; + const done = tasks.filter((t: any) => t.status === "done" || t.status === "completed").length; + const overdue = tasks.filter((t: any) => t.dueDate && new Date(t.dueDate) < new Date() && t.status !== "done").length; + const pdcaComplete = pdcaCycles.filter((c: any) => c.status === "completed").length; + const pdcaTotal = pdcaCycles.length || 1; + + const completionScore = (done / total) * 40; + const overdueScore = Math.max(0, 30 - (overdue / total) * 30); + const pdcaScore = (pdcaComplete / pdcaTotal) * 30; + const healthScore = Math.round(completionScore + overdueScore + pdcaScore); + + const level = healthScore >= 80 ? "saudável" : healthScore >= 50 ? "atenção" : "crítico"; + const color = healthScore >= 80 ? "green" : healthScore >= 50 ? "yellow" : "red"; + + res.json({ + projectId, + healthScore, + level, + color, + breakdown: { + completion: { score: Math.round(completionScore), weight: "40%", tasks: `${done}/${tasks.length}` }, + timeliness: { score: Math.round(overdueScore), weight: "30%", overdue }, + pdca: { score: Math.round(pdcaScore), weight: "30%", cycles: `${pdcaComplete}/${pdcaCycles.length}` }, + }, + }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } +}); + export default router; diff --git a/server/crm/frappe-service.ts b/server/crm/frappe-service.ts index 11565c4..73e9ab2 100644 --- a/server/crm/frappe-service.ts +++ b/server/crm/frappe-service.ts @@ -36,6 +36,7 @@ export class FrappeService { "Content-Type": "application/json", Accept: "application/json", }, + signal: AbortSignal.timeout(30000), }; if (data && (method === "POST" || method === "PUT")) { diff --git a/server/index.ts b/server/index.ts index eeaa0e2..20654ef 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,4 +1,5 @@ import express, { type Request, Response, NextFunction } from "express"; +import rateLimit from "express-rate-limit"; import { registerRoutes } from "./routes"; import { serveStatic } from "./static"; import { registerAllTools } from "./autonomous/tools"; @@ -6,6 +7,7 @@ import { storage } from "./storage"; import { createServer } from "http"; import { spawn } from "child_process"; import path from "path"; +import { logger, httpLogger } from "./logger"; interface ManagedService { name: string; @@ -296,43 +298,35 @@ app.use( app.use(express.urlencoded({ extended: false })); -export function log(message: string, source = "express") { - const formattedTime = new Date().toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - - console.log(`${formattedTime} [${source}] ${message}`); -} - -app.use((req, res, next) => { - const start = Date.now(); - const path = req.path; - let capturedJsonResponse: Record | undefined = undefined; - - const originalResJson = res.json; - res.json = function (bodyJson, ...args) { - capturedJsonResponse = bodyJson; - return originalResJson.apply(res, [bodyJson, ...args]); - }; - - res.on("finish", () => { - const duration = Date.now() - start; - if (path.startsWith("/api")) { - let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`; - if (capturedJsonResponse) { - logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; - } - - log(logLine); - } - }); - - next(); +// Rate limiting +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 500, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests, please try again later." }, + skip: (req) => !req.path.startsWith("/api"), }); +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many login attempts, please try again later." }, +}); + +app.use("/api", apiLimiter); +app.use("/api/login", authLimiter); +app.use("/api/register", authLimiter); + +// Structured HTTP logging +app.use(httpLogger()); + +export function log(message: string, source = "express") { + logger.info(message, { source }); +} + // Plus proxy is configured in server/plus/proxy.ts via setupPlusProxy // It's registered after session middleware to enable SSO authentication diff --git a/server/lms/routes.ts b/server/lms/routes.ts index 96f2f64..c5344fe 100644 --- a/server/lms/routes.ts +++ b/server/lms/routes.ts @@ -4,6 +4,15 @@ import { sql } from "drizzle-orm"; const router = Router(); +function requireAuth(req: any, res: any, next: any) { + if (!req.isAuthenticated()) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +} + +router.use(requireAuth); + router.get("/courses", async (req: Request, res: Response) => { try { const { category, featured, published } = req.query; diff --git a/server/logger.ts b/server/logger.ts new file mode 100644 index 0000000..ac7eb01 --- /dev/null +++ b/server/logger.ts @@ -0,0 +1,42 @@ +import winston from "winston"; + +const isProduction = process.env.NODE_ENV === "production"; + +export const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || "info", + format: isProduction + ? winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ) + : winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: "HH:mm:ss" }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const extras = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ""; + return `${timestamp} [${level}] ${message}${extras}`; + }) + ), + transports: [new winston.transports.Console()], +}); + +export function httpLogger() { + return (req: any, res: any, next: any) => { + const start = Date.now(); + const { method, path: reqPath } = req; + + res.on("finish", () => { + if (!reqPath.startsWith("/api")) return; + const duration = Date.now() - start; + const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info"; + logger.log(level, `${method} ${reqPath} ${res.statusCode}`, { + duration_ms: duration, + user_id: req.user?.id || null, + tenant_id: req.user?.tenantId || null, + }); + }); + + next(); + }; +} diff --git a/server/manus/service.ts b/server/manus/service.ts index 802b2e5..114f104 100644 --- a/server/manus/service.ts +++ b/server/manus/service.ts @@ -12,6 +12,8 @@ import * as erpnextService from "../erpnext/service"; const openai = new OpenAI({ apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY, baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL, + timeout: 30000, + maxRetries: 3, }); const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, um assistente empresarial inteligente e proativo. @@ -126,7 +128,27 @@ REGRA CRÍTICA PARA RESPOSTA FINAL: - Se analisou um documento, inclua os dados extraídos E sua interpretação`; class ManusService extends EventEmitter { + private pendingApprovals: Map }> = new Map(); + private async executeTool(tool: string, input: Record, userId: string): Promise { + // Dangerous tools require explicit user approval via ask_human first + const DANGEROUS_TOOLS = new Set(["shell", "write_file", "python_execute"]); + if (DANGEROUS_TOOLS.has(tool)) { + const approvalKey = `${userId}:${tool}:${JSON.stringify(input)}`; + if (!this.pendingApprovals.has(approvalKey)) { + this.pendingApprovals.set(approvalKey, { tool, input }); + const preview = tool === "shell" ? input.command + : tool === "write_file" ? `Escrever em: ${input.path}` + : `Executar código Python (${String(input.code || "").substring(0, 80)}...)`; + return { + success: false, + output: `[APROVAÇÃO NECESSÁRIA] Esta ação requer confirmação: ${preview}. Use ask_human para solicitar aprovação antes de prosseguir.`, + error: "requires_approval" + }; + } + this.pendingApprovals.delete(approvalKey); + } + try { switch (tool) { case "web_search": diff --git a/server/marketplace/routes.ts b/server/marketplace/routes.ts index 596e820..6e92a16 100644 --- a/server/marketplace/routes.ts +++ b/server/marketplace/routes.ts @@ -5,6 +5,52 @@ import { eq, desc, and, sql } from "drizzle-orm"; const router = Router(); +function requireAuth(req: any, res: any, next: any) { + if (!req.isAuthenticated()) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +} + +// My apps: returns active subscriptions for the current user's tenant +// Public modules (isCore=true) are always included +router.get("/my-apps", requireAuth, async (req: any, res: Response) => { + try { + const tenantId = req.user?.tenantId; + + // Core modules always available + const coreModules = await db + .select({ code: marketplaceModules.code, route: marketplaceModules.route }) + .from(marketplaceModules) + .where(and(eq(marketplaceModules.isCore, true), eq(marketplaceModules.isActive, true))); + + // Subscribed modules for this tenant + const subscribed = tenantId + ? await db + .select({ code: marketplaceModules.code, route: marketplaceModules.route }) + .from(moduleSubscriptions) + .innerJoin(marketplaceModules, eq(moduleSubscriptions.moduleId, marketplaceModules.id)) + .where( + and( + eq(moduleSubscriptions.tenantId, tenantId), + eq(moduleSubscriptions.status, "active"), + eq(marketplaceModules.isActive, true) + ) + ) + : []; + + const allCodes = new Set([ + ...coreModules.map((m) => m.code), + ...subscribed.map((m) => m.code), + ]); + + res.json({ subscribedCodes: [...allCodes], tenantId: tenantId || null }); + } catch (error) { + console.error("Error fetching my-apps:", error); + res.status(500).json({ error: "Failed to fetch subscribed apps" }); + } +}); + router.get("/modules", async (req: Request, res: Response) => { try { const modules = await db diff --git a/server/metaset/routes.ts b/server/metaset/routes.ts index 128cfb0..2632d0c 100644 --- a/server/metaset/routes.ts +++ b/server/metaset/routes.ts @@ -4,14 +4,17 @@ import { metasetClient } from "./client"; const METASET_HOST = process.env.METABASE_HOST || "localhost"; const METASET_PORT = parseInt(process.env.METABASE_PORT || "8088", 10); const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`; -const ADMIN_EMAIL = process.env.METASET_ADMIN_EMAIL || "admin@arcadia.app"; -const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD || "Arcadia2026!BI"; +const ADMIN_EMAIL = process.env.METASET_ADMIN_EMAIL; +const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD; export function registerMetaSetRoutes(app: Express): void { app.get("/api/bi/metaset/autologin", async (req: Request, res: Response) => { try { if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + return res.status(503).json({ error: "MetaSet credentials not configured (METASET_ADMIN_EMAIL / METASET_ADMIN_PASSWORD)" }); + } const sessionResp = await fetch(`${METASET_URL}/api/session`, { method: "POST", diff --git a/server/python/__init__.py b/server/python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/python/automation_engine.py b/server/python/automation_engine.py index 6825b58..d39ed74 100644 --- a/server/python/automation_engine.py +++ b/server/python/automation_engine.py @@ -36,7 +36,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/server/python/bi_analysis_service.py b/server/python/bi_analysis_service.py index 37b73a4..a1621bc 100644 --- a/server/python/bi_analysis_service.py +++ b/server/python/bi_analysis_service.py @@ -23,7 +23,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/server/python/bi_engine.py b/server/python/bi_engine.py index dafb6c0..d9a25ba 100644 --- a/server/python/bi_engine.py +++ b/server/python/bi_engine.py @@ -38,7 +38,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/server/python/contabil_service.py b/server/python/contabil_service.py index 1239cc5..65ef878 100644 --- a/server/python/contabil_service.py +++ b/server/python/contabil_service.py @@ -21,7 +21,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/server/python/embeddings_service.py b/server/python/embeddings_service.py index e14943e..43673be 100644 --- a/server/python/embeddings_service.py +++ b/server/python/embeddings_service.py @@ -133,7 +133,8 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) diff --git a/server/python/fisco_service.py b/server/python/fisco_service.py index 3e6e27a..b6816ff 100644 --- a/server/python/fisco_service.py +++ b/server/python/fisco_service.py @@ -56,7 +56,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/server/python/people_service.py b/server/python/people_service.py index d895b65..e6bfc2b 100644 --- a/server/python/people_service.py +++ b/server/python/people_service.py @@ -20,7 +20,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[os.getenv("APP_URL", "http://localhost:5000")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/server/quality/routes.ts b/server/quality/routes.ts index db98300..c5c2b9f 100644 --- a/server/quality/routes.ts +++ b/server/quality/routes.ts @@ -13,11 +13,20 @@ import { eq, desc, and, gte, lte, like, or, sql, isNull } from "drizzle-orm"; const router = Router(); +function requireAuth(req: any, res: any, next: any) { + if (!req.isAuthenticated()) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +} + +router.use(requireAuth); + // ========== AMOSTRAS (RF-QC01) ========== router.get("/samples", async (req: Request, res: Response) => { try { - const { projectId, status, startDate, endDate, limit = 50 } = req.query; + const { projectId, status, startDate, endDate, limit = 50, offset = 0 } = req.query; let conditions = []; if (projectId) conditions.push(eq(qualitySamples.projectId, Number(projectId))); @@ -29,7 +38,7 @@ router.get("/samples", async (req: Request, res: Response) => { .from(qualitySamples) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(qualitySamples.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: samples }); } catch (error) { @@ -82,7 +91,7 @@ router.put("/samples/:id", async (req: Request, res: Response) => { router.get("/lab-reports", async (req: Request, res: Response) => { try { - const { sampleId, laboratoryId, status, limit = 50 } = req.query; + const { sampleId, laboratoryId, status, limit = 50, offset = 0 } = req.query; let conditions = []; if (sampleId) conditions.push(eq(qualityLabReports.sampleId, Number(sampleId))); @@ -93,7 +102,7 @@ router.get("/lab-reports", async (req: Request, res: Response) => { .from(qualityLabReports) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(qualityLabReports.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: reports }); } catch (error) { @@ -130,7 +139,7 @@ router.put("/lab-reports/:id", async (req: Request, res: Response) => { router.get("/non-conformities", async (req: Request, res: Response) => { try { - const { projectId, status, type, severity, limit = 50 } = req.query; + const { projectId, status, type, severity, limit = 50, offset = 0 } = req.query; let conditions = []; if (projectId) conditions.push(eq(qualityNonConformities.projectId, Number(projectId))); @@ -142,7 +151,7 @@ router.get("/non-conformities", async (req: Request, res: Response) => { .from(qualityNonConformities) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(qualityNonConformities.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: rncs }); } catch (error) { @@ -201,7 +210,7 @@ router.post("/non-conformities/:id/close", async (req: Request, res: Response) = router.get("/documents", async (req: Request, res: Response) => { try { - const { type, category, status, limit = 50 } = req.query; + const { type, category, status, limit = 50, offset = 0 } = req.query; let conditions = []; if (type) conditions.push(eq(qualityDocuments.type, type as string)); @@ -212,7 +221,7 @@ router.get("/documents", async (req: Request, res: Response) => { .from(qualityDocuments) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(qualityDocuments.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: docs }); } catch (error) { @@ -302,7 +311,7 @@ router.get("/documents/:id/revisions", async (req: Request, res: Response) => { router.get("/field-forms", async (req: Request, res: Response) => { try { - const { projectId, formType, status, limit = 50 } = req.query; + const { projectId, formType, status, limit = 50, offset = 0 } = req.query; let conditions = []; if (projectId) conditions.push(eq(qualityFieldForms.projectId, Number(projectId))); @@ -313,7 +322,7 @@ router.get("/field-forms", async (req: Request, res: Response) => { .from(qualityFieldForms) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(qualityFieldForms.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: forms }); } catch (error) { @@ -361,7 +370,7 @@ router.get("/training-matrix", async (req: Request, res: Response) => { .from(qualityTrainingMatrix) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(qualityTrainingMatrix.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: trainings }); } catch (error) { @@ -403,7 +412,7 @@ router.get("/training-matrix/expiring", async (req: Request, res: Response) => { router.get("/field-expenses", async (req: Request, res: Response) => { try { - const { projectId, responsibleId, status, category, limit = 50 } = req.query; + const { projectId, responsibleId, status, category, limit = 50, offset = 0 } = req.query; let conditions = []; if (projectId) conditions.push(eq(fieldExpenses.projectId, Number(projectId))); @@ -415,7 +424,7 @@ router.get("/field-expenses", async (req: Request, res: Response) => { .from(fieldExpenses) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(fieldExpenses.createdAt)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: expenses }); } catch (error) { @@ -493,7 +502,7 @@ router.post("/field-expenses/:id/reject", async (req: Request, res: Response) => router.get("/homologated-suppliers", async (req: Request, res: Response) => { try { - const { status, certification, limit = 50 } = req.query; + const { status, certification, limit = 50, offset = 0 } = req.query; let conditions = [eq(suppliers.isHomologated, 1)]; if (status) conditions.push(eq(suppliers.homologationStatus, status as string)); @@ -502,7 +511,7 @@ router.get("/homologated-suppliers", async (req: Request, res: Response) => { .from(suppliers) .where(and(...conditions)) .orderBy(desc(suppliers.qualityScore)) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: suppliersList }); } catch (error) { @@ -631,7 +640,7 @@ router.get("/services", async (req: Request, res: Response) => { .from(environmentalServices) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(environmentalServices.category, environmentalServices.name) - .limit(Number(limit)); + .limit(Number(limit)).offset(Number(offset)); return res.json({ data: services }); } catch (error) { diff --git a/server/routes.ts b/server/routes.ts index fb18b06..1b0fe2a 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -136,7 +136,10 @@ export async function registerRoutes( // Arcádia Plus - SSO routes (proxy already registered at top) app.use("/api/plus/sso", plusSsoRoutes); - app.get("/api/tenants", async (_req, res) => { + app.get("/api/tenants", async (req: any, res) => { + if (!req.isAuthenticated()) { + return res.status(401).json({ error: "Authentication required" }); + } try { const tenants = await storage.getTenants(); res.json(tenants); diff --git a/server/whatsapp/routes.ts b/server/whatsapp/routes.ts index 71618ab..e739174 100644 --- a/server/whatsapp/routes.ts +++ b/server/whatsapp/routes.ts @@ -229,7 +229,7 @@ export function registerWhatsappRoutes(app: Express): void { const userId = req.user!.id; const config = req.body; - whatsappService.setAutoReplyConfig(userId, config); + await whatsappService.setAutoReplyConfig(userId, config); res.json({ success: true, config: whatsappService.getAutoReplyConfig(userId) }); } catch (error) { console.error("WhatsApp set auto-reply config error:", error); diff --git a/server/whatsapp/service.ts b/server/whatsapp/service.ts index a063206..159ac7f 100644 --- a/server/whatsapp/service.ts +++ b/server/whatsapp/service.ts @@ -9,7 +9,7 @@ import { EventEmitter } from "events"; import * as fs from "fs"; import * as path from "path"; import { db } from "../../db/index"; -import { whatsappContacts, whatsappMessages, whatsappTickets, graphNodes, graphEdges, chatThreads, chatParticipants, chatMessages, pcCrmLeads, tenants } from "@shared/schema"; +import { whatsappContacts, whatsappMessages, whatsappTickets, whatsappSessions, graphNodes, graphEdges, chatThreads, chatParticipants, chatMessages, pcCrmLeads, tenants } from "@shared/schema"; import { eq, and, desc, sql } from "drizzle-orm"; import { learningService } from "../learning/service"; import OpenAI from "openai"; @@ -57,8 +57,17 @@ class WhatsAppService extends EventEmitter { } } - setAutoReplyConfig(userId: string, config: Partial): void { - const existing = this.autoReplyConfigs.get(userId) || { + async setAutoReplyConfig(userId: string, config: Partial): Promise { + const existing = this.autoReplyConfigs.get(userId) || await this.loadAutoReplyConfig(userId); + const merged = { ...existing, ...config }; + this.autoReplyConfigs.set(userId, merged); + await db.update(whatsappSessions) + .set({ autoReplyConfig: merged }) + .where(eq(whatsappSessions.userId, userId)); + } + + async loadAutoReplyConfig(userId: string): Promise { + const defaults: AutoReplyConfig = { enabled: false, welcomeMessage: "Olá! Obrigado por entrar em contato. Em breve um atendente irá te responder.", businessHours: { start: 8, end: 18 }, @@ -66,7 +75,16 @@ class WhatsAppService extends EventEmitter { aiEnabled: true, maxAutoRepliesPerContact: 3, }; - this.autoReplyConfigs.set(userId, { ...existing, ...config }); + try { + const [session] = await db.select({ autoReplyConfig: whatsappSessions.autoReplyConfig }) + .from(whatsappSessions) + .where(eq(whatsappSessions.userId, userId)) + .limit(1); + if (session?.autoReplyConfig) { + return { ...defaults, ...(session.autoReplyConfig as Partial) }; + } + } catch {} + return defaults; } getAutoReplyConfig(userId: string): AutoReplyConfig { diff --git a/server/xos/routes.ts b/server/xos/routes.ts index a5d7c53..9f5edf8 100644 --- a/server/xos/routes.ts +++ b/server/xos/routes.ts @@ -4,6 +4,15 @@ import { sql } from "drizzle-orm"; const router = Router(); +function requireAuth(req: any, res: any, next: any) { + if (!req.isAuthenticated()) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +} + +router.use(requireAuth); + // ========== CONTACTS ========== router.get("/contacts", async (req: Request, res: Response) => { @@ -325,7 +334,7 @@ router.delete("/deals/:id", async (req: Request, res: Response) => { router.get("/conversations", async (req: Request, res: Response) => { try { - const { status, channel, assigned_to, queue_id } = req.query; + const { status, channel, assigned_to, queue_id, limit = 50, offset = 0 } = req.query; let query = sql` SELECT cv.*, @@ -343,7 +352,7 @@ router.get("/conversations", async (req: Request, res: Response) => { if (assigned_to) query = sql`${query} AND cv.assigned_to = ${assigned_to}`; if (queue_id) query = sql`${query} AND cv.queue_id = ${parseInt(queue_id as string)}`; - query = sql`${query} ORDER BY cv.updated_at DESC LIMIT 50`; + query = sql`${query} ORDER BY cv.updated_at DESC LIMIT ${parseInt(limit as string)} OFFSET ${parseInt(offset as string)}`; const result = await db.execute(query); res.json(result.rows || result); @@ -373,7 +382,7 @@ router.get("/conversations/:id/messages", async (req: Request, res: Response) => router.get("/tickets", async (req: Request, res: Response) => { try { - const { status, priority, assigned_to } = req.query; + const { status, priority, assigned_to, limit = 50, offset = 0 } = req.query; let query = sql` SELECT t.*, @@ -387,7 +396,7 @@ router.get("/tickets", async (req: Request, res: Response) => { if (priority) query = sql`${query} AND t.priority = ${priority}`; if (assigned_to) query = sql`${query} AND t.assigned_to = ${assigned_to}`; - query = sql`${query} ORDER BY t.created_at DESC LIMIT 50`; + query = sql`${query} ORDER BY t.created_at DESC LIMIT ${parseInt(limit as string)} OFFSET ${parseInt(offset as string)}`; const result = await db.execute(query); res.json(result.rows || result); @@ -423,7 +432,7 @@ router.post("/tickets", async (req: Request, res: Response) => { router.get("/activities", async (req: Request, res: Response) => { try { - const { contact_id, deal_id, type, status } = req.query; + const { contact_id, deal_id, type, status, limit = 50, offset = 0 } = req.query; let query = sql` SELECT a.*, @@ -440,7 +449,7 @@ router.get("/activities", async (req: Request, res: Response) => { if (type) query = sql`${query} AND a.type = ${type}`; if (status) query = sql`${query} AND a.status = ${status}`; - query = sql`${query} ORDER BY a.due_at ASC NULLS LAST, a.created_at DESC LIMIT 50`; + query = sql`${query} ORDER BY a.due_at ASC NULLS LAST, a.created_at DESC LIMIT ${parseInt(limit as string)} OFFSET ${parseInt(offset as string)}`; const result = await db.execute(query); res.json(result.rows || result); diff --git a/shared/schema.ts b/shared/schema.ts index 67a90ef..fbccf1c 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -158,6 +158,7 @@ export type InsertExternalAppPermission = z.infer tenants.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), parentId: integer("parent_id"), // for nested pages title: text("title").notNull().default("Sem título"), @@ -207,6 +208,7 @@ export const dashboardWidgets = pgTable("dashboard_widgets", { // ========== QUICK NOTES (Scratch Pad) ========== export const quickNotes = pgTable("quick_notes", { id: serial("id").primaryKey(), + tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), content: text("content").notNull(), isPinned: integer("is_pinned").default(0), @@ -218,6 +220,7 @@ export const quickNotes = pgTable("quick_notes", { // ========== UNIFIED INBOX / ACTIVITY FEED ========== export const activityFeed = pgTable("activity_feed", { id: serial("id").primaryKey(), + tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), actorId: varchar("actor_id").references(() => users.id), // who performed the action type: text("type").notNull(), // created, updated, deleted, mentioned, assigned, completed, commented @@ -281,6 +284,7 @@ export type InsertCommandHistoryEntry = z.infer tenants.id, { onDelete: "cascade" }), userId: varchar("user_id").references(() => users.id, { onDelete: "cascade" }), title: text("title").notNull(), createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(), @@ -312,6 +316,7 @@ export type InsertMessage = z.infer; export const knowledgeBase = pgTable("knowledge_base", { id: serial("id").primaryKey(), + tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }), title: text("title").notNull(), content: text("content").notNull(), author: text("author").notNull(), @@ -409,6 +414,7 @@ export type InsertTaskExecution = z.infer; export const chatThreads = pgTable("chat_threads", { id: serial("id").primaryKey(), + tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }), type: text("type").notNull().default("direct"), name: text("name"), createdBy: varchar("created_by").references(() => users.id, { onDelete: "set null" }), @@ -443,6 +449,7 @@ export const whatsappSessions = pgTable("whatsapp_sessions", { status: text("status").default("disconnected"), phoneNumber: text("phone_number"), lastSync: timestamp("last_sync"), + autoReplyConfig: jsonb("auto_reply_config").$type>(), createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(), }); @@ -530,6 +537,7 @@ export type InsertWhatsappTicket = z.infer; export const manusRuns = pgTable("manus_runs", { id: serial("id").primaryKey(), + tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), prompt: text("prompt").notNull(), status: text("status").default("running"),