From 87645f18d62f113cbf84e764d7b07c1a28ff81de Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 15 Oct 2025 08:49:48 +0530 Subject: [PATCH 1/3] feat: implement branch management with CRUD operations and associated routes, controller, service, and migration --- server.js | 2 + src/__tests__/branch.test.js | 145 ++++++++++++++++++ src/controllers/branchController.js | 122 +++++++++++++++ .../20251014052343-create-branches-table.js | 45 ++++++ src/models/branch.js | 37 +++++ src/models/index.js | 3 +- src/routes/branch.js | 22 +++ src/services/branchService.js | 111 ++++++++++++++ 8 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/branch.test.js create mode 100644 src/controllers/branchController.js create mode 100644 src/database/migrations/20251014052343-create-branches-table.js create mode 100644 src/models/branch.js create mode 100644 src/routes/branch.js create mode 100644 src/services/branchService.js diff --git a/server.js b/server.js index cd42df2..a0e8aa4 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import healthRoutes from './src/routes/health.js'; import { setupSocket } from './src/socket/socket.js'; import issueRoutes from './src/routes/issues.js'; import userRoutes from './src/routes/users.js'; +import branchRoutes from './src/routes/branch.js'; const app = express(); const server = http.createServer(app); @@ -23,6 +24,7 @@ app.use('/api/health', healthRoutes); app.use('/api/v1/issues', issueRoutes); app.use('/api/v1/users', userRoutes); +app.use('/api/v1/branches', branchRoutes); // Basic route app.get('/api/', (req, res) => { diff --git a/src/__tests__/branch.test.js b/src/__tests__/branch.test.js new file mode 100644 index 0000000..d1ac2a5 --- /dev/null +++ b/src/__tests__/branch.test.js @@ -0,0 +1,145 @@ +// tests/branch.test.js +import request from 'supertest'; +import express from 'express'; +import { getSequelizeInstance } from '../services/connectionService.js'; +import branchRoutes from '../routes/branch.js'; +import Branch from '../models/branch.js'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/branches', branchRoutes); + +let sequelize; + +beforeAll(async () => { + sequelize = getSequelizeInstance(); + await sequelize.sync({ force: true }); +}); + +afterAll(async () => { + await sequelize.close(); +}); + +describe('Branch Endpoints', () => { + let branchId; + + describe('POST /api/v1/branches', () => { + it('should create a new branch', async () => { + const res = await request(app).post('/api/v1/branches').send({ + name: 'Colombo Branch', + location: 'Colombo 07', + manager_id: 10, + contactNumber: '0771234567' + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('id'); + expect(res.body.data.name).toBe('Colombo Branch'); + expect(res.body.data.location).toBe('Colombo 07'); + expect(res.body.data.manager_id).toBe(10); + expect(res.body.data.contactNumber).toBe('0771234567'); + + branchId = res.body.data.id; + }); + + it('should reject branch with missing name', async () => { + const res = await request(app).post('/api/v1/branches').send({ + location: 'Colombo 07', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Name and location are required'); + }); + + it('should reject branch with missing location', async () => { + const res = await request(app).post('/api/v1/branches').send({ + name: 'Test Branch', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Name and location are required'); + }); + }); + + describe('GET /api/v1/branches', () => { + it('should get all branches', async () => { + const res = await request(app).get('/api/v1/branches'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body).toHaveProperty('count'); + }); + }); + + describe('GET /api/v1/branches/:id', () => { + it('should get branch by valid ID', async () => { + const res = await request(app).get(`/api/v1/branches/${branchId}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.id).toBe(branchId); + expect(res.body.data.name).toBe('Colombo Branch'); + }); + + it('should return 404 for non-existent branch ID', async () => { + const res = await request(app).get('/api/v1/branches/9999'); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + }); + }); + + describe('PUT /api/v1/branches/:id', () => { + it('should update branch name and manager_id', async () => { + const res = await request(app).put(`/api/v1/branches/${branchId}`).send({ + name: 'Colombo Updated', + manager_id: 20 + }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe('Colombo Updated'); + expect(res.body.data.manager_id).toBe(20); + // Verify location was not changed + expect(res.body.data.location).toBe('Colombo 07'); + }); + + it('should return 404 for non-existent branch update', async () => { + const res = await request(app).put('/api/v1/branches/9999').send({ + name: 'No Branch' + }); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + }); + }); + + describe('DELETE /api/v1/branches/:id', () => { + it('should delete branch', async () => { + const res = await request(app).delete(`/api/v1/branches/${branchId}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe('Branch deleted successfully'); + }); + + it('should not find deleted branch', async () => { + const res = await request(app).get(`/api/v1/branches/${branchId}`); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + }); + + it('should return 404 when deleting non-existent branch', async () => { + const res = await request(app).delete('/api/v1/branches/9999'); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/controllers/branchController.js b/src/controllers/branchController.js new file mode 100644 index 0000000..e2da3a3 --- /dev/null +++ b/src/controllers/branchController.js @@ -0,0 +1,122 @@ +import branchService from '../services/branchService.js'; + +class BranchController { + // POST /api/v1/branches - Create a new branch + async addBranch(req, res) { + try { + const { name, location, manager_id, contactNumber } = req.body; + + // Validation + if (!name || !location) { + return res.status(400).json({ + success: false, + message: 'Name and location are required', + }); + } + + const result = await branchService.addBranch({ + name, + location, + manager_id, + contactNumber + }); + + if (result.success) { + return res.status(201).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + } + + // GET /api/v1/branches - Get all branches + async getAllBranches(req, res) { + try { + const result = await branchService.getAllBranches(); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + } + + // GET /api/v1/branches/:id - Get branch by ID + async getBranchById(req, res) { + try { + const { id } = req.params; + + const result = await branchService.getBranchById(parseInt(id)); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + } + + // PUT /api/v1/branches/:id - Update branch by ID + async updateBranch(req, res) { + try { + const { id } = req.params; + const { name, location } = req.body; + + const result = await branchService.updateBranch(parseInt(id), { name, location }); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + } + + // DELETE /api/v1/branches/:id - Delete branch by ID + async deleteBranch(req, res) { + try { + const { id } = req.params; + + const result = await branchService.deleteBranch(parseInt(id)); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + } +} + +export default new BranchController(); \ No newline at end of file diff --git a/src/database/migrations/20251014052343-create-branches-table.js b/src/database/migrations/20251014052343-create-branches-table.js new file mode 100644 index 0000000..9e8cdfd --- /dev/null +++ b/src/database/migrations/20251014052343-create-branches-table.js @@ -0,0 +1,45 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +export default { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('branches', { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + allowNull: false, + primaryKey: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + location: { + type: Sequelize.STRING, + allowNull: false, + }, + manager_id: { + type: Sequelize.INTEGER, + allowNull: true, + }, + contactNumber: { + type: Sequelize.STRING, + allowNull: true, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('NOW()'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('NOW()'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('branches'); + }, +}; \ No newline at end of file diff --git a/src/models/branch.js b/src/models/branch.js new file mode 100644 index 0000000..80a0522 --- /dev/null +++ b/src/models/branch.js @@ -0,0 +1,37 @@ +import { DataTypes } from 'sequelize'; +import { getSequelizeInstance } from '../services/connectionService.js'; + +const sequelize = getSequelizeInstance(); + +const Branch = sequelize.define( + 'Branch', + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + location: { + type: DataTypes.STRING, + allowNull: false, + }, + manager_id: { + type: DataTypes.INTEGER, + allowNull: true, + }, + contactNumber: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + tableName: 'branches', + timestamps: true, + } +); + +export default Branch; \ No newline at end of file diff --git a/src/models/index.js b/src/models/index.js index b2406ab..45caae0 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -1,6 +1,7 @@ import { getSequelizeInstance } from '../services/connectionService.js'; import Issue from './issue.js'; import User from './user.js'; +import Branch from './branch.js'; const sequelize = getSequelizeInstance(); @@ -18,4 +19,4 @@ const models = { // Note: No relationships defined yet to avoid conflicts during development export default models; -export { Issue, User, sequelize }; \ No newline at end of file +export { Issue, User, Branch, sequelize }; \ No newline at end of file diff --git a/src/routes/branch.js b/src/routes/branch.js new file mode 100644 index 0000000..d220806 --- /dev/null +++ b/src/routes/branch.js @@ -0,0 +1,22 @@ +// src/routes/branchRoutes.js +import { Router } from 'express'; +import branchController from '../controllers/branchController.js'; + +const router = Router(); + +// POST /api/v1/branches - Add a new branch +router.post('/', branchController.addBranch); + +// GET /api/v1/branches - Get all branches +router.get('/', branchController.getAllBranches); + +// GET /api/v1/branches/:id - Get branch by ID +router.get('/:id', branchController.getBranchById); + +// PUT /api/v1/branches/:id - Update branch by ID +router.put('/:id', branchController.updateBranch); + +// DELETE /api/v1/branches/:id - Delete branch by ID +router.delete('/:id', branchController.deleteBranch); + +export default router; \ No newline at end of file diff --git a/src/services/branchService.js b/src/services/branchService.js new file mode 100644 index 0000000..58e6472 --- /dev/null +++ b/src/services/branchService.js @@ -0,0 +1,111 @@ +import { Branch } from '../models/index.js'; + +class BranchService { + // Create a new branch + async addBranch(branchData) { + try { + console.log('branchData:', branchData); // Debug log + const branch = await Branch.create(branchData); + return { + success: true, + data: branch, + message: 'Branch created successfully' + }; + } catch (error) { + return { + success: false, + error: error.message, + message: 'Failed to create branch' + }; + } + } + + // Get all branches + async getAllBranches() { + try { + const branches = await Branch.findAll(); + return { + success: true, + data: branches, + count: branches.length, + message: 'Branches retrieved successfully' + }; + } catch (error) { + return { + success: false, + error: error.message, + message: 'Failed to retrieve branches' + }; + } + } + + // Get single branch by ID + async getBranchById(id) { + try { + const branch = await Branch.findByPk(id); + if (!branch) { + return { success: false, message: 'Branch not found' }; + } + return { + success: true, + data: branch, + message: 'Branch retrieved successfully' + }; + } catch (error) { + return { + success: false, + error: error.message, + message: 'Failed to retrieve branch' + }; + } + } + + // Update a branch + async updateBranch(id, updateData) { + try { + const branch = await Branch.findByPk(id); + if (!branch) { + return { success: false, message: 'Branch not found' }; + } + + await branch.update(updateData); + + return { + success: true, + data: branch, + message: 'Branch updated successfully' + }; + } catch (error) { + return { + success: false, + error: error.message, + message: 'Failed to update branch' + }; + } + } + + // Delete a branch + async deleteBranch(id) { + try { + const branch = await Branch.findByPk(id); + if (!branch) { + return { success: false, message: 'Branch not found' }; + } + + await branch.destroy(); + + return { + success: true, + message: 'Branch deleted successfully' + }; + } catch (error) { + return { + success: false, + error: error.message, + message: 'Failed to delete branch' + }; + } + } +} + +export default new BranchService(); \ No newline at end of file From 342993a99c3a80fdd8562c029143ecf420d55013 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 15 Oct 2025 09:30:36 +0530 Subject: [PATCH 2/3] feat: remove contactNumber field from branches model, migration, and tests --- src/__tests__/branch.test.js | 4 ---- src/controllers/branchController.js | 17 +++++++++++------ ...34439-remove-contact-number-from-branches.js | 15 +++++++++++++++ src/models/branch.js | 4 ---- 4 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 src/database/migrations/20251015034439-remove-contact-number-from-branches.js diff --git a/src/__tests__/branch.test.js b/src/__tests__/branch.test.js index d1ac2a5..220be70 100644 --- a/src/__tests__/branch.test.js +++ b/src/__tests__/branch.test.js @@ -29,7 +29,6 @@ describe('Branch Endpoints', () => { name: 'Colombo Branch', location: 'Colombo 07', manager_id: 10, - contactNumber: '0771234567' }); expect(res.statusCode).toBe(201); @@ -38,7 +37,6 @@ describe('Branch Endpoints', () => { expect(res.body.data.name).toBe('Colombo Branch'); expect(res.body.data.location).toBe('Colombo 07'); expect(res.body.data.manager_id).toBe(10); - expect(res.body.data.contactNumber).toBe('0771234567'); branchId = res.body.data.id; }); @@ -72,7 +70,6 @@ describe('Branch Endpoints', () => { expect(res.body.success).toBe(true); expect(Array.isArray(res.body.data)).toBe(true); expect(res.body.data.length).toBeGreaterThan(0); - expect(res.body).toHaveProperty('count'); }); }); @@ -105,7 +102,6 @@ describe('Branch Endpoints', () => { expect(res.body.success).toBe(true); expect(res.body.data.name).toBe('Colombo Updated'); expect(res.body.data.manager_id).toBe(20); - // Verify location was not changed expect(res.body.data.location).toBe('Colombo 07'); }); diff --git a/src/controllers/branchController.js b/src/controllers/branchController.js index e2da3a3..ef5cd17 100644 --- a/src/controllers/branchController.js +++ b/src/controllers/branchController.js @@ -4,7 +4,7 @@ class BranchController { // POST /api/v1/branches - Create a new branch async addBranch(req, res) { try { - const { name, location, manager_id, contactNumber } = req.body; + const { name, location, manager_id } = req.body; // Removed contactNumber // Validation if (!name || !location) { @@ -17,8 +17,8 @@ class BranchController { const result = await branchService.addBranch({ name, location, - manager_id, - contactNumber + manager_id + // contactNumber removed }); if (result.success) { @@ -79,14 +79,19 @@ class BranchController { async updateBranch(req, res) { try { const { id } = req.params; - const { name, location } = req.body; + const { name, location, manager_id } = req.body; // Removed contactNumber - const result = await branchService.updateBranch(parseInt(id), { name, location }); + const result = await branchService.updateBranch(id, { + name, + location, + manager_id + // contactNumber removed + }); if (result.success) { return res.status(200).json(result); } else { - return res.status(400).json(result); + return res.status(404).json(result); } } catch (error) { return res.status(500).json({ diff --git a/src/database/migrations/20251015034439-remove-contact-number-from-branches.js b/src/database/migrations/20251015034439-remove-contact-number-from-branches.js new file mode 100644 index 0000000..8aaa8d0 --- /dev/null +++ b/src/database/migrations/20251015034439-remove-contact-number-from-branches.js @@ -0,0 +1,15 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +export default { + async up(queryInterface, Sequelize) { + await queryInterface.removeColumn('branches', 'contactNumber'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.addColumn('branches', 'contactNumber', { + type: Sequelize.STRING, + allowNull: true, + }); + }, +}; diff --git a/src/models/branch.js b/src/models/branch.js index 80a0522..7c94256 100644 --- a/src/models/branch.js +++ b/src/models/branch.js @@ -23,10 +23,6 @@ const Branch = sequelize.define( type: DataTypes.INTEGER, allowNull: true, }, - contactNumber: { - type: DataTypes.STRING, - allowNull: true, - }, }, { tableName: 'branches', From 98e2ce8301c9806dc5b6f75d2e243a2c3e3d5e51 Mon Sep 17 00:00:00 2001 From: naveen sanjula <82176749+naveensanjula975@users.noreply.github.com> Date: Sun, 19 Oct 2025 13:18:38 +0530 Subject: [PATCH 3/3] fix: resolve conflicts & Merge branch 'dev' of into feat/branch-endpoints --- package-lock.json | 3 - server.js | 2 + src/__tests__/thirdparties.test.js | 458 ++++++++++++++++++ src/controllers/cashRequestController.js | 8 +- src/controllers/thirdPartiesController.js | 178 +++++++ ...251015000000-create-thirdparties-table.cjs | 53 ++ src/middleware/validation.js | 97 ++++ src/models/thirdParty.js | 98 ++++ src/routes/cashRequestRoutes.js | 2 +- src/routes/thirdparties.js | 75 +++ src/services/thirdpartiesService.js | 143 ++++++ 11 files changed, 1109 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/thirdparties.test.js create mode 100644 src/controllers/thirdPartiesController.js create mode 100644 src/database/migrations/20251015000000-create-thirdparties-table.cjs create mode 100644 src/models/thirdParty.js create mode 100644 src/routes/thirdparties.js create mode 100644 src/services/thirdpartiesService.js diff --git a/package-lock.json b/package-lock.json index b3b078a..3635319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1891,7 +1890,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5408,7 +5406,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", diff --git a/server.js b/server.js index 47dd054..fa743b0 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,7 @@ import { setupSocket } from './src/socket/socket.js'; import issueRoutes from './src/routes/issues.js'; import userRoutes from './src/routes/users.js'; import branchRoutes from './src/routes/branch.js'; +import thirdPartiesRoutes from './src/routes/thirdparties.js'; import cashRequestRoutes from './src/routes/cashRequestRoutes.js'; const app = express(); @@ -31,6 +32,7 @@ app.use('/api/v1/cash-requests', cashRequestRoutes); app.use('/api/v1/issues', issueRoutes); app.use('/api/v1/users', userRoutes); app.use('/api/v1/branches', branchRoutes); +app.use('/api/v1/thirdparties', thirdPartiesRoutes); // Basic route app.get('/api/', (req, res) => { diff --git a/src/__tests__/thirdparties.test.js b/src/__tests__/thirdparties.test.js new file mode 100644 index 0000000..ef36505 --- /dev/null +++ b/src/__tests__/thirdparties.test.js @@ -0,0 +1,458 @@ +import request from 'supertest'; +import express from 'express'; +import { getSequelizeInstance } from '../services/connectionService.js'; +import thirdPartiesRoutes from '../routes/thirdparties.js'; +import ThirdParty from '../models/thirdParty.js'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/thirdparties', thirdPartiesRoutes); + +let sequelize; + +beforeAll(async () => { + sequelize = getSequelizeInstance(); + await sequelize.sync({ force: true }); // Reset database before tests +}); + +afterAll(async () => { + await sequelize.close(); +}); + +describe('ThirdParty Endpoints', () => { + let thirdPartyId; + + describe('POST /api/v1/thirdparties', () => { + it('should create a new third party with valid data', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Tech Solutions Inc', + email: 'contact@techsolutions.com', + location: 'New York', + phone: '1234567890', + worktype: 'Software Development', + profilePicture: 'https://example.com/logo.png' + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('id'); + expect(res.body.data.organization).toBe('Tech Solutions Inc'); + expect(res.body.data.email).toBe('contact@techsolutions.com'); + expect(res.body.data.location).toBe('New York'); + expect(res.body.data.worktype).toBe('Software Development'); + expect(res.body.message).toBe('Third party created successfully'); + + thirdPartyId = res.body.data.id; + }); + + it('should create a third party with minimal required data', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Minimal Corp', + email: 'minimal@corp.com' + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.organization).toBe('Minimal Corp'); + expect(res.body.data.email).toBe('minimal@corp.com'); + expect(res.body.data.location).toBeNull(); + expect(res.body.data.phone).toBeNull(); + expect(res.body.data.worktype).toBeNull(); + }); + + it('should reject third party with duplicate email', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Duplicate Email Corp', + email: 'contact@techsolutions.com', // Same email as first test + location: 'California' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain('Email already exists'); + }); + + it('should reject third party with invalid email format', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Invalid Email Corp', + email: 'not-an-email', + location: 'Texas' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.errors).toBeDefined(); + }); + + it('should reject third party with missing organization', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + email: 'noorg@test.com', + location: 'Florida' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.errors).toBeDefined(); + }); + + it('should reject third party with missing email', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'No Email Corp', + location: 'Nevada' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.errors).toBeDefined(); + }); + + it('should reject third party with short organization name', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'A', // Too short + email: 'short@org.com' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject third party with short phone number', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Short Phone Corp', + email: 'shortphone@corp.com', + phone: '123' // Too short + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject third party with invalid profile picture URL', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Invalid URL Corp', + email: 'invalidurl@corp.com', + profilePicture: 'not-a-url' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('GET /api/v1/thirdparties', () => { + beforeAll(async () => { + // Create additional test data + await ThirdParty.create({ + organization: 'AI Solutions Ltd', + email: 'ai@solutions.com', + location: 'California', + worktype: 'AI Development' + }); + await ThirdParty.create({ + organization: 'Web Design Co', + email: 'web@design.com', + location: 'New York', + worktype: 'Web Development' + }); + await ThirdParty.create({ + organization: 'Mobile Apps Inc', + email: 'mobile@apps.com', + location: 'Texas', + worktype: 'Mobile Development' + }); + }); + + it('should get all third parties', async () => { + const res = await request(app).get('/api/v1/thirdparties'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body).toHaveProperty('count'); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body.count).toBe(res.body.data.length); + }); + + it('should filter third parties by worktype', async () => { + const res = await request(app).get('/api/v1/thirdparties?worktype=AI Development'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body.data[0].worktype).toBe('AI Development'); + }); + + it('should filter third parties by location', async () => { + const res = await request(app).get('/api/v1/thirdparties?location=California'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body.data[0].location).toBe('California'); + }); + + it('should search third parties by organization name', async () => { + const res = await request(app).get('/api/v1/thirdparties?search=Tech'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body.data[0].organization).toContain('Tech'); + }); + + it('should return empty array for non-existent search term', async () => { + const res = await request(app).get('/api/v1/thirdparties?search=NonExistentCompany'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBe(0); + expect(res.body.count).toBe(0); + }); + + it('should combine multiple filters', async () => { + const res = await request(app).get('/api/v1/thirdparties?worktype=Software Development&location=New York'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe('GET /api/v1/thirdparties/:id', () => { + it('should get third party by valid ID', async () => { + const res = await request(app).get(`/api/v1/thirdparties/${thirdPartyId}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.id).toBe(thirdPartyId); + expect(res.body.data.organization).toBe('Tech Solutions Inc'); + }); + + it('should return 404 for non-existent third party ID', async () => { + const res = await request(app).get('/api/v1/thirdparties/99999'); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain('Third party not found'); + }); + + it('should reject invalid ID format', async () => { + const res = await request(app).get('/api/v1/thirdparties/invalid'); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject negative ID', async () => { + const res = await request(app).get('/api/v1/thirdparties/-1'); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('PUT /api/v1/thirdparties/:id', () => { + it('should update third party with valid data', async () => { + const res = await request(app).put(`/api/v1/thirdparties/${thirdPartyId}`).send({ + organization: 'Updated Tech Solutions Inc', + location: 'California', + worktype: 'AI Development', + phone: '9876543210' + }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.organization).toBe('Updated Tech Solutions Inc'); + expect(res.body.data.location).toBe('California'); + expect(res.body.data.worktype).toBe('AI Development'); + expect(res.body.data.phone).toBe('9876543210'); + expect(res.body.message).toBe('Third party updated successfully'); + }); + + it('should update only specified fields', async () => { + const res = await request(app).put(`/api/v1/thirdparties/${thirdPartyId}`).send({ + worktype: 'Machine Learning' + }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.worktype).toBe('Machine Learning'); + expect(res.body.data.organization).toBe('Updated Tech Solutions Inc'); // Should remain unchanged + }); + + it('should return 404 for non-existent third party ID', async () => { + const res = await request(app).put('/api/v1/thirdparties/99999').send({ + organization: 'Non-existent Update' + }); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain('Third party not found'); + }); + + it('should reject update with invalid email format', async () => { + const res = await request(app).put(`/api/v1/thirdparties/${thirdPartyId}`).send({ + email: 'invalid-email' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject update with short organization name', async () => { + const res = await request(app).put(`/api/v1/thirdparties/${thirdPartyId}`).send({ + organization: 'A' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('GET /api/v1/thirdparties/worktype/:worktype', () => { + it('should get third parties by work type', async () => { + const res = await request(app).get('/api/v1/thirdparties/worktype/AI Development'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + if (res.body.data.length > 0) { + expect(res.body.data[0].worktype).toBe('AI Development'); + } + }); + + it('should return empty array for non-existent work type', async () => { + const res = await request(app).get('/api/v1/thirdparties/worktype/NonExistentWorkType'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBe(0); + }); + }); + + describe('GET /api/v1/thirdparties/location/:location', () => { + it('should get third parties by location', async () => { + const res = await request(app).get('/api/v1/thirdparties/location/California'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + if (res.body.data.length > 0) { + expect(res.body.data[0].location).toBe('California'); + } + }); + + it('should return empty array for non-existent location', async () => { + const res = await request(app).get('/api/v1/thirdparties/location/NonExistentLocation'); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBe(0); + }); + }); + + describe('DELETE /api/v1/thirdparties/:id', () => { + let deleteTestId; + + beforeAll(async () => { + // Create a third party specifically for deletion test + const deleteTest = await ThirdParty.create({ + organization: 'Delete Test Corp', + email: 'delete@test.com', + location: 'Delete Location' + }); + deleteTestId = deleteTest.id; + }); + + it('should delete third party by valid ID', async () => { + const res = await request(app).delete(`/api/v1/thirdparties/${deleteTestId}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe('Third party deleted successfully'); + }); + + it('should return 404 when trying to get deleted third party', async () => { + const res = await request(app).get(`/api/v1/thirdparties/${deleteTestId}`); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain('Third party not found'); + }); + + it('should return 404 for non-existent third party ID', async () => { + const res = await request(app).delete('/api/v1/thirdparties/99999'); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain('Third party not found'); + }); + + it('should reject invalid ID format for deletion', async () => { + const res = await request(app).delete('/api/v1/thirdparties/invalid'); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should successfully delete the main test third party', async () => { + const res = await request(app).delete(`/api/v1/thirdparties/${thirdPartyId}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe('Third party deleted successfully'); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle very long organization name', async () => { + const longName = 'A'.repeat(300); // Longer than 255 chars + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: longName, + email: 'longname@test.com' + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should handle very long location', async () => { + const longLocation = 'B'.repeat(300); // Longer than 255 chars + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'Location Test Corp', + email: 'locationtest@test.com', + location: longLocation + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should handle very long work type', async () => { + const longWorkType = 'C'.repeat(150); // Longer than 100 chars + const res = await request(app).post('/api/v1/thirdparties').send({ + organization: 'WorkType Test Corp', + email: 'worktypetest@test.com', + worktype: longWorkType + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should handle empty request body', async () => { + const res = await request(app).post('/api/v1/thirdparties').send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should handle malformed JSON', async () => { + const res = await request(app) + .post('/api/v1/thirdparties') + .set('Content-Type', 'application/json') + .send('{"organization": "Test", "email":}'); // Malformed JSON + + expect(res.statusCode).toBe(400); + }); + }); +}); \ No newline at end of file diff --git a/src/controllers/cashRequestController.js b/src/controllers/cashRequestController.js index a23c106..6dc36a5 100644 --- a/src/controllers/cashRequestController.js +++ b/src/controllers/cashRequestController.js @@ -309,17 +309,17 @@ export const getTechnicianStats = async (req, res) => { /** * Get cash requests by ticket ID - * @route GET /api/v1/cash-requests?ticket_id= - * @query {string} ticket_id - Ticket ID to filter cash requests + * @route GET /api/v1/cash-requests/by-ticket/:ticket_id + * @param {string} ticket_id - Ticket ID to filter cash requests */ export const getCashRequestsByTicketId = async (req, res) => { try { - const { ticket_id } = req.query; + const { ticket_id } = req.params; if (!ticket_id) { return res.status(400).json({ success: false, - message: 'ticket_id query parameter is required' + message: 'ticket_id route parameter is required' }); } diff --git a/src/controllers/thirdPartiesController.js b/src/controllers/thirdPartiesController.js new file mode 100644 index 0000000..cf3ad79 --- /dev/null +++ b/src/controllers/thirdPartiesController.js @@ -0,0 +1,178 @@ +import thirdPartiesService from '../services/thirdpartiesService.js'; + +/** + * ThirdParties Controller - Handles HTTP requests for third party operations + */ +class ThirdPartiesController { + /** + * Create a new third party + * @route POST /api/v1/thirdparties + */ + async createThirdParty(req, res) { + try { + const thirdPartyData = req.body; + const thirdParty = await thirdPartiesService.createThirdParty(thirdPartyData); + + res.status(201).json({ + success: true, + message: 'Third party created successfully', + data: thirdParty + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } + } + + /** + * Get all third parties + * @route GET /api/v1/thirdparties + */ + async getAllThirdParties(req, res) { + try { + const { worktype, location, search } = req.query; + + let thirdParties; + + // Handle search + if (search) { + thirdParties = await thirdPartiesService.searchThirdParties(search); + } else { + // Handle filters + const filters = {}; + if (worktype) filters.worktype = worktype; + if (location) filters.location = location; + + thirdParties = await thirdPartiesService.getAllThirdParties(filters); + } + + res.status(200).json({ + success: true, + count: thirdParties.length, + data: thirdParties + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message + }); + } + } + + /** + * Get third party by ID + * @route GET /api/v1/thirdparties/:id + */ + async getThirdPartyById(req, res) { + try { + const { id } = req.params; + const thirdParty = await thirdPartiesService.getThirdPartyById(id); + + res.status(200).json({ + success: true, + data: thirdParty + }); + } catch (error) { + const statusCode = error.message === 'Third party not found' ? 404 : 500; + res.status(statusCode).json({ + success: false, + message: error.message + }); + } + } + + /** + * Update third party by ID + * @route PUT /api/v1/thirdparties/:id + */ + async updateThirdParty(req, res) { + try { + const { id } = req.params; + const updateData = req.body; + + const thirdParty = await thirdPartiesService.updateThirdParty(id, updateData); + + res.status(200).json({ + success: true, + message: 'Third party updated successfully', + data: thirdParty + }); + } catch (error) { + const statusCode = error.message === 'Third party not found' ? 404 : 400; + res.status(statusCode).json({ + success: false, + message: error.message + }); + } + } + + /** + * Delete third party by ID + * @route DELETE /api/v1/thirdparties/:id + */ + async deleteThirdParty(req, res) { + try { + const { id } = req.params; + const result = await thirdPartiesService.deleteThirdParty(id); + + res.status(200).json({ + success: true, + message: result.message + }); + } catch (error) { + const statusCode = error.message === 'Third party not found' ? 404 : 500; + res.status(statusCode).json({ + success: false, + message: error.message + }); + } + } + + /** + * Get third parties by work type + * @route GET /api/v1/thirdparties/worktype/:worktype + */ + async getThirdPartiesByWorkType(req, res) { + try { + const { worktype } = req.params; + const thirdParties = await thirdPartiesService.getThirdPartiesByWorkType(worktype); + + res.status(200).json({ + success: true, + count: thirdParties.length, + data: thirdParties + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message + }); + } + } + + /** + * Get third parties by location + * @route GET /api/v1/thirdparties/location/:location + */ + async getThirdPartiesByLocation(req, res) { + try { + const { location } = req.params; + const thirdParties = await thirdPartiesService.getThirdPartiesByLocation(location); + + res.status(200).json({ + success: true, + count: thirdParties.length, + data: thirdParties + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message + }); + } + } +} + +export default new ThirdPartiesController(); \ No newline at end of file diff --git a/src/database/migrations/20251015000000-create-thirdparties-table.cjs b/src/database/migrations/20251015000000-create-thirdparties-table.cjs new file mode 100644 index 0000000..28733c9 --- /dev/null +++ b/src/database/migrations/20251015000000-create-thirdparties-table.cjs @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ThirdParties', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + organization: { + type: Sequelize.STRING, + allowNull: false, + }, + email: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + location: { + type: Sequelize.STRING, + allowNull: true, + }, + phone: { + type: Sequelize.STRING, + allowNull: true, + }, + worktype: { + type: Sequelize.STRING, + allowNull: true, + }, + profilePicture: { + type: Sequelize.STRING, + allowNull: true, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ThirdParties'); + }, +}; \ No newline at end of file diff --git a/src/middleware/validation.js b/src/middleware/validation.js index 84903ed..36815dc 100644 --- a/src/middleware/validation.js +++ b/src/middleware/validation.js @@ -91,3 +91,100 @@ export const validateUserId = [ handleValidationErrors ]; + +/** + * Validation rules for creating a third party + */ +export const validateCreateThirdParty = [ + body('organization') + .trim() + .notEmpty() + .withMessage('Organization name is required') + .isLength({ min: 2, max: 255 }) + .withMessage('Organization name must be between 2 and 255 characters'), + + body('email') + .trim() + .notEmpty() + .withMessage('Email is required') + .isEmail() + .withMessage('Must be a valid email address') + .normalizeEmail(), + + body('location') + .optional() + .trim() + .isLength({ max: 255 }) + .withMessage('Location must be less than 255 characters'), + + body('phone') + .optional() + .trim() + .isLength({ min: 10, max: 20 }) + .withMessage('Phone number must be between 10 and 20 characters'), + + body('worktype') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('Work type must be less than 100 characters'), + + body('profilePicture') + .optional() + .isURL() + .withMessage('Profile picture must be a valid URL'), + + handleValidationErrors +]; + +/** + * Validation rules for updating a third party + */ +export const validateUpdateThirdParty = [ + body('organization') + .optional() + .trim() + .isLength({ min: 2, max: 255 }) + .withMessage('Organization name must be between 2 and 255 characters'), + + body('email') + .optional() + .trim() + .isEmail() + .withMessage('Must be a valid email address') + .normalizeEmail(), + + body('location') + .optional() + .trim() + .isLength({ max: 255 }) + .withMessage('Location must be less than 255 characters'), + + body('phone') + .optional() + .trim() + .isLength({ min: 10, max: 20 }) + .withMessage('Phone number must be between 10 and 20 characters'), + + body('worktype') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('Work type must be less than 100 characters'), + + body('profilePicture') + .optional() + .isURL() + .withMessage('Profile picture must be a valid URL'), + + handleValidationErrors +]; + +/** + * Validation for third party ID parameter + */ +export const validateThirdPartyId = [ + param('id').isInt({ min: 1 }).withMessage('Third party ID must be a positive integer'), + + handleValidationErrors +]; diff --git a/src/models/thirdParty.js b/src/models/thirdParty.js new file mode 100644 index 0000000..2ab3f99 --- /dev/null +++ b/src/models/thirdParty.js @@ -0,0 +1,98 @@ +import { DataTypes } from 'sequelize'; +import { getSequelizeInstance } from '../services/connectionService.js'; + +const sequelize = getSequelizeInstance(); + +const ThirdParty = sequelize.define( + 'ThirdParty', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + organization: { + type: DataTypes.STRING(255), + allowNull: false, + validate: { + notNull: { + msg: 'Organization name is required' + }, + notEmpty: { + msg: 'Organization name cannot be empty' + }, + len: { + args: [2, 255], + msg: 'Organization name must be between 2 and 255 characters' + } + } + }, + email: { + type: DataTypes.STRING(255), + allowNull: false, + unique: true, + validate: { + notNull: { + msg: 'Email is required' + }, + isEmail: { + msg: 'Must be a valid email address' + } + } + }, + location: { + type: DataTypes.STRING(255), + allowNull: true, + validate: { + len: { + args: [0, 255], + msg: 'Location must be less than 255 characters' + } + } + }, + phone: { + type: DataTypes.STRING(20), + allowNull: true, + validate: { + len: { + args: [10, 20], + msg: 'Phone number must be between 10 and 20 characters' + } + } + }, + worktype: { + type: DataTypes.STRING(100), + allowNull: true, + validate: { + len: { + args: [0, 100], + msg: 'Work type must be less than 100 characters' + } + } + }, + profilePicture: { + type: DataTypes.STRING(500), + allowNull: true, + comment: 'URL to profile picture in MinIO storage' + } + }, + { + tableName: 'ThirdParties', + timestamps: true, + indexes: [ + { + unique: true, + fields: ['email'] + }, + { + fields: ['organization'] + }, + { + fields: ['worktype'] + } + ] + } +); + +export default ThirdParty; \ No newline at end of file diff --git a/src/routes/cashRequestRoutes.js b/src/routes/cashRequestRoutes.js index 26c5015..1da851b 100644 --- a/src/routes/cashRequestRoutes.js +++ b/src/routes/cashRequestRoutes.js @@ -68,7 +68,7 @@ const updateCashRequestValidation = [ ]; router.get('/', getAllCashRequests); -router.get('/by-ticket', getCashRequestsByTicketId); +router.get('/by-ticket/:ticket_id', validateId('ticket_id'), getCashRequestsByTicketId); router.get('/stats/:technician_id', validateId('technician_id'), getTechnicianStats); router.get('/:id', validateUUID('id'), getCashRequestById); router.post('/', createCashRequestValidation, createCashRequest); diff --git a/src/routes/thirdparties.js b/src/routes/thirdparties.js new file mode 100644 index 0000000..1ff685d --- /dev/null +++ b/src/routes/thirdparties.js @@ -0,0 +1,75 @@ +import express from 'express'; +import thirdPartiesController from '../controllers/thirdPartiesController.js'; +import { + validateCreateThirdParty, + validateUpdateThirdParty, + validateThirdPartyId +} from '../middleware/validation.js'; + +const router = express.Router(); + +// ============================================ +// THIRD PARTY ROUTES +// ============================================ + +/** + * @route POST /api/v1/thirdparties + * @desc Create a new third party + * @access Public (no auth for now) + * @body { organization, email, location?, phone?, worktype?, profilePicture? } + */ +router.post('/', validateCreateThirdParty, thirdPartiesController.createThirdParty); + +/** + * @route GET /api/v1/thirdparties + * @desc Get all third parties with optional filters + * @access Public + * @query ?worktype=value&location=value&search=value + */ +router.get('/', thirdPartiesController.getAllThirdParties); + +/** + * @route GET /api/v1/thirdparties/:id + * @desc Get third party by ID + * @access Public + */ +router.get('/:id', validateThirdPartyId, thirdPartiesController.getThirdPartyById); + +/** + * @route PUT /api/v1/thirdparties/:id + * @desc Update third party by ID + * @access Public + */ +router.put( + '/:id', + validateThirdPartyId, + validateUpdateThirdParty, + thirdPartiesController.updateThirdParty +); + +/** + * @route DELETE /api/v1/thirdparties/:id + * @desc Delete third party by ID (hard delete) + * @access Public + */ +router.delete('/:id', validateThirdPartyId, thirdPartiesController.deleteThirdParty); + +// ============================================ +// SPECIALIZED ROUTES FOR FILTERING +// ============================================ + +/** + * @route GET /api/v1/thirdparties/worktype/:worktype + * @desc Get third parties by work type + * @access Public + */ +router.get('/worktype/:worktype', thirdPartiesController.getThirdPartiesByWorkType); + +/** + * @route GET /api/v1/thirdparties/location/:location + * @desc Get third parties by location + * @access Public + */ +router.get('/location/:location', thirdPartiesController.getThirdPartiesByLocation); + +export default router; \ No newline at end of file diff --git a/src/services/thirdpartiesService.js b/src/services/thirdpartiesService.js new file mode 100644 index 0000000..e4ca24f --- /dev/null +++ b/src/services/thirdpartiesService.js @@ -0,0 +1,143 @@ +import ThirdParty from '../models/thirdParty.js'; + +/** + * ThirdParties Service - Contains all business logic for third party operations + * Handles CRUD operations for third party organizations + */ +class ThirdPartiesService { + /** + * Create a new third party + * @param {Object} thirdPartyData - Third party data + * @returns {Promise} Created third party + */ + async createThirdParty(thirdPartyData) { + try { + const thirdParty = await ThirdParty.create(thirdPartyData); + return thirdParty; + } catch (error) { + if (error.name === 'SequelizeUniqueConstraintError') { + throw new Error('Email already exists'); + } + throw error; + } + } + + /** + * Get all third parties + * @param {Object} filters - Optional filters (worktype, location) + * @returns {Promise} List of third parties + */ + async getAllThirdParties(filters = {}) { + const where = {}; + + // Add filters if provided + if (filters.worktype) { + where.worktype = filters.worktype; + } + if (filters.location) { + where.location = filters.location; + } + + const thirdParties = await ThirdParty.findAll({ + where, + order: [['createdAt', 'DESC']] + }); + + return thirdParties; + } + + /** + * Get third party by ID + * @param {number} thirdPartyId - Third party ID + * @returns {Promise} Third party object + */ + async getThirdPartyById(thirdPartyId) { + const thirdParty = await ThirdParty.findOne({ + where: { id: thirdPartyId } + }); + + if (!thirdParty) { + throw new Error('Third party not found'); + } + + return thirdParty; + } + + /** + * Update third party by ID + * @param {number} thirdPartyId - Third party ID + * @param {Object} updateData - Data to update + * @returns {Promise} Updated third party + */ + async updateThirdParty(thirdPartyId, updateData) { + const thirdParty = await ThirdParty.findOne({ + where: { id: thirdPartyId } + }); + + if (!thirdParty) { + throw new Error('Third party not found'); + } + + await thirdParty.update(updateData); + return thirdParty; + } + + /** + * Delete third party by ID + * @param {number} thirdPartyId - Third party ID + * @returns {Promise} Success message + */ + async deleteThirdParty(thirdPartyId) { + const thirdParty = await ThirdParty.findOne({ + where: { id: thirdPartyId } + }); + + if (!thirdParty) { + throw new Error('Third party not found'); + } + + await thirdParty.destroy(); + + return { message: 'Third party deleted successfully' }; + } + + /** + * Get third parties by work type + * @param {string} worktype - Work type filter + * @returns {Promise} List of third parties + */ + async getThirdPartiesByWorkType(worktype) { + return this.getAllThirdParties({ worktype }); + } + + /** + * Get third parties by location + * @param {string} location - Location filter + * @returns {Promise} List of third parties + */ + async getThirdPartiesByLocation(location) { + return this.getAllThirdParties({ location }); + } + + /** + * Search third parties by organization name + * @param {string} searchTerm - Search term for organization + * @returns {Promise} List of matching third parties + */ + async searchThirdParties(searchTerm) { + const { Op } = await import('sequelize'); + + const thirdParties = await ThirdParty.findAll({ + where: { + organization: { + [Op.iLike]: `%${searchTerm}%` + } + }, + order: [['createdAt', 'DESC']] + }); + + return thirdParties; + } +} + +export default new ThirdPartiesService(); \ No newline at end of file