diff --git a/README.md b/README.md index aaa9674..183f077 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ Create a `.env` file based on the following template: # Database Configuration DATABASE_URL="postgresql://postgres:password@localhost:5432/bottlecrm?schema=public" +# JWT Secret (required for authentication) +# Generate a secure secret using openssl: +# openssl rand -base64 32 +JWT_SECRET="" + # Google OAuth (Optional) GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" diff --git a/api/routes/leads.js b/api/routes/leads.js index 53fd957..72de36e 100644 --- a/api/routes/leads.js +++ b/api/routes/leads.js @@ -159,6 +159,39 @@ router.get('/metadata', async (req, res) => { * schema: * type: integer * default: 10 + * - in: query + * name: search + * schema: + * type: string + * description: Search by name, email, or company + * - in: query + * name: status + * schema: + * type: string + * enum: [NEW, PENDING, CONTACTED, QUALIFIED, UNQUALIFIED, CONVERTED] + * description: Filter by lead status + * - in: query + * name: leadSource + * schema: + * type: string + * enum: [WEB, PHONE_INQUIRY, PARTNER_REFERRAL, COLD_CALL, TRADE_SHOW, EMPLOYEE_REFERRAL, ADVERTISEMENT, OTHER] + * description: Filter by lead source + * - in: query + * name: industry + * schema: + * type: string + * description: Filter by industry + * - in: query + * name: rating + * schema: + * type: string + * enum: [Hot, Warm, Cold] + * description: Filter by rating + * - in: query + * name: converted + * schema: + * type: boolean + * description: Filter by conversion status * responses: * 200: * description: List of leads @@ -179,10 +212,82 @@ router.get('/', async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; + + const { + search, + status, + leadSource, + industry, + rating, + converted + } = req.query; + + // Build where clause for filtering + let whereClause = { + organizationId: req.organizationId + }; + + // Add search filter (search in firstName, lastName, email, company) + if (search) { + whereClause.OR = [ + { + firstName: { + contains: search, + mode: 'insensitive' + } + }, + { + lastName: { + contains: search, + mode: 'insensitive' + } + }, + { + email: { + contains: search, + mode: 'insensitive' + } + }, + { + company: { + contains: search, + mode: 'insensitive' + } + } + ]; + } + + // Add status filter + if (status) { + whereClause.status = status; + } + + // Add leadSource filter + if (leadSource) { + whereClause.leadSource = leadSource; + } + + // Add industry filter + if (industry) { + whereClause.industry = { + contains: industry, + mode: 'insensitive' + }; + } + + // Add rating filter + if (rating) { + whereClause.rating = rating; + } + + // Add converted filter + if (converted !== undefined) { + whereClause.isConverted = converted === 'true'; + } const [leads, total] = await Promise.all([ prisma.lead.findMany({ - where: { organizationId: req.organizationId }, + where: whereClause, skip, take: limit, orderBy: { createdAt: 'desc' }, @@ -193,17 +298,25 @@ router.get('/', async (req, res) => { } }), prisma.lead.count({ - where: { organizationId: req.organizationId } + where: whereClause }) ]); + // Calculate pagination info + const totalPages = Math.ceil(total / limit); + const hasNext = page < totalPages; + const hasPrev = page > 1; + res.json({ + success: true, leads, pagination: { page, limit, total, - pages: Math.ceil(total / limit) + totalPages, + hasNext, + hasPrev } }); } catch (error) { @@ -297,10 +410,21 @@ router.get('/:id', async (req, res) => { * type: string * company: * type: string + * title: + * type: string * status: * type: string + * enum: [NEW, PENDING, CONTACTED, QUALIFIED, UNQUALIFIED, CONVERTED] * leadSource: * type: string + * enum: [WEB, PHONE_INQUIRY, PARTNER_REFERRAL, COLD_CALL, TRADE_SHOW, EMPLOYEE_REFERRAL, ADVERTISEMENT, OTHER] + * industry: + * type: string + * rating: + * type: string + * enum: [Hot, Warm, Cold] + * description: + * type: string * responses: * 201: * description: Lead created successfully @@ -309,21 +433,61 @@ router.get('/:id', async (req, res) => { */ router.post('/', async (req, res) => { try { - const { firstName, lastName, email, phone, company, status, leadSource } = req.body; + const { + firstName, + lastName, + email, + phone, + company, + title, + status, + leadSource, + industry, + rating, + description + } = req.body; if (!firstName || !lastName || !email) { return res.status(400).json({ error: 'First name, last name, and email are required' }); } + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + // Validate status if provided + const validStatuses = ['NEW', 'PENDING', 'CONTACTED', 'QUALIFIED', 'UNQUALIFIED', 'CONVERTED']; + if (status && !validStatuses.includes(status)) { + return res.status(400).json({ error: 'Invalid status value' }); + } + + // Validate leadSource if provided + const validSources = ['WEB', 'PHONE_INQUIRY', 'PARTNER_REFERRAL', 'COLD_CALL', 'TRADE_SHOW', 'EMPLOYEE_REFERRAL', 'ADVERTISEMENT', 'OTHER']; + if (leadSource && !validSources.includes(leadSource)) { + return res.status(400).json({ error: 'Invalid lead source value' }); + } + + // Validate rating if provided + const validRatings = ['Hot', 'Warm', 'Cold']; + if (rating && !validRatings.includes(rating)) { + return res.status(400).json({ error: 'Invalid rating value' }); + } + const lead = await prisma.lead.create({ data: { - firstName, - lastName, - email, - phone, - company, - status: status || 'NEW', - leadSource, + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email.trim().toLowerCase(), + phone: phone?.trim() || null, + company: company?.trim() || null, + title: title?.trim() || null, + status: status || 'PENDING', + leadSource: leadSource || null, + industry: industry?.trim() || null, + rating: rating || null, + description: description?.trim() || null, organizationId: req.organizationId, ownerId: req.userId }, @@ -337,6 +501,9 @@ router.post('/', async (req, res) => { res.status(201).json(lead); } catch (error) { console.error('Create lead error:', error); + if (error.code === 'P2002') { + return res.status(409).json({ error: 'A lead with this email already exists in this organization' }); + } res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/api/routes/organizations.js b/api/routes/organizations.js new file mode 100644 index 0000000..5fcdaff --- /dev/null +++ b/api/routes/organizations.js @@ -0,0 +1,390 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +/** + * @swagger + * components: + * schemas: + * Organization: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * domain: + * type: string + * logo: + * type: string + * website: + * type: string + * industry: + * type: string + * description: + * type: string + * isActive: + * type: boolean + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * userRole: + * type: string + * enum: [ADMIN, USER] + * CreateOrganizationRequest: + * type: object + * required: + * - name + * properties: + * name: + * type: string + * domain: + * type: string + * logo: + * type: string + * website: + * type: string + * industry: + * type: string + * description: + * type: string + */ + +/** + * @swagger + * /organizations: + * get: + * summary: Get organizations list for the authenticated user + * tags: [Organizations] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: Page number for pagination + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 10 + * description: Number of organizations per page + * - in: query + * name: search + * schema: + * type: string + * description: Search term to filter organizations by name + * - in: query + * name: industry + * schema: + * type: string + * description: Filter by industry + * - in: query + * name: active + * schema: + * type: boolean + * description: Filter by active status + * responses: + * 200: + * description: List of organizations + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * organizations: + * type: array + * items: + * $ref: '#/components/schemas/Organization' + * pagination: + * type: object + * properties: + * page: + * type: integer + * limit: + * type: integer + * total: + * type: integer + * totalPages: + * type: integer + * hasNext: + * type: boolean + * hasPrev: + * type: boolean + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get('/', verifyToken, async (req, res) => { + try { + const userId = req.userId; + const { + page = 1, + limit = 10, + search, + industry, + active + } = req.query; + + // Validate pagination parameters + const pageNum = Math.max(1, parseInt(page) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 10)); + const skip = (pageNum - 1) * limitNum; + + // Build where clause for filtering + let whereClause = { + users: { + some: { + userId: userId + } + } + }; + + // Add search filter + if (search) { + whereClause.name = { + contains: search, + mode: 'insensitive' + }; + } + + // Add industry filter + if (industry) { + whereClause.industry = { + contains: industry, + mode: 'insensitive' + }; + } + + // Add active status filter + if (active !== undefined) { + whereClause.isActive = active === 'true'; + } + + // Get organizations with user role + const [organizations, totalCount] = await Promise.all([ + prisma.organization.findMany({ + where: whereClause, + include: { + users: { + where: { + userId: userId + }, + select: { + role: true, + joinedAt: true + } + } + }, + orderBy: { + name: 'asc' + }, + skip: skip, + take: limitNum + }), + prisma.organization.count({ + where: whereClause + }) + ]); + + // Format response + const formattedOrganizations = organizations.map(org => ({ + id: org.id, + name: org.name, + domain: org.domain, + logo: org.logo, + website: org.website, + industry: org.industry, + description: org.description, + isActive: org.isActive, + createdAt: org.createdAt, + updatedAt: org.updatedAt, + userRole: org.users[0]?.role || 'USER', + joinedAt: org.users[0]?.joinedAt + })); + + // Calculate pagination info + const totalPages = Math.ceil(totalCount / limitNum); + const hasNext = pageNum < totalPages; + const hasPrev = pageNum > 1; + + res.json({ + success: true, + organizations: formattedOrganizations, + pagination: { + page: pageNum, + limit: limitNum, + total: totalCount, + totalPages, + hasNext, + hasPrev + } + }); + + } catch (error) { + console.error('Organizations list error:', error); + res.status(500).json({ + success: false, + error: 'Internal server error' + }); + } +}); + +/** + * @swagger + * /organizations: + * post: + * summary: Create a new organization + * tags: [Organizations] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateOrganizationRequest' + * responses: + * 201: + * description: Organization created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * organization: + * $ref: '#/components/schemas/Organization' + * 400: + * description: Bad request - validation error + * 401: + * description: Unauthorized + * 409: + * description: Organization with this name already exists + * 500: + * description: Internal server error + */ +router.post('/', verifyToken, async (req, res) => { + try { + const userId = req.userId; + const { + name, + domain, + logo, + website, + industry, + description + } = req.body; + + // Validate required fields + if (!name || !name.trim()) { + return res.status(400).json({ + success: false, + error: 'Organization name is required' + }); + } + + // Check if organization with this name already exists + const existingOrg = await prisma.organization.findFirst({ + where: { + name: { + equals: name.trim(), + mode: 'insensitive' + } + } + }); + + if (existingOrg) { + return res.status(409).json({ + success: false, + error: 'Organization with this name already exists' + }); + } + + // Validate website URL format if provided + if (website && website.trim()) { + try { + new URL(website.trim()); + } catch (error) { + return res.status(400).json({ + success: false, + error: 'Invalid website URL format' + }); + } + } + + // Create organization and add user as admin + const organization = await prisma.organization.create({ + data: { + name: name.trim(), + domain: domain?.trim() || null, + logo: logo?.trim() || null, + website: website?.trim() || null, + industry: industry?.trim() || null, + description: description?.trim() || null, + users: { + create: { + userId: userId, + role: 'ADMIN' + } + } + }, + include: { + users: { + where: { + userId: userId + }, + select: { + role: true, + joinedAt: true + } + } + } + }); + + // Format response + const formattedOrganization = { + id: organization.id, + name: organization.name, + domain: organization.domain, + logo: organization.logo, + website: organization.website, + industry: organization.industry, + description: organization.description, + isActive: organization.isActive, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + userRole: organization.users[0]?.role || 'ADMIN', + joinedAt: organization.users[0]?.joinedAt + }; + + res.status(201).json({ + success: true, + organization: formattedOrganization + }); + + } catch (error) { + console.error('Organization creation error:', error); + res.status(500).json({ + success: false, + error: 'Internal server error' + }); + } +}); + +export default router; diff --git a/server.js b/server.js index 3ccacfa..b7150da 100644 --- a/server.js +++ b/server.js @@ -15,12 +15,13 @@ import accountRoutes from './api/routes/accounts.js'; import contactRoutes from './api/routes/contacts.js'; import opportunityRoutes from './api/routes/opportunities.js'; import taskRoutes from './api/routes/tasks.js'; +import organizationRoutes from './api/routes/organizations.js'; dotenv.config(); const app = express(); const logger = createLogger(); -const PORT = process.env.API_PORT || 3001; +const PORT = process.env.PORT || 3001; // Trust proxy setting for rate limiting app.set('trust proxy', 1); @@ -85,6 +86,7 @@ app.use('/accounts', accountRoutes); app.use('/contacts', contactRoutes); app.use('/opportunities', opportunityRoutes); app.use('/tasks', taskRoutes); +app.use('/organizations', organizationRoutes); app.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString() });