From 0c006da8a5ce268b2397449cfc04a20d80abff92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 16:13:10 +0000 Subject: [PATCH] feat: rate limiting, logging estruturado, tenant isolation, Compass AI, paginacao MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- migrations/0002_tenant_isolation.sql | 40 +++++++++ migrations/meta/_journal.json | 7 ++ package.json | 2 + server/compass/routes.ts | 120 +++++++++++++++++++++++++++ server/index.ts | 64 +++++++------- server/logger.ts | 42 ++++++++++ server/quality/routes.ts | 32 +++---- server/xos/routes.ts | 12 +-- shared/schema.ts | 7 ++ 9 files changed, 269 insertions(+), 57 deletions(-) create mode 100644 migrations/0002_tenant_isolation.sql create mode 100644 server/logger.ts 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 42cce47..e332aca 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -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 } ] } \ 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/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/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/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/quality/routes.ts b/server/quality/routes.ts index d437c85..c5c2b9f 100644 --- a/server/quality/routes.ts +++ b/server/quality/routes.ts @@ -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) { diff --git a/server/xos/routes.ts b/server/xos/routes.ts index ab5f959..9f5edf8 100644 --- a/server/xos/routes.ts +++ b/server/xos/routes.ts @@ -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); diff --git a/shared/schema.ts b/shared/schema.ts index 93f6168..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" }), @@ -531,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"),