feat: rate limiting, logging estruturado, tenant isolation, Compass AI, paginacao
QUALIDADE: - Rate limiting: 500 req/15min em /api; 20 req/15min em login/register - Winston logger: JSON em prod, colorido em dev; HTTP middleware com user_id/tenant_id - Paginacao: XOS (conversations, tickets, activities) e Quality (9 endpoints) recebem offset MULTI-TENANCY: - tenantId adicionado a 7 tabelas criticas: workspace_pages, quick_notes, activity_feed, conversations, knowledge_base, chat_threads, manus_runs - Migration 0002_tenant_isolation.sql com indices de performance PROCESS COMPASS COM IA: - POST /api/compass/projects/:id/ai-brief Gera briefing executivo via GPT-4o-mini: situação, riscos, ações prioritárias, health score — baseado em tasks, PDCA e status do projeto - GET /api/compass/projects/:id/health Score 0-100 calculado: 40% completude de tasks + 30% pontualidade + 30% PDCA https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
parent
1601ad0c12
commit
0c006da8a5
|
|
@ -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);
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1741824000000,
|
||||
"tag": "0001_whatsapp_auto_reply_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1741824060000,
|
||||
"tag": "0002_tenant_isolation",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ router.use(requireAuth);
|
|||
|
||||
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)));
|
||||
|
|
@ -38,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) {
|
||||
|
|
@ -91,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)));
|
||||
|
|
@ -102,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) {
|
||||
|
|
@ -139,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)));
|
||||
|
|
@ -151,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) {
|
||||
|
|
@ -210,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));
|
||||
|
|
@ -221,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) {
|
||||
|
|
@ -311,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)));
|
||||
|
|
@ -322,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) {
|
||||
|
|
@ -370,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) {
|
||||
|
|
@ -412,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)));
|
||||
|
|
@ -424,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) {
|
||||
|
|
@ -502,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));
|
||||
|
|
@ -511,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) {
|
||||
|
|
@ -640,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) {
|
||||
|
|
|
|||
|
|
@ -334,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.*,
|
||||
|
|
@ -352,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);
|
||||
|
|
@ -382,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.*,
|
||||
|
|
@ -396,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);
|
||||
|
|
@ -432,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.*,
|
||||
|
|
@ -449,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);
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
@ -531,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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue