Merge pull request #2 from jonaspachecoometas/claude/analyze-project-0mXjP

Correções de segurança e implementações.
This commit is contained in:
jonaspachecoometas 2026-03-13 14:06:38 -03:00 committed by GitHub
commit 76c1dd7090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 479 additions and 86 deletions

1
.gitignore vendored
View File

@ -63,3 +63,4 @@ attached_assets/
# Package lock (regenerated on install)
package-lock.json
.claude/

View File

@ -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() {
<Card
key={app.id}
className="bg-white/5 border-white/10 hover:border-white/30 cursor-pointer transition-all hover:scale-105 group"
onClick={() => setLocation(app.route)}
onClick={() => handleAppClick(app)}
data-testid={`featured-app-${app.id}`}
>
<CardContent className="p-4 text-center">
@ -579,27 +600,37 @@ export default function AppCenter() {
<TabsContent value={activeCategory} className="mt-0">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredApps.map(app => (
{filteredApps.map(app => {
const locked = isLocked(app);
return (
<Card
key={app.id}
className="bg-white/5 border-white/10 hover:border-white/30 cursor-pointer transition-all group overflow-hidden"
onClick={() => 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}`}
>
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${app.color} flex items-center justify-center text-white flex-shrink-0 group-hover:scale-110 transition-transform`}>
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${app.color} flex items-center justify-center text-white flex-shrink-0 ${locked ? "" : "group-hover:scale-110"} transition-transform relative`}>
{app.icon}
{locked && (
<div className="absolute inset-0 rounded-2xl bg-black/50 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-white group-hover:text-indigo-400 transition-colors truncate">
<h3 className={`font-semibold truncate ${locked ? "text-slate-400" : "text-white group-hover:text-indigo-400"} transition-colors`}>
{app.name}
</h3>
{app.status === "beta" && (
{locked && (
<Badge className="bg-slate-600/50 text-slate-400 text-xs">Não contratado</Badge>
)}
{!locked && app.status === "beta" && (
<Badge className="bg-yellow-500/20 text-yellow-300 text-xs">Beta</Badge>
)}
{app.status === "coming_soon" && (
{!locked && app.status === "coming_soon" && (
<Badge className="bg-slate-500/20 text-slate-300 text-xs">Em breve</Badge>
)}
</div>
@ -607,13 +638,20 @@ export default function AppCenter() {
</div>
</div>
<div className="mt-4 flex justify-end">
<span className="text-xs text-indigo-400 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
Abrir <ArrowRight className="w-3 h-3" />
</span>
{locked ? (
<span className="text-xs text-slate-500 flex items-center gap-1">
Ver no Marketplace <ArrowRight className="w-3 h-3" />
</span>
) : (
<span className="text-xs text-indigo-400 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
Abrir <ArrowRight className="w-3 h-3" />
</span>
)}
</div>
</CardContent>
</Card>
))}
);
})}
</div>
{filteredApps.length === 0 && (

View File

@ -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"

View File

@ -0,0 +1 @@
ALTER TABLE "whatsapp_sessions" ADD COLUMN IF NOT EXISTS "auto_reply_config" jsonb;

View File

@ -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);

View File

@ -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
}
]
}

View File

@ -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",

0
server/__init__.py Normal file
View File

View File

@ -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,

View File

@ -58,6 +58,7 @@ const ALL_PHASES: PipelinePhase[] = ["design", "codegen", "validation", "staging
class PipelineOrchestrator extends EventEmitter {
private activeMonitors: Map<number, NodeJS.Timeout> = new Map();
private processingMonitors: Set<number> = new Set();
async createPipeline(prompt: string, userId: string = "system", metadata?: any): Promise<XosDevPipeline> {
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);

View File

@ -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;

View File

@ -36,6 +36,7 @@ export class FrappeService {
"Content-Type": "application/json",
Accept: "application/json",
},
signal: AbortSignal.timeout(30000),
};
if (data && (method === "POST" || method === "PUT")) {

View File

@ -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<string, any> | 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

View File

@ -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;

42
server/logger.ts Normal file
View File

@ -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();
};
}

View File

@ -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<string, { tool: string; input: Record<string, any> }> = new Map();
private async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
// 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":

View File

@ -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

View File

@ -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",

View File

View File

@ -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=["*"],

View File

@ -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=["*"],

View File

@ -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=["*"],

View File

@ -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=["*"],

View File

@ -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=["*"],
)

View File

@ -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=["*"],

View File

@ -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=["*"],

View File

@ -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) {

View File

@ -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);

View File

@ -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);

View File

@ -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<AutoReplyConfig>): void {
const existing = this.autoReplyConfigs.get(userId) || {
async setAutoReplyConfig(userId: string, config: Partial<AutoReplyConfig>): Promise<void> {
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<AutoReplyConfig> {
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<AutoReplyConfig>) };
}
} catch {}
return defaults;
}
getAutoReplyConfig(userId: string): AutoReplyConfig {

View File

@ -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);

View File

@ -158,6 +158,7 @@ export type InsertExternalAppPermission = z.infer<typeof insertExternalAppPermis
// ========== PRODUCTIVITY HUB - Pages & Blocks (Notion-style) ==========
export const workspacePages = pgTable("workspace_pages", {
id: serial("id").primaryKey(),
tenantId: integer("tenant_id").references(() => 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<typeof insertCommandHistorySchem
export const conversations = pgTable("conversations", {
id: serial("id").primaryKey(),
tenantId: integer("tenant_id").references(() => 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<typeof insertMessageSchema>;
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<typeof insertTaskExecutionSchema>;
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<Record<string, any>>(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
@ -530,6 +537,7 @@ export type InsertWhatsappTicket = z.infer<typeof insertWhatsappTicketSchema>;
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"),