1593 lines
55 KiB
TypeScript
1593 lines
55 KiB
TypeScript
import { Router } from "express";
|
|
import { crmStorage } from "./storage";
|
|
import { whatsappBridge } from "./whatsapp-bridge";
|
|
import { communicationService } from "./communication";
|
|
|
|
whatsappBridge.init(communicationService);
|
|
|
|
import {
|
|
insertCrmPartnerSchema,
|
|
insertCrmPartnerCertificationSchema,
|
|
insertCrmPartnerPerformanceSchema,
|
|
insertCrmContractSchema,
|
|
insertCrmChannelSchema,
|
|
insertCrmThreadSchema,
|
|
insertCrmMessageSchema,
|
|
insertCrmEventSchema,
|
|
insertCrmCommissionRuleSchema,
|
|
insertCrmProductSchema,
|
|
insertCrmClientSchema,
|
|
insertCrmPipelineStageSchema,
|
|
insertCrmLeadSchema,
|
|
insertCrmOpportunitySchema,
|
|
insertCrmOpportunityProductSchema,
|
|
insertCrmProposalSchema,
|
|
insertCrmProposalItemSchema,
|
|
insertCrmContractMilestoneSchema,
|
|
insertCrmFrappeConnectorSchema,
|
|
insertCrmFrappeMappingSchema,
|
|
} from "@shared/schema";
|
|
import { createFrappeService } from "./frappe-service";
|
|
import { z } from "zod";
|
|
|
|
const router = Router();
|
|
|
|
function requireAuth(req: any, res: any, next: any) {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
router.get("/partners", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const partners = await crmStorage.getPartners(tenantId);
|
|
res.json(partners);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch partners" });
|
|
}
|
|
});
|
|
|
|
router.get("/partners/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const partner = await crmStorage.getPartner(Number(req.params.id));
|
|
if (!partner) {
|
|
return res.status(404).json({ error: "Partner not found" });
|
|
}
|
|
res.json(partner);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch partner" });
|
|
}
|
|
});
|
|
|
|
router.post("/partners", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmPartnerSchema.parse(req.body);
|
|
const partner = await crmStorage.createPartner(data);
|
|
res.status(201).json(partner);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create partner" });
|
|
}
|
|
});
|
|
|
|
router.patch("/partners/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmPartnerSchema.partial().parse(req.body);
|
|
const partner = await crmStorage.updatePartner(Number(req.params.id), data);
|
|
if (!partner) {
|
|
return res.status(404).json({ error: "Partner not found" });
|
|
}
|
|
res.json(partner);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to update partner" });
|
|
}
|
|
});
|
|
|
|
router.delete("/partners/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deletePartner(Number(req.params.id));
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Partner not found" });
|
|
}
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete partner" });
|
|
}
|
|
});
|
|
|
|
router.get("/partners/:id/certifications", requireAuth, async (req, res) => {
|
|
try {
|
|
const certs = await crmStorage.getPartnerCertifications(Number(req.params.id));
|
|
res.json(certs);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch certifications" });
|
|
}
|
|
});
|
|
|
|
router.post("/partners/:id/certifications", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmPartnerCertificationSchema.parse({ ...req.body, partnerId: Number(req.params.id) });
|
|
const cert = await crmStorage.createCertification(data);
|
|
res.status(201).json(cert);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create certification" });
|
|
}
|
|
});
|
|
|
|
router.get("/partners/:id/performance", requireAuth, async (req, res) => {
|
|
try {
|
|
const perf = await crmStorage.getPartnerPerformance(Number(req.params.id));
|
|
res.json(perf);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch performance" });
|
|
}
|
|
});
|
|
|
|
router.post("/partners/:id/performance", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmPartnerPerformanceSchema.parse({ ...req.body, partnerId: Number(req.params.id) });
|
|
const perf = await crmStorage.createPerformance(data);
|
|
res.status(201).json(perf);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create performance record" });
|
|
}
|
|
});
|
|
|
|
router.get("/contracts", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const partnerId = req.query.partnerId ? Number(req.query.partnerId) : undefined;
|
|
const clientId = req.query.clientId ? Number(req.query.clientId) : undefined;
|
|
const contracts = await crmStorage.getContracts(tenantId, partnerId, clientId);
|
|
res.json(contracts);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch contracts" });
|
|
}
|
|
});
|
|
|
|
router.get("/contracts/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const contract = await crmStorage.getContract(Number(req.params.id));
|
|
if (!contract) {
|
|
return res.status(404).json({ error: "Contract not found" });
|
|
}
|
|
res.json(contract);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch contract" });
|
|
}
|
|
});
|
|
|
|
router.post("/contracts", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmContractSchema.parse(req.body);
|
|
const contract = await crmStorage.createContract(data);
|
|
res.status(201).json(contract);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create contract" });
|
|
}
|
|
});
|
|
|
|
router.patch("/contracts/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmContractSchema.partial().parse(req.body);
|
|
const contract = await crmStorage.updateContract(Number(req.params.id), data);
|
|
if (!contract) {
|
|
return res.status(404).json({ error: "Contract not found" });
|
|
}
|
|
res.json(contract);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to update contract" });
|
|
}
|
|
});
|
|
|
|
router.get("/contracts/:id/revenue", requireAuth, async (req, res) => {
|
|
try {
|
|
const schedule = await crmStorage.getRevenueSchedule(Number(req.params.id));
|
|
res.json(schedule);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch revenue schedule" });
|
|
}
|
|
});
|
|
|
|
router.get("/channels", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const channels = await crmStorage.getChannels(tenantId);
|
|
res.json(channels);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch channels" });
|
|
}
|
|
});
|
|
|
|
router.post("/channels", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmChannelSchema.parse(req.body);
|
|
const channel = await crmStorage.createChannel(data);
|
|
res.status(201).json(channel);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create channel" });
|
|
}
|
|
});
|
|
|
|
router.patch("/channels/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmChannelSchema.partial().parse(req.body);
|
|
const channel = await crmStorage.updateChannel(Number(req.params.id), data);
|
|
if (!channel) {
|
|
return res.status(404).json({ error: "Channel not found" });
|
|
}
|
|
res.json(channel);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to update channel" });
|
|
}
|
|
});
|
|
|
|
router.get("/threads", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const channelId = req.query.channelId ? Number(req.query.channelId) : undefined;
|
|
const status = req.query.status as string | undefined;
|
|
const threads = await crmStorage.getThreads(tenantId, channelId, status);
|
|
res.json(threads);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch threads" });
|
|
}
|
|
});
|
|
|
|
router.get("/threads/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const thread = await crmStorage.getThread(Number(req.params.id));
|
|
if (!thread) {
|
|
return res.status(404).json({ error: "Thread not found" });
|
|
}
|
|
res.json(thread);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch thread" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmThreadSchema.parse(req.body);
|
|
const thread = await crmStorage.createThread(data);
|
|
res.status(201).json(thread);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create thread" });
|
|
}
|
|
});
|
|
|
|
router.patch("/threads/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmThreadSchema.partial().parse(req.body);
|
|
const thread = await crmStorage.updateThread(Number(req.params.id), data);
|
|
if (!thread) {
|
|
return res.status(404).json({ error: "Thread not found" });
|
|
}
|
|
res.json(thread);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to update thread" });
|
|
}
|
|
});
|
|
|
|
router.get("/threads/:id/messages", requireAuth, async (req, res) => {
|
|
try {
|
|
const limit = req.query.limit ? Number(req.query.limit) : 50;
|
|
const messages = await crmStorage.getMessages(Number(req.params.id), limit);
|
|
res.json(messages);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch messages" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads/:id/messages", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmMessageSchema.parse({ ...req.body, threadId: Number(req.params.id) });
|
|
const message = await crmStorage.createMessage(data);
|
|
res.status(201).json(message);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create message" });
|
|
}
|
|
});
|
|
|
|
router.get("/events", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.user!.id;
|
|
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
|
const events = await crmStorage.getEvents(userId, startDate, endDate);
|
|
const serializedEvents = events.map(e => ({
|
|
...e,
|
|
startAt: e.startAt instanceof Date ? e.startAt.toISOString() : e.startAt,
|
|
endAt: e.endAt instanceof Date ? e.endAt.toISOString() : e.endAt,
|
|
createdAt: e.createdAt instanceof Date ? e.createdAt.toISOString() : e.createdAt,
|
|
updatedAt: e.updatedAt instanceof Date ? e.updatedAt.toISOString() : e.updatedAt,
|
|
completedAt: e.completedAt instanceof Date ? e.completedAt.toISOString() : e.completedAt,
|
|
}));
|
|
res.json(serializedEvents);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch events" });
|
|
}
|
|
});
|
|
|
|
router.post("/events", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmEventSchema.parse({ ...req.body, userId: req.user!.id });
|
|
const event = await crmStorage.createEvent(data);
|
|
res.status(201).json(event);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create event" });
|
|
}
|
|
});
|
|
|
|
router.patch("/events/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmEventSchema.partial().parse(req.body);
|
|
const event = await crmStorage.updateEvent(Number(req.params.id), data);
|
|
if (!event) {
|
|
return res.status(404).json({ error: "Event not found" });
|
|
}
|
|
res.json(event);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to update event" });
|
|
}
|
|
});
|
|
|
|
router.delete("/events/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteEvent(Number(req.params.id));
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Event not found" });
|
|
}
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete event" });
|
|
}
|
|
});
|
|
|
|
router.get("/commission-rules", requireAuth, async (req, res) => {
|
|
try {
|
|
const rules = await crmStorage.getCommissionRules();
|
|
res.json(rules);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch commission rules" });
|
|
}
|
|
});
|
|
|
|
router.post("/commission-rules", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmCommissionRuleSchema.parse(req.body);
|
|
const rule = await crmStorage.createCommissionRule(data);
|
|
res.status(201).json(rule);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create commission rule" });
|
|
}
|
|
});
|
|
|
|
router.get("/commissions", requireAuth, async (req, res) => {
|
|
try {
|
|
const partnerId = req.query.partnerId ? Number(req.query.partnerId) : undefined;
|
|
const userId = req.query.userId as string | undefined;
|
|
const commissions = await crmStorage.getCommissions(partnerId, userId);
|
|
res.json(commissions);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch commissions" });
|
|
}
|
|
});
|
|
|
|
router.get("/stats", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const stats = await crmStorage.getStats(tenantId);
|
|
res.json(stats);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch stats" });
|
|
}
|
|
});
|
|
|
|
import { commissionEngine } from "./commission-engine";
|
|
|
|
router.post("/channels/:id/whatsapp/connect", requireAuth, async (req, res) => {
|
|
try {
|
|
const channelId = Number(req.params.id);
|
|
const tenantId = req.body.tenantId || 1;
|
|
const channelName = req.body.name || "WhatsApp Principal";
|
|
const channel = await communicationService.connectWhatsAppChannel(channelId, tenantId, channelName);
|
|
res.json(channel);
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to connect WhatsApp" });
|
|
}
|
|
});
|
|
|
|
router.post("/channels/whatsapp/new", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.body.tenantId || 1;
|
|
const channelName = req.body.name || "WhatsApp Principal";
|
|
const channel = await communicationService.connectNewWhatsAppChannel(tenantId, channelName);
|
|
res.json(channel);
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to create WhatsApp channel" });
|
|
}
|
|
});
|
|
|
|
router.post("/channels/:id/whatsapp/disconnect", requireAuth, async (req, res) => {
|
|
try {
|
|
await communicationService.disconnectWhatsAppChannel(Number(req.params.id));
|
|
res.json({ success: true });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to disconnect WhatsApp" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads/:id/send", requireAuth, async (req, res) => {
|
|
try {
|
|
const { content, type } = req.body;
|
|
if (!content) {
|
|
return res.status(400).json({ error: "content is required" });
|
|
}
|
|
const message = await communicationService.sendMessage(
|
|
Number(req.params.id),
|
|
content,
|
|
req.user!.id,
|
|
type || "text",
|
|
"whatsapp"
|
|
);
|
|
res.status(201).json(message);
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to send message" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads/:id/read", requireAuth, async (req, res) => {
|
|
try {
|
|
await communicationService.markThreadRead(Number(req.params.id));
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to mark thread as read" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads/:id/assign", requireAuth, async (req, res) => {
|
|
try {
|
|
const { assignedToId } = req.body;
|
|
if (!assignedToId) {
|
|
return res.status(400).json({ error: "assignedToId is required" });
|
|
}
|
|
await communicationService.assignThread(Number(req.params.id), assignedToId);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to assign thread" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads/:id/close", requireAuth, async (req, res) => {
|
|
try {
|
|
await communicationService.closeThread(Number(req.params.id));
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to close thread" });
|
|
}
|
|
});
|
|
|
|
router.post("/threads/:id/reopen", requireAuth, async (req, res) => {
|
|
try {
|
|
await communicationService.reopenThread(Number(req.params.id));
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to reopen thread" });
|
|
}
|
|
});
|
|
|
|
router.get("/quick-messages", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const messages = await communicationService.getQuickMessages(tenantId, req.user!.id);
|
|
res.json(messages);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch quick messages" });
|
|
}
|
|
});
|
|
|
|
router.post("/quick-messages", requireAuth, async (req, res) => {
|
|
try {
|
|
const { tenantId, shortcut, title, content, category, isGlobal } = req.body;
|
|
if (!shortcut || !title || !content) {
|
|
return res.status(400).json({ error: "shortcut, title, and content are required" });
|
|
}
|
|
const message = await communicationService.createQuickMessage(
|
|
tenantId,
|
|
req.user!.id,
|
|
shortcut,
|
|
title,
|
|
content,
|
|
category,
|
|
isGlobal
|
|
);
|
|
res.status(201).json(message);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to create quick message" });
|
|
}
|
|
});
|
|
|
|
router.get("/threads/stats", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const stats = await communicationService.getThreadStats(tenantId);
|
|
res.json(stats);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch thread stats" });
|
|
}
|
|
});
|
|
|
|
router.post("/commission-rules/seed", requireAuth, async (req, res) => {
|
|
try {
|
|
await commissionEngine.seedDefaultRules();
|
|
res.json({ success: true, message: "Default commission rules created" });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to seed commission rules" });
|
|
}
|
|
});
|
|
|
|
router.post("/contracts/:id/process-commissions", requireAuth, async (req, res) => {
|
|
try {
|
|
const salesUserId = req.body.salesUserId || req.user!.id;
|
|
const processed = await commissionEngine.processContractCommissions(Number(req.params.id), salesUserId);
|
|
res.json({ success: true, processed });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to process commissions" });
|
|
}
|
|
});
|
|
|
|
router.get("/commissions/summary", requireAuth, async (req, res) => {
|
|
try {
|
|
const partnerId = req.query.partnerId ? Number(req.query.partnerId) : undefined;
|
|
const userId = req.query.userId as string | undefined;
|
|
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
|
const summary = await commissionEngine.getCommissionSummary(partnerId, userId, startDate, endDate);
|
|
res.json(summary);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch commission summary" });
|
|
}
|
|
});
|
|
|
|
router.post("/commissions/:id/mark-paid", requireAuth, async (req, res) => {
|
|
try {
|
|
await commissionEngine.markCommissionPaid(Number(req.params.id));
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to mark commission as paid" });
|
|
}
|
|
});
|
|
|
|
router.post("/contracts/:id/extend-schedule", requireAuth, async (req, res) => {
|
|
try {
|
|
const monthsAhead = req.body.monthsAhead || 12;
|
|
const schedules = await commissionEngine.extendRevenueSchedule(Number(req.params.id), monthsAhead);
|
|
res.json({ success: true, schedules });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to extend schedule" });
|
|
}
|
|
});
|
|
|
|
router.post("/contracts/extend-all", requireAuth, async (req, res) => {
|
|
try {
|
|
const monthsAhead = req.body.monthsAhead || 12;
|
|
const extended = await commissionEngine.extendAllActiveContracts(monthsAhead);
|
|
res.json({ success: true, extended });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to extend schedules" });
|
|
}
|
|
});
|
|
|
|
import { googleCalendarService } from "./google-calendar";
|
|
|
|
router.get("/google/auth", requireAuth, (req, res) => {
|
|
try {
|
|
const authUrl = googleCalendarService.getAuthUrl(req.user!.id);
|
|
res.json({ url: authUrl });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to get auth URL" });
|
|
}
|
|
});
|
|
|
|
router.get("/google/callback", async (req, res) => {
|
|
try {
|
|
const { code, state } = req.query;
|
|
if (!code || !state) {
|
|
return res.status(400).send("Missing code or state");
|
|
}
|
|
await googleCalendarService.handleCallback(code as string, state as string);
|
|
res.redirect("/crm?google_connected=true");
|
|
} catch (error: any) {
|
|
res.status(500).send(`Failed to connect: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
router.get("/google/status", requireAuth, async (req, res) => {
|
|
try {
|
|
const connected = await googleCalendarService.isConnected(req.user!.id);
|
|
res.json({ connected });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to check status" });
|
|
}
|
|
});
|
|
|
|
router.post("/google/disconnect", requireAuth, async (req, res) => {
|
|
try {
|
|
await googleCalendarService.disconnect(req.user!.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to disconnect" });
|
|
}
|
|
});
|
|
|
|
router.post("/google/sync", requireAuth, async (req, res) => {
|
|
try {
|
|
const startDate = req.body.startDate ? new Date(req.body.startDate) : new Date();
|
|
const endDate = req.body.endDate ? new Date(req.body.endDate) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
await googleCalendarService.syncEvents(req.user!.id, startDate, endDate);
|
|
res.json({ success: true });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to sync events" });
|
|
}
|
|
});
|
|
|
|
router.get("/google/events", requireAuth, async (req, res) => {
|
|
try {
|
|
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
|
const events = await googleCalendarService.getEvents(req.user!.id, startDate, endDate);
|
|
res.json(events);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch events" });
|
|
}
|
|
});
|
|
|
|
router.post("/google/events", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmEventSchema.parse(req.body);
|
|
const event = await crmStorage.createEvent({ ...data, userId: req.user!.id });
|
|
|
|
const isConnected = await googleCalendarService.isConnected(req.user!.id);
|
|
if (isConnected) {
|
|
const googleEventId = await googleCalendarService.createEvent(req.user!.id, event);
|
|
if (googleEventId) {
|
|
await crmStorage.updateEvent(event.id, { googleEventId });
|
|
}
|
|
}
|
|
|
|
res.status(201).json(event);
|
|
} catch (error: any) {
|
|
if (error.errors) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create event" });
|
|
}
|
|
});
|
|
|
|
router.put("/events/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const event = await crmStorage.updateEvent(Number(req.params.id), req.body);
|
|
if (!event) {
|
|
return res.status(404).json({ error: "Event not found" });
|
|
}
|
|
|
|
if (event.googleEventId) {
|
|
await googleCalendarService.updateEvent(req.user!.id, event.googleEventId, event);
|
|
}
|
|
|
|
res.json(event);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update event" });
|
|
}
|
|
});
|
|
|
|
router.delete("/events/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const event = await crmStorage.getEvent(Number(req.params.id));
|
|
if (!event) {
|
|
return res.status(404).json({ error: "Event not found" });
|
|
}
|
|
|
|
if (event.googleEventId) {
|
|
await googleCalendarService.deleteEvent(req.user!.id, event.googleEventId);
|
|
}
|
|
|
|
await crmStorage.deleteEvent(event.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete event" });
|
|
}
|
|
});
|
|
|
|
// ========== PRODUCTS ==========
|
|
router.get("/products", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const products = await crmStorage.getProducts(tenantId);
|
|
res.json(products);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch products" });
|
|
}
|
|
});
|
|
|
|
router.get("/products/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const product = await crmStorage.getProduct(Number(req.params.id));
|
|
if (!product) return res.status(404).json({ error: "Product not found" });
|
|
res.json(product);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch product" });
|
|
}
|
|
});
|
|
|
|
router.post("/products", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmProductSchema.parse(req.body);
|
|
const product = await crmStorage.createProduct(data);
|
|
res.status(201).json(product);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to create product" });
|
|
}
|
|
});
|
|
|
|
router.patch("/products/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmProductSchema.partial().parse(req.body);
|
|
const product = await crmStorage.updateProduct(Number(req.params.id), data);
|
|
if (!product) return res.status(404).json({ error: "Product not found" });
|
|
res.json(product);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to update product" });
|
|
}
|
|
});
|
|
|
|
router.delete("/products/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteProduct(Number(req.params.id));
|
|
if (!deleted) return res.status(404).json({ error: "Product not found" });
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete product" });
|
|
}
|
|
});
|
|
|
|
// ========== CLIENTS ==========
|
|
router.get("/clients", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const user = req.user as any;
|
|
const allowedModules = user?.allowedModules || user?.profile?.allowedModules || [];
|
|
const isAdmin = user?.role === "admin" || allowedModules.includes("admin");
|
|
const partnerId = !isAdmin && user?.partnerId ? Number(user.partnerId) : undefined;
|
|
const userId = !isAdmin && user?.partnerId ? user.id : undefined;
|
|
const clients = await crmStorage.getClients(tenantId, partnerId, userId);
|
|
res.json(clients);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch clients" });
|
|
}
|
|
});
|
|
|
|
router.get("/clients/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const client = await crmStorage.getClient(Number(req.params.id));
|
|
if (!client) return res.status(404).json({ error: "Client not found" });
|
|
res.json(client);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch client" });
|
|
}
|
|
});
|
|
|
|
router.post("/clients", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmClientSchema.parse(req.body);
|
|
const client = await crmStorage.createClient(data);
|
|
res.status(201).json(client);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to create client" });
|
|
}
|
|
});
|
|
|
|
router.patch("/clients/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmClientSchema.partial().parse(req.body);
|
|
const client = await crmStorage.updateClient(Number(req.params.id), data);
|
|
if (!client) return res.status(404).json({ error: "Client not found" });
|
|
res.json(client);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to update client" });
|
|
}
|
|
});
|
|
|
|
router.delete("/clients/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteClient(Number(req.params.id));
|
|
if (!deleted) return res.status(404).json({ error: "Client not found" });
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete client" });
|
|
}
|
|
});
|
|
|
|
router.post("/partners/:id/convert-to-client", requireAuth, async (req, res) => {
|
|
try {
|
|
const partnerId = Number(req.params.id);
|
|
const additionalData = req.body || {};
|
|
const client = await crmStorage.convertPartnerToClient(partnerId, additionalData);
|
|
if (!client) return res.status(404).json({ error: "Partner not found" });
|
|
res.status(201).json(client);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to convert partner to client" });
|
|
}
|
|
});
|
|
|
|
// ========== PIPELINE STAGES ==========
|
|
router.get("/pipeline-stages", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const stages = await crmStorage.getPipelineStages(tenantId);
|
|
res.json(stages);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch pipeline stages" });
|
|
}
|
|
});
|
|
|
|
router.post("/pipeline-stages", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmPipelineStageSchema.parse(req.body);
|
|
const stage = await crmStorage.createPipelineStage(data);
|
|
res.status(201).json(stage);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to create pipeline stage" });
|
|
}
|
|
});
|
|
|
|
router.patch("/pipeline-stages/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmPipelineStageSchema.partial().parse(req.body);
|
|
const stage = await crmStorage.updatePipelineStage(Number(req.params.id), data);
|
|
if (!stage) return res.status(404).json({ error: "Stage not found" });
|
|
res.json(stage);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to update pipeline stage" });
|
|
}
|
|
});
|
|
|
|
router.delete("/pipeline-stages/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deletePipelineStage(Number(req.params.id));
|
|
if (!deleted) return res.status(404).json({ error: "Stage not found" });
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete pipeline stage" });
|
|
}
|
|
});
|
|
|
|
// ========== LEADS ==========
|
|
router.get("/leads", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const status = req.query.status as string | undefined;
|
|
const leads = await crmStorage.getLeads(tenantId, status);
|
|
res.json(leads);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch leads" });
|
|
}
|
|
});
|
|
|
|
router.get("/leads/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const lead = await crmStorage.getLead(Number(req.params.id));
|
|
if (!lead) return res.status(404).json({ error: "Lead not found" });
|
|
res.json(lead);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch lead" });
|
|
}
|
|
});
|
|
|
|
router.post("/leads", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmLeadSchema.parse({ ...req.body, userId: req.user!.id });
|
|
const lead = await crmStorage.createLead(data);
|
|
res.status(201).json(lead);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to create lead" });
|
|
}
|
|
});
|
|
|
|
router.patch("/leads/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmLeadSchema.partial().parse(req.body);
|
|
const lead = await crmStorage.updateLead(Number(req.params.id), data);
|
|
if (!lead) return res.status(404).json({ error: "Lead not found" });
|
|
res.json(lead);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to update lead" });
|
|
}
|
|
});
|
|
|
|
router.delete("/leads/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteLead(Number(req.params.id));
|
|
if (!deleted) return res.status(404).json({ error: "Lead not found" });
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete lead" });
|
|
}
|
|
});
|
|
|
|
router.post("/leads/:id/convert", requireAuth, async (req, res) => {
|
|
try {
|
|
const leadId = Number(req.params.id);
|
|
const lead = await crmStorage.getLead(leadId);
|
|
if (!lead) return res.status(404).json({ error: "Lead not found" });
|
|
|
|
const opportunityData = insertCrmOpportunitySchema.parse({
|
|
tenantId: lead.tenantId,
|
|
userId: req.user!.id,
|
|
name: req.body.name || `Oportunidade - ${lead.name}`,
|
|
description: req.body.description || lead.notes,
|
|
stageId: req.body.stageId,
|
|
value: req.body.value || 0,
|
|
expectedCloseDate: req.body.expectedCloseDate,
|
|
assignedTo: lead.assignedTo || req.user!.id,
|
|
});
|
|
|
|
const opportunity = await crmStorage.convertLeadToOpportunity(leadId, opportunityData);
|
|
res.status(201).json(opportunity);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to convert lead" });
|
|
}
|
|
});
|
|
|
|
// ========== OPPORTUNITIES ==========
|
|
router.get("/opportunities", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const stageId = req.query.stageId ? Number(req.query.stageId) : undefined;
|
|
const status = req.query.status as string | undefined;
|
|
const opportunities = await crmStorage.getOpportunities(tenantId, stageId, status);
|
|
res.json(opportunities);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch opportunities" });
|
|
}
|
|
});
|
|
|
|
router.get("/opportunities/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const opportunity = await crmStorage.getOpportunity(Number(req.params.id));
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
res.json(opportunity);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch opportunity" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmOpportunitySchema.parse({ ...req.body, userId: req.user!.id });
|
|
const opportunity = await crmStorage.createOpportunity(data);
|
|
res.status(201).json(opportunity);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to create opportunity" });
|
|
}
|
|
});
|
|
|
|
router.patch("/opportunities/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmOpportunitySchema.partial().parse(req.body);
|
|
const opportunity = await crmStorage.updateOpportunity(Number(req.params.id), data);
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
res.json(opportunity);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to update opportunity" });
|
|
}
|
|
});
|
|
|
|
router.delete("/opportunities/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteOpportunity(Number(req.params.id));
|
|
if (!deleted) return res.status(404).json({ error: "Opportunity not found" });
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete opportunity" });
|
|
}
|
|
});
|
|
|
|
router.patch("/opportunities/:id/stage", requireAuth, async (req, res) => {
|
|
try {
|
|
const { stageId } = req.body;
|
|
if (!stageId) return res.status(400).json({ error: "stageId is required" });
|
|
const opportunity = await crmStorage.moveOpportunityToStage(Number(req.params.id), stageId);
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
res.json(opportunity);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to move opportunity" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities/:id/won", requireAuth, async (req, res) => {
|
|
try {
|
|
const opportunity = await crmStorage.updateOpportunity(Number(req.params.id), { status: "won" });
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
res.json(opportunity);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to mark opportunity as won" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities/:id/lost", requireAuth, async (req, res) => {
|
|
try {
|
|
const { lossReason } = req.body;
|
|
const opportunity = await crmStorage.updateOpportunity(Number(req.params.id), { status: "lost", lossReason });
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
res.json(opportunity);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to mark opportunity as lost" });
|
|
}
|
|
});
|
|
|
|
// ========== OPPORTUNITY PRODUCTS ==========
|
|
router.get("/opportunities/:id/products", requireAuth, async (req, res) => {
|
|
try {
|
|
const products = await crmStorage.getOpportunityProducts(Number(req.params.id));
|
|
res.json(products);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch opportunity products" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities/:id/products", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmOpportunityProductSchema.parse({ ...req.body, opportunityId: Number(req.params.id) });
|
|
const product = await crmStorage.addProductToOpportunity(data);
|
|
res.status(201).json(product);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) return res.status(400).json({ error: error.errors });
|
|
res.status(500).json({ error: "Failed to add product to opportunity" });
|
|
}
|
|
});
|
|
|
|
router.delete("/opportunity-products/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.removeProductFromOpportunity(Number(req.params.id));
|
|
if (!deleted) return res.status(404).json({ error: "Product not found" });
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to remove product from opportunity" });
|
|
}
|
|
});
|
|
|
|
// ========== OPPORTUNITY APPROVAL & PROJECT CREATION ==========
|
|
router.post("/opportunities/:id/approve", requireAuth, async (req, res) => {
|
|
try {
|
|
const opportunity = await crmStorage.getOpportunity(Number(req.params.id));
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
|
|
if (opportunity.approvalStatus === "approved") {
|
|
return res.status(400).json({ error: "Opportunity already approved" });
|
|
}
|
|
|
|
const updated = await crmStorage.updateOpportunity(Number(req.params.id), {
|
|
approvalStatus: "approved",
|
|
approvedAt: new Date(),
|
|
approvedBy: req.user!.id,
|
|
status: "won"
|
|
});
|
|
|
|
res.json(updated);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to approve opportunity" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities/:id/reject", requireAuth, async (req, res) => {
|
|
try {
|
|
const { reason } = req.body;
|
|
const opportunity = await crmStorage.getOpportunity(Number(req.params.id));
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
|
|
const updated = await crmStorage.updateOpportunity(Number(req.params.id), {
|
|
approvalStatus: "rejected",
|
|
lossReason: reason,
|
|
status: "lost"
|
|
});
|
|
|
|
res.json(updated);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to reject opportunity" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities/:id/open-project", requireAuth, async (req, res) => {
|
|
try {
|
|
const opportunity = await crmStorage.getOpportunity(Number(req.params.id));
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
|
|
if (opportunity.approvalStatus !== "approved") {
|
|
return res.status(400).json({ error: "Opportunity must be approved before opening a project" });
|
|
}
|
|
|
|
if (opportunity.processCompassProjectId) {
|
|
return res.status(400).json({ error: "Project already created for this opportunity" });
|
|
}
|
|
|
|
const { tenantId, clientId } = req.body;
|
|
if (!tenantId || !clientId) {
|
|
return res.status(400).json({ error: "tenantId and clientId are required" });
|
|
}
|
|
|
|
const project = await crmStorage.createProcessCompassProject({
|
|
tenantId,
|
|
clientId,
|
|
userId: req.user!.id,
|
|
name: `Projeto: ${opportunity.name}`,
|
|
description: opportunity.description || `Projeto originado da oportunidade #${opportunity.id}`,
|
|
status: "backlog"
|
|
});
|
|
|
|
const updated = await crmStorage.updateOpportunity(Number(req.params.id), {
|
|
processCompassProjectId: project.id
|
|
});
|
|
|
|
res.json({ opportunity: updated, project });
|
|
} catch (error) {
|
|
console.error("Error creating project:", error);
|
|
res.status(500).json({ error: "Failed to create project" });
|
|
}
|
|
});
|
|
|
|
router.post("/opportunities/:id/bill", requireAuth, async (req, res) => {
|
|
try {
|
|
const opportunity = await crmStorage.getOpportunity(Number(req.params.id));
|
|
if (!opportunity) return res.status(404).json({ error: "Opportunity not found" });
|
|
|
|
if (opportunity.approvalStatus !== "approved") {
|
|
return res.status(400).json({ error: "Opportunity must be approved before billing" });
|
|
}
|
|
|
|
const updated = await crmStorage.updateOpportunity(Number(req.params.id), {
|
|
billingStatus: "pending"
|
|
});
|
|
|
|
res.json(updated);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update billing status" });
|
|
}
|
|
});
|
|
|
|
// ========== CRM STATS ==========
|
|
router.get("/stats/sales", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const stats = await crmStorage.getCrmStats(tenantId);
|
|
res.json(stats);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch CRM stats" });
|
|
}
|
|
});
|
|
|
|
// ========== Frappe Connector Routes ==========
|
|
router.get("/frappe/connectors", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.query.tenantId ? Number(req.query.tenantId) : undefined;
|
|
const connectors = await crmStorage.getFrappeConnectors(tenantId);
|
|
const safeConnectors = connectors.map(({ apiKey, apiSecret, ...rest }) => ({
|
|
...rest,
|
|
hasCredentials: Boolean(apiKey && apiSecret),
|
|
}));
|
|
res.json(safeConnectors);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch Frappe connectors" });
|
|
}
|
|
});
|
|
|
|
router.get("/frappe/connectors/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const connector = await crmStorage.getFrappeConnector(Number(req.params.id));
|
|
if (!connector) {
|
|
return res.status(404).json({ error: "Connector not found" });
|
|
}
|
|
const { apiKey, apiSecret, ...safeConnector } = connector;
|
|
res.json({ ...safeConnector, hasCredentials: Boolean(apiKey && apiSecret) });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch connector" });
|
|
}
|
|
});
|
|
|
|
router.post("/frappe/connectors", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmFrappeConnectorSchema.parse(req.body);
|
|
const connector = await crmStorage.createFrappeConnector(data);
|
|
const { apiKey, apiSecret, ...safeConnector } = connector;
|
|
res.status(201).json({ ...safeConnector, hasCredentials: true });
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create connector" });
|
|
}
|
|
});
|
|
|
|
router.patch("/frappe/connectors/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmFrappeConnectorSchema.partial().parse(req.body);
|
|
const connector = await crmStorage.updateFrappeConnector(Number(req.params.id), data);
|
|
if (!connector) {
|
|
return res.status(404).json({ error: "Connector not found" });
|
|
}
|
|
const { apiKey, apiSecret, ...safeConnector } = connector;
|
|
res.json({ ...safeConnector, hasCredentials: Boolean(apiKey && apiSecret) });
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to update connector" });
|
|
}
|
|
});
|
|
|
|
router.delete("/frappe/connectors/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteFrappeConnector(Number(req.params.id));
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Connector not found" });
|
|
}
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete connector" });
|
|
}
|
|
});
|
|
|
|
router.post("/frappe/connectors/:id/test", requireAuth, async (req, res) => {
|
|
try {
|
|
const frappeService = await createFrappeService(Number(req.params.id));
|
|
if (!frappeService) {
|
|
return res.status(404).json({ error: "Connector not found" });
|
|
}
|
|
const result = await frappeService.testConnection();
|
|
|
|
await crmStorage.updateFrappeConnector(Number(req.params.id), {
|
|
status: result.success ? "active" : "error",
|
|
errorMessage: result.error || null,
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (error: any) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.post("/frappe/connectors/:id/sync", requireAuth, async (req, res) => {
|
|
try {
|
|
const connectorId = Number(req.params.id);
|
|
const { entities } = req.body;
|
|
|
|
const frappeService = await createFrappeService(connectorId);
|
|
if (!frappeService) {
|
|
return res.status(404).json({ error: "Connector not found" });
|
|
}
|
|
|
|
const syncLog = await crmStorage.createSyncLog({
|
|
connectorId,
|
|
syncType: "push",
|
|
status: "running",
|
|
});
|
|
|
|
const results: Record<string, any> = {};
|
|
let totalSuccess = 0;
|
|
let totalFailed = 0;
|
|
const allErrors: string[] = [];
|
|
|
|
if (!entities || entities.includes("leads")) {
|
|
const leads = await crmStorage.getLeads();
|
|
const leadResult = await frappeService.syncLeadsToFrappe(leads);
|
|
results.leads = leadResult;
|
|
totalSuccess += leadResult.success;
|
|
totalFailed += leadResult.failed;
|
|
allErrors.push(...leadResult.errors);
|
|
}
|
|
|
|
if (!entities || entities.includes("opportunities")) {
|
|
const opportunities = await crmStorage.getOpportunities();
|
|
const oppResult = await frappeService.syncOpportunitiesToFrappe(opportunities);
|
|
results.opportunities = oppResult;
|
|
totalSuccess += oppResult.success;
|
|
totalFailed += oppResult.failed;
|
|
allErrors.push(...oppResult.errors);
|
|
}
|
|
|
|
if (!entities || entities.includes("products")) {
|
|
const products = await crmStorage.getProducts();
|
|
const productResult = await frappeService.syncProductsToFrappe(products);
|
|
results.products = productResult;
|
|
totalSuccess += productResult.success;
|
|
totalFailed += productResult.failed;
|
|
allErrors.push(...productResult.errors);
|
|
}
|
|
|
|
if (!entities || entities.includes("partners")) {
|
|
const partners = await crmStorage.getPartners();
|
|
const partnerResult = await frappeService.syncPartnersToFrappe(partners);
|
|
results.partners = partnerResult;
|
|
totalSuccess += partnerResult.success;
|
|
totalFailed += partnerResult.failed;
|
|
allErrors.push(...partnerResult.errors);
|
|
}
|
|
|
|
await crmStorage.updateSyncLog(syncLog.id, {
|
|
status: totalFailed > 0 ? "completed_with_errors" : "completed",
|
|
recordsProcessed: totalSuccess + totalFailed,
|
|
recordsSuccess: totalSuccess,
|
|
recordsFailed: totalFailed,
|
|
errorDetails: allErrors.length > 0 ? allErrors.join("\n") : null,
|
|
completedAt: new Date(),
|
|
});
|
|
|
|
await crmStorage.updateFrappeConnector(connectorId, {
|
|
lastSyncAt: new Date(),
|
|
});
|
|
|
|
res.json({ success: true, results, totalSuccess, totalFailed });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get("/frappe/connectors/:id/logs", requireAuth, async (req, res) => {
|
|
try {
|
|
const logs = await crmStorage.getSyncLogs(Number(req.params.id));
|
|
res.json(logs);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch sync logs" });
|
|
}
|
|
});
|
|
|
|
router.get("/frappe/connectors/:id/mappings", requireAuth, async (req, res) => {
|
|
try {
|
|
const mappings = await crmStorage.getFrappeMappings(Number(req.params.id));
|
|
res.json(mappings);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch mappings" });
|
|
}
|
|
});
|
|
|
|
router.post("/frappe/connectors/:id/mappings", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmFrappeMappingSchema.parse({
|
|
...req.body,
|
|
connectorId: Number(req.params.id),
|
|
});
|
|
const mapping = await crmStorage.createFrappeMapping(data);
|
|
res.status(201).json(mapping);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create mapping" });
|
|
}
|
|
});
|
|
|
|
// ========== PROPOSALS (PROPOSTAS COMERCIAIS) ==========
|
|
router.get("/proposals", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.headers["x-tenant-id"] ? Number(req.headers["x-tenant-id"]) : 1;
|
|
const proposals = await crmStorage.getProposals(tenantId);
|
|
res.json(proposals);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch proposals" });
|
|
}
|
|
});
|
|
|
|
router.get("/proposals/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.headers["x-tenant-id"] ? Number(req.headers["x-tenant-id"]) : 1;
|
|
const proposal = await crmStorage.getProposal(Number(req.params.id), tenantId);
|
|
if (!proposal) {
|
|
return res.status(404).json({ error: "Proposal not found" });
|
|
}
|
|
res.json(proposal);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch proposal" });
|
|
}
|
|
});
|
|
|
|
router.get("/opportunities/:id/proposals", requireAuth, async (req, res) => {
|
|
try {
|
|
const proposals = await crmStorage.getProposalsByOpportunity(Number(req.params.id));
|
|
res.json(proposals);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch proposals" });
|
|
}
|
|
});
|
|
|
|
router.post("/proposals", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.headers["x-tenant-id"] ? Number(req.headers["x-tenant-id"]) : 1;
|
|
const userId = (req.user as any)?.id;
|
|
const data = insertCrmProposalSchema.parse({ ...req.body, tenantId, createdById: userId });
|
|
const proposal = await crmStorage.createProposal(data);
|
|
res.status(201).json(proposal);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create proposal" });
|
|
}
|
|
});
|
|
|
|
router.patch("/proposals/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.headers["x-tenant-id"] ? Number(req.headers["x-tenant-id"]) : 1;
|
|
const proposal = await crmStorage.updateProposal(Number(req.params.id), tenantId, req.body);
|
|
if (!proposal) {
|
|
return res.status(404).json({ error: "Proposal not found" });
|
|
}
|
|
res.json(proposal);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update proposal" });
|
|
}
|
|
});
|
|
|
|
router.delete("/proposals/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const tenantId = req.headers["x-tenant-id"] ? Number(req.headers["x-tenant-id"]) : 1;
|
|
const deleted = await crmStorage.deleteProposal(Number(req.params.id), tenantId);
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Proposal not found" });
|
|
}
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete proposal" });
|
|
}
|
|
});
|
|
|
|
// ========== PROPOSAL ITEMS ==========
|
|
router.get("/proposals/:id/items", requireAuth, async (req, res) => {
|
|
try {
|
|
const items = await crmStorage.getProposalItems(Number(req.params.id));
|
|
res.json(items);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch proposal items" });
|
|
}
|
|
});
|
|
|
|
router.post("/proposals/:id/items", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmProposalItemSchema.parse({ ...req.body, proposalId: Number(req.params.id) });
|
|
const item = await crmStorage.createProposalItem(data);
|
|
res.status(201).json(item);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create proposal item" });
|
|
}
|
|
});
|
|
|
|
router.patch("/proposal-items/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const item = await crmStorage.updateProposalItem(Number(req.params.id), req.body);
|
|
if (!item) {
|
|
return res.status(404).json({ error: "Proposal item not found" });
|
|
}
|
|
res.json(item);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update proposal item" });
|
|
}
|
|
});
|
|
|
|
router.delete("/proposal-items/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteProposalItem(Number(req.params.id));
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Proposal item not found" });
|
|
}
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete proposal item" });
|
|
}
|
|
});
|
|
|
|
// ========== CONTRACT MILESTONES ==========
|
|
router.get("/contracts/:id/milestones", requireAuth, async (req, res) => {
|
|
try {
|
|
const milestones = await crmStorage.getContractMilestones(Number(req.params.id));
|
|
res.json(milestones);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fetch contract milestones" });
|
|
}
|
|
});
|
|
|
|
router.post("/contracts/:id/milestones", requireAuth, async (req, res) => {
|
|
try {
|
|
const data = insertCrmContractMilestoneSchema.parse({ ...req.body, contractId: Number(req.params.id) });
|
|
const milestone = await crmStorage.createContractMilestone(data);
|
|
res.status(201).json(milestone);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors });
|
|
}
|
|
res.status(500).json({ error: "Failed to create milestone" });
|
|
}
|
|
});
|
|
|
|
router.patch("/milestones/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const milestone = await crmStorage.updateContractMilestone(Number(req.params.id), req.body);
|
|
if (!milestone) {
|
|
return res.status(404).json({ error: "Milestone not found" });
|
|
}
|
|
res.json(milestone);
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to update milestone" });
|
|
}
|
|
});
|
|
|
|
router.delete("/milestones/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const deleted = await crmStorage.deleteContractMilestone(Number(req.params.id));
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Milestone not found" });
|
|
}
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to delete milestone" });
|
|
}
|
|
});
|
|
|
|
export default router;
|