diff --git a/server.js b/server.js index cd42df2..c243d50 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 cashRequestRoutes from './src/routes/cashRequestRoutes.js'; const app = express(); const server = http.createServer(app); @@ -18,15 +19,20 @@ setupSocket(server); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +// Middleware +app.use(express.json()); // Parse JSON request bodies +app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies + // Routes app.use('/api/health', healthRoutes); +app.use('/api/v1/cash-requests', cashRequestRoutes); app.use('/api/v1/issues', issueRoutes); app.use('/api/v1/users', userRoutes); // Basic route app.get('/api/', (req, res) => { - res.json({ + res.json({ message: 'FixPoint API Server is running!', version: '1.0.0', timestamp: new Date().toISOString() @@ -36,18 +42,18 @@ app.get('/api/', (req, res) => { // Initialize services and start server async function startServer() { const PORT = process.env.PORT || 5000; - + try { console.log('Starting FixPoint Server...'); - + // Initialize database connection console.log('Initializing database connection...'); await initializeDatabase(); - + // Initialize MinIO storage console.log('Initializing MinIO storage...'); await initializeStorage(); - + // Start the server server.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); @@ -55,8 +61,9 @@ async function startServer() { console.log(`Health check: http://localhost:${PORT}/api/health`); console.log(`Database health: http://localhost:${PORT}/api/health/database`); console.log(`Storage health: http://localhost:${PORT}/api/health/storage`); + console.log(`Cash Requests API: http://localhost:${PORT}/api/v1/cash-requests`); }); - + } catch (error) { console.error('Failed to start server:', error.message); process.exit(1); diff --git a/src/__tests__/cash-requests.test.js b/src/__tests__/cash-requests.test.js new file mode 100644 index 0000000..e9f2d50 --- /dev/null +++ b/src/__tests__/cash-requests.test.js @@ -0,0 +1,205 @@ +/** + * Usage: node src/__tests__/cash-requests.test.js + */ + +const BASE_URL = 'http://localhost:5000/api/v1/cash-requests'; + +// Sample test data +const sampleCashRequest = { + technician_id: 1, + ticket_id: 1, + amount: 150.00, + description: 'Spare parts for refrigerator repair including compressor relay and thermostat' +}; + +async function testRequest(method, url, body = null) { + const options = { + method, + headers: { + 'Content-Type': 'application/json' + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + const data = await response.json(); + return { status: response.status, data }; + } catch (error) { + return { error: error.message }; + } +} + +async function runTests() { + console.log('🧪 Testing Cash Requests API...\n'); + + let createdRequestId = null; + + // Test 1: Health Check + console.log('1ļøāƒ£ Testing server health...'); + try { + const healthResponse = await fetch('http://localhost:5000/api/health'); + const healthData = await healthResponse.json(); + console.log('āœ… Server is running:', healthData.status); + } catch (error) { + console.log('āŒ Server is not running. Please start the server first.'); + process.exit(1); + } + + // Test 2: Create Cash Request + console.log('\n2ļøāƒ£ Creating a new cash request...'); + const createResult = await testRequest('POST', BASE_URL, sampleCashRequest); + if (createResult.data && createResult.data.success) { + createdRequestId = createResult.data.data.id; + console.log('āœ… Cash request created:', createdRequestId); + console.log(' Amount:', createResult.data.data.amount); + console.log(' Status:', createResult.data.data.status); + } else { + console.log('āŒ Failed to create cash request:', createResult.data?.message || createResult.error); + } + + // Test 3: Get All Cash Requests + console.log('\n3ļøāƒ£ Getting all cash requests...'); + const getAllResult = await testRequest('GET', BASE_URL); + if (getAllResult.data && getAllResult.data.success) { + console.log('āœ… Retrieved', getAllResult.data.count, 'cash request(s)'); + } else { + console.log('āŒ Failed to get cash requests:', getAllResult.data?.message || getAllResult.error); + } + + // Test 4: Get Cash Request by ID + if (createdRequestId) { + console.log('\n4ļøāƒ£ Getting cash request by ID...'); + const getByIdResult = await testRequest('GET', `${BASE_URL}/${createdRequestId}`); + if (getByIdResult.data && getByIdResult.data.success) { + console.log('āœ… Retrieved cash request:', createdRequestId); + console.log(' Description:', getByIdResult.data.data.description); + } else { + console.log('āŒ Failed to get cash request:', getByIdResult.data?.message || getByIdResult.error); + } + } + + // Test 5: Update Cash Request + if (createdRequestId) { + console.log('\n5ļøāƒ£ Updating cash request...'); + const updateData = { + amount: 175.00, + description: 'Updated: Spare parts and additional tools for refrigerator repair' + }; + const updateResult = await testRequest('PUT', `${BASE_URL}/${createdRequestId}`, updateData); + if (updateResult.data && updateResult.data.success) { + console.log('āœ… Cash request updated'); + console.log(' New amount:', updateResult.data.data.amount); + } else { + console.log('āŒ Failed to update cash request:', updateResult.data?.message || updateResult.error); + } + } + + // Test 6: Approve Cash Request + if (createdRequestId) { + console.log('\n6ļøāƒ£ Approving cash request...'); + const approveResult = await testRequest('PATCH', `${BASE_URL}/${createdRequestId}/approve`); + if (approveResult.data && approveResult.data.success) { + console.log('āœ… Cash request approved'); + console.log(' Status:', approveResult.data.data.status); + } else { + console.log('āŒ Failed to approve cash request:', approveResult.data?.message || approveResult.error); + } + } + + // Test 7: Get Filtered Cash Requests (by status) + console.log('\n7ļøāƒ£ Getting approved cash requests...'); + const getApprovedResult = await testRequest('GET', `${BASE_URL}?status=approved`); + if (getApprovedResult.data && getApprovedResult.data.success) { + console.log('āœ… Retrieved', getApprovedResult.data.count, 'approved request(s)'); + } else { + console.log('āŒ Failed to get approved requests:', getApprovedResult.data?.message || getApprovedResult.error); + } + + // Test 7.5: Get Cash Requests by Ticket ID + console.log('\n7.5ļøāƒ£ Getting cash requests by ticket ID...'); + const getByTicketResult = await testRequest('GET', `${BASE_URL}/by-ticket?ticket_id=${sampleCashRequest.ticket_id}`); + if (getByTicketResult.data && getByTicketResult.data.success) { + console.log('āœ… Retrieved', getByTicketResult.data.count, 'cash request(s) for ticket'); + } else { + console.log('āŒ Failed to get cash requests by ticket:', getByTicketResult.data?.message || getByTicketResult.error); + } + + // Test 7.6: Get Cash Requests by Ticket ID (non-existent) + console.log('\n7.6ļøāƒ£ Testing filtering by non-existent ticket ID...'); + const getByTicketEmptyResult = await testRequest('GET', `${BASE_URL}/by-ticket?ticket_id=99999`); + if (getByTicketEmptyResult.data && getByTicketEmptyResult.data.success && getByTicketEmptyResult.data.count === 0) { + console.log('āœ… Correctly returned empty results for non-existent ticket'); + } else { + console.log('āŒ Unexpected result for non-existent ticket:', getByTicketEmptyResult.data?.message || getByTicketEmptyResult.error); + } + + // Test 8: Get Technician Statistics + console.log('\n8ļøāƒ£ Getting technician statistics...'); + const statsResult = await testRequest('GET', `${BASE_URL}/stats/${sampleCashRequest.technician_id}`); + if (statsResult.data && statsResult.data.success) { + console.log('āœ… Retrieved technician statistics'); + console.log(' Total requests:', statsResult.data.data.total_requests); + console.log(' Approved:', statsResult.data.data.approved_count); + console.log(' Total approved amount:', statsResult.data.data.total_approved_amount); + } else { + console.log('āŒ Failed to get statistics:', statsResult.data?.message || statsResult.error); + } + + // Test 9: Validation Test (should fail) + console.log('\n9ļøāƒ£ Testing validation (should fail)...'); + const invalidRequest = { + technician_id: 'invalid-id', + amount: -50, + description: 'Short' + }; + const validationResult = await testRequest('POST', BASE_URL, invalidRequest); + if (validationResult.data && !validationResult.data.success) { + console.log('āœ… Validation working correctly (request rejected)'); + console.log(' Errors:', validationResult.data.errors?.length || 'validation failed'); + } else { + console.log('āš ļø Validation might not be working properly'); + } + + // Test 10: Delete Cash Request (cleanup) + if (createdRequestId) { + console.log('\nšŸ”Ÿ Cleaning up - deleting cash request...'); + const deleteResult = await testRequest('DELETE', `${BASE_URL}/${createdRequestId}`); + if (deleteResult.data && deleteResult.data.success) { + console.log('āœ… Cash request deleted'); + } else { + console.log('āŒ Failed to delete cash request:', deleteResult.data?.message || deleteResult.error); + } + } + + console.log('\n✨ Testing complete!\n'); +} + +export { runTests }; + +if (process.argv[1].includes('cash-requests.test.js')) { + runTests().catch(console.error); +} + +// Jest test suite wrapper (only run when executed by Jest) +if (typeof describe !== 'undefined') { + describe('Cash Requests API - Integration Tests', () => { + test('manual integration test placeholder', () => { + expect(true).toBe(true); + }); + }); + + afterAll(async () => { + try { + const http = await import('http'); + const https = await import('https'); + http.globalAgent?.destroy(); + https.globalAgent?.destroy(); + } catch (err) { + // Ignore errors during cleanup + } + }); +} \ No newline at end of file diff --git a/src/controllers/cashRequestController.js b/src/controllers/cashRequestController.js new file mode 100644 index 0000000..a23c106 --- /dev/null +++ b/src/controllers/cashRequestController.js @@ -0,0 +1,342 @@ +/** + * Cash Request Controller + * Handles HTTP requests for petty cash requests + */ + +import * as CashRequestService from '../services/cashRequestService.js'; +import { validationResult } from 'express-validator'; + +/** + * Get all cash requests with optional filtering + * @route GET /api/v1/cash-requests + * @query {string} technician_id - Optional: Filter by technician ID + * @query {string} status - Optional: Filter by status (pending, approved, rejected) + * @query {string} ticket_id - Optional: Filter by ticket ID + */ +export const getAllCashRequests = async (req, res) => { + try { + const filters = {}; + + // Extract query parameters for filtering + if (req.query.technician_id) { + filters.technician_id = req.query.technician_id; + } + + if (req.query.status) { + filters.status = req.query.status; + } + + if (req.query.ticket_id) { + filters.ticket_id = req.query.ticket_id; + } + + const cashRequests = await CashRequestService.getAllCashRequests(filters); + + res.status(200).json({ + success: true, + message: 'Cash requests retrieved successfully', + count: cashRequests.length, + data: cashRequests + }); + } catch (error) { + console.error('Error in getAllCashRequests:', error); + res.status(500).json({ + success: false, + message: 'Unable to retrieve cash requests', + error: error.message + }); + } +}; + +/** + * Get a single cash request by ID + * @route GET /api/v1/cash-requests/:id + * @param {string} id - Cash request UUID + */ +export const getCashRequestById = async (req, res) => { + try { + const { id } = req.params; + + const cashRequest = await CashRequestService.getCashRequestById(id); + + if (!cashRequest) { + return res.status(404).json({ + success: false, + message: 'Cash request not found' + }); + } + + res.status(200).json({ + success: true, + message: 'Cash request retrieved successfully', + data: cashRequest + }); + } catch (error) { + console.error('Error in getCashRequestById:', error); + res.status(500).json({ + success: false, + message: 'Unable to retrieve cash request', + error: error.message + }); + } +}; + +/** + * Create a new cash request + * @route POST /api/v1/cash-requests + * @body {string} technician_id - Technician UUID (required) + * @body {string} ticket_id - Ticket UUID (required) + * @body {number} amount - Amount requested (required, must be > 0) + * @body {string} description - Description of expense (required) + */ +export const createCashRequest = async (req, res) => { + try { + // Check for validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array() + }); + } + + const { technician_id, ticket_id, amount, description } = req.body; + + const newCashRequest = await CashRequestService.createCashRequest({ + technician_id, + ticket_id, + amount, + description + }); + + res.status(201).json({ + success: true, + message: 'Petty cash request created successfully', + data: newCashRequest + }); + } catch (error) { + console.error('Error in createCashRequest:', error); + res.status(500).json({ + success: false, + message: 'Unable to create petty cash request', + error: error.message + }); + } +}; + +/** + * Update an existing cash request + * @route PUT /api/v1/cash-requests/:id + * @param {string} id - Cash request UUID + * @body {number} amount - Optional: Updated amount + * @body {string} description - Optional: Updated description + * @body {string} status - Optional: Updated status (pending, approved, rejected) + */ +export const updateCashRequest = async (req, res) => { + try { + // Check for validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array() + }); + } + + const { id } = req.params; + const updateData = {}; + + // Only include fields that are provided + if (req.body.amount !== undefined) { + updateData.amount = req.body.amount; + } + if (req.body.description !== undefined) { + updateData.description = req.body.description; + } + if (req.body.status !== undefined) { + updateData.status = req.body.status; + } + + const updatedCashRequest = await CashRequestService.updateCashRequest(id, updateData); + + if (!updatedCashRequest) { + return res.status(404).json({ + success: false, + message: 'Cash request not found' + }); + } + + res.status(200).json({ + success: true, + message: 'Cash request updated successfully', + data: updatedCashRequest + }); + } catch (error) { + console.error('Error in updateCashRequest:', error); + res.status(500).json({ + success: false, + message: 'Unable to update cash request', + error: error.message + }); + } +}; + +/** + * Delete a cash request + * @route DELETE /api/v1/cash-requests/:id + * @param {string} id - Cash request UUID + */ +export const deleteCashRequest = async (req, res) => { + try { + const { id } = req.params; + + const deleted = await CashRequestService.deleteCashRequest(id); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: 'Cash request not found' + }); + } + + res.status(200).json({ + success: true, + message: 'Cash request deleted successfully' + }); + } catch (error) { + console.error('Error in deleteCashRequest:', error); + res.status(500).json({ + success: false, + message: 'Unable to delete cash request', + error: error.message + }); + } +}; + +/** + * Approve a cash request + * @route PATCH /api/v1/cash-requests/:id/approve + * @param {string} id - Cash request UUID + */ +export const approveCashRequest = async (req, res) => { + try { + const { id } = req.params; + + const approvedRequest = await CashRequestService.approveCashRequest(id); + + if (!approvedRequest) { + return res.status(404).json({ + success: false, + message: 'Cash request not found' + }); + } + + res.status(200).json({ + success: true, + message: 'Cash request approved successfully', + data: approvedRequest + }); + } catch (error) { + console.error('Error in approveCashRequest:', error); + res.status(500).json({ + success: false, + message: 'Unable to approve cash request', + error: error.message + }); + } +}; + +/** + * Reject a cash request + * @route PATCH /api/v1/cash-requests/:id/reject + * @param {string} id - Cash request UUID + */ +export const rejectCashRequest = async (req, res) => { + try { + const { id } = req.params; + + const rejectedRequest = await CashRequestService.rejectCashRequest(id); + + if (!rejectedRequest) { + return res.status(404).json({ + success: false, + message: 'Cash request not found' + }); + } + + res.status(200).json({ + success: true, + message: 'Cash request rejected successfully', + data: rejectedRequest + }); + } catch (error) { + console.error('Error in rejectCashRequest:', error); + res.status(500).json({ + success: false, + message: 'Unable to reject cash request', + error: error.message + }); + } +}; + +/** + * Get statistics for a technician + * @route GET /api/v1/cash-requests/stats/:technician_id + * @param {string} technician_id - Technician UUID + */ +export const getTechnicianStats = async (req, res) => { + try { + const { technician_id } = req.params; + + const stats = await CashRequestService.getTechnicianStats(technician_id); + + res.status(200).json({ + success: true, + message: 'Technician statistics retrieved successfully', + data: stats + }); + } catch (error) { + console.error('Error in getTechnicianStats:', error); + res.status(500).json({ + success: false, + message: 'Unable to retrieve technician statistics', + error: error.message + }); + } +}; + +/** + * Get cash requests by ticket ID + * @route GET /api/v1/cash-requests?ticket_id= + * @query {string} ticket_id - Ticket ID to filter cash requests + */ +export const getCashRequestsByTicketId = async (req, res) => { + try { + const { ticket_id } = req.query; + + if (!ticket_id) { + return res.status(400).json({ + success: false, + message: 'ticket_id query parameter is required' + }); + } + + const cashRequests = await CashRequestService.getAllCashRequests({ ticket_id }); + + res.status(200).json({ + success: true, + message: 'Cash requests for ticket retrieved successfully', + count: cashRequests.length, + data: cashRequests + }); + } catch (error) { + console.error('Error in getCashRequestsByTicketId:', error); + res.status(500).json({ + success: false, + message: 'Unable to retrieve cash requests for ticket', + error: error.message + }); + } +}; diff --git a/src/database/migrations/20251012000000-create-petty-cash-requests-table.cjs b/src/database/migrations/20251012000000-create-petty-cash-requests-table.cjs new file mode 100644 index 0000000..43db469 --- /dev/null +++ b/src/database/migrations/20251012000000-create-petty-cash-requests-table.cjs @@ -0,0 +1,135 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Create ENUM type for cash request status + await queryInterface.sequelize.query(` + DO $$ BEGIN + CREATE TYPE cash_request_status AS ENUM ('pending', 'approved', 'rejected'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `); + + // Create petty_cash_requests table + await queryInterface.createTable('petty_cash_requests', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('gen_random_uuid()') + }, + technician_id: { + type: Sequelize.INTEGER, + allowNull: false, + comment: 'Foreign key reference to the technician who submitted the request' + }, + ticket_id: { + type: Sequelize.INTEGER, + allowNull: false, + comment: 'Foreign key reference to the maintenance ticket' + }, + amount: { + type: Sequelize.DECIMAL(10, 2), + allowNull: false, + validate: { + min: 0.01 + }, + comment: 'Requested cash amount (max 10 digits, 2 decimal places)' + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + comment: 'Detailed description of what the cash will be used for' + }, + status: { + type: 'cash_request_status', + allowNull: false, + defaultValue: 'pending', + comment: 'Current status: pending, approved, or rejected' + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + field: 'created_at' + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + field: 'updated_at' + } + }); + + // Add indexes for better query performance + await queryInterface.addIndex('petty_cash_requests', ['technician_id'], { + name: 'idx_petty_cash_requests_technician_id' + }); + + await queryInterface.addIndex('petty_cash_requests', ['ticket_id'], { + name: 'idx_petty_cash_requests_ticket_id' + }); + + await queryInterface.addIndex('petty_cash_requests', ['status'], { + name: 'idx_petty_cash_requests_status' + }); + + await queryInterface.addIndex('petty_cash_requests', ['created_at'], { + name: 'idx_petty_cash_requests_created_at' + }); + + // Add check constraint for amount > 0 + await queryInterface.sequelize.query(` + ALTER TABLE petty_cash_requests + ADD CONSTRAINT check_amount_positive + CHECK (amount > 0); + `); + + // Create trigger function to automatically update updated_at timestamp + await queryInterface.sequelize.query(` + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; + `); + + // Create trigger to call the function + await queryInterface.sequelize.query(` + CREATE TRIGGER update_petty_cash_requests_updated_at + BEFORE UPDATE ON petty_cash_requests + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + `); + + // Add table comment + await queryInterface.sequelize.query(` + COMMENT ON TABLE petty_cash_requests IS + 'Stores petty cash requests submitted by technicians for maintenance expenses'; + `); + }, + + async down(queryInterface, Sequelize) { + // Drop trigger + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS update_petty_cash_requests_updated_at ON petty_cash_requests; + `); + + // Drop trigger function + await queryInterface.sequelize.query(` + DROP FUNCTION IF EXISTS update_updated_at_column(); + `); + + // Drop table (indexes and constraints will be dropped automatically) + await queryInterface.dropTable('petty_cash_requests'); + + // Drop ENUM type + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS cash_request_status; + `); + } +}; diff --git a/src/database/seeders/20251018000000-demo-issues.cjs b/src/database/seeders/20251018000000-demo-issues.cjs new file mode 100644 index 0000000..3ac235d --- /dev/null +++ b/src/database/seeders/20251018000000-demo-issues.cjs @@ -0,0 +1,37 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert('issues', [ + { + branch_id: 1, + title: 'Refrigerator cooling issue', + userid: 3, // Mike Manager (branch manager) + description: 'The walk-in refrigerator is not maintaining the proper temperature. Needs urgent inspection.', + createdAt: new Date(), + updatedAt: new Date() + }, + { + branch_id: 1, + title: 'Oven heating element malfunction', + userid: 3, + description: 'One of the pizza ovens has a faulty heating element. Temperature is inconsistent.', + createdAt: new Date(), + updatedAt: new Date() + }, + { + branch_id: 2, + title: 'Air conditioning not working', + userid: 4, // Lisa Manager (branch manager) + description: 'The AC unit in the kitchen area stopped working. Staff are working in high temperatures.', + createdAt: new Date(), + updatedAt: new Date() + } + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('issues', null, {}); + } +}; diff --git a/src/models/index.js b/src/models/index.js index 9338cac..552d4ce 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 PettyCashRequest from './pettyCashRequest.js'; import Technician from './technician.js'; import BranchManager from './branchManager.js'; import MaintenanceExecutive from './maintenanceExecutive.js'; @@ -50,6 +51,7 @@ MaintenanceExecutive.belongsTo(User, { const models = { Issue, User, + PettyCashRequest, Technician, BranchManager, MaintenanceExecutive, @@ -59,8 +61,16 @@ const models = { // Define associations here when you have multiple models // For example: // Issue.belongsTo(User, { foreignKey: 'userid' }); -// Issue.belongsTo(Branch, { foreignKey: 'branch_id' }); -// Note: User-role relationships are now defined above - +// PettyCashRequest.belongsTo(User, { foreignKey: 'technician_id' }); +// Note: No relationships defined yet to avoid conflicts during development +// Export a single default and named exports for convenience export default models; -export { Issue, User, Technician, BranchManager, MaintenanceExecutive, sequelize }; \ No newline at end of file +export { + Issue, + User, + PettyCashRequest, + Technician, + BranchManager, + MaintenanceExecutive, + sequelize +}; \ No newline at end of file diff --git a/src/models/pettyCashRequest.js b/src/models/pettyCashRequest.js new file mode 100644 index 0000000..2650b75 --- /dev/null +++ b/src/models/pettyCashRequest.js @@ -0,0 +1,204 @@ +import { DataTypes } from 'sequelize'; +import { getSequelizeInstance } from '../services/connectionService.js'; + +const sequelize = getSequelizeInstance(); + +const PettyCashRequest = sequelize.define( + 'PettyCashRequest', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + technician_id: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + notNull: { + msg: 'Technician ID is required' + }, + isInt: { + msg: 'Technician ID must be an integer' + } + }, + comment: 'Foreign key reference to the technician who submitted the request' + }, + ticket_id: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + notNull: { + msg: 'Ticket ID is required' + }, + isInt: { + msg: 'Ticket ID must be an integer' + } + }, + comment: 'Foreign key reference to the maintenance ticket' + }, + amount: { + type: DataTypes.DECIMAL(10, 2), + allowNull: false, + validate: { + notNull: { + msg: 'Amount is required' + }, + isDecimal: { + msg: 'Amount must be a valid decimal number' + }, + min: { + args: [0.01], + msg: 'Amount must be greater than 0' + } + }, + comment: 'Requested cash amount (max 10 digits, 2 decimal places)' + }, + description: { + type: DataTypes.TEXT, + allowNull: false, + validate: { + notNull: { + msg: 'Description is required' + }, + notEmpty: { + msg: 'Description cannot be empty' + }, + len: { + args: [10, 5000], + msg: 'Description must be between 10 and 5000 characters' + } + }, + comment: 'Detailed description of what the cash will be used for' + }, + status: { + type: DataTypes.ENUM('pending', 'approved', 'rejected'), + allowNull: false, + defaultValue: 'pending', + validate: { + notNull: { + msg: 'Status is required' + }, + isIn: { + args: [['pending', 'approved', 'rejected']], + msg: 'Status must be pending, approved, or rejected' + } + }, + comment: 'Current status: pending, approved, or rejected' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }, + { + tableName: 'petty_cash_requests', + timestamps: true, + underscored: true, + indexes: [ + { + name: 'idx_petty_cash_requests_technician_id', + fields: ['technician_id'] + }, + { + name: 'idx_petty_cash_requests_ticket_id', + fields: ['ticket_id'] + }, + { + name: 'idx_petty_cash_requests_status', + fields: ['status'] + }, + { + name: 'idx_petty_cash_requests_created_at', + fields: ['created_at'] + } + ] + } +); + +/** + * Get all petty cash requests with optional filtering + * @param {Object} filters - Optional filters (technician_id, status, ticket_id) + * @returns {Promise} Array of cash requests + */ +PettyCashRequest.getAllWithFilters = async function (filters = {}) { + const whereClause = {}; + + if (filters.technician_id) { + whereClause.technician_id = filters.technician_id; + } + + if (filters.status) { + whereClause.status = filters.status; + } + + if (filters.ticket_id) { + whereClause.ticket_id = filters.ticket_id; + } + + return await this.findAll({ + where: whereClause, + order: [['created_at', 'DESC']] + }); +}; + +/** + * Get cash requests statistics for a technician + * @param {string} technician_id - Technician UUID + * @returns {Promise} Statistics object + */ +PettyCashRequest.getStatsByTechnician = async function (technician_id) { + const [results] = await sequelize.query(` + SELECT + COUNT(*) as total_requests, + COUNT(*) FILTER (WHERE status = 'pending') as pending_count, + COUNT(*) FILTER (WHERE status = 'approved') as approved_count, + COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, + COALESCE(SUM(amount) FILTER (WHERE status = 'approved'), 0) as total_approved_amount, + COALESCE(SUM(amount) FILTER (WHERE status = 'pending'), 0) as total_pending_amount + FROM petty_cash_requests + WHERE technician_id = :technician_id + `, { + replacements: { technician_id }, + type: sequelize.QueryTypes.SELECT + }); + + return results[0] || { + total_requests: 0, + pending_count: 0, + approved_count: 0, + rejected_count: 0, + total_approved_amount: 0, + total_pending_amount: 0 + }; +}; + +/** + * Update the status of a cash request + * @param {string} id - Cash request UUID + * @param {string} status - New status (pending, approved, rejected) + * @returns {Promise} Updated cash request or null if not found + */ +PettyCashRequest.updateStatus = async function (id, status) { + const cashRequest = await this.findByPk(id); + + if (!cashRequest) { + return null; + } + + cashRequest.status = status; + await cashRequest.save(); + + return cashRequest; +}; + +export default PettyCashRequest; diff --git a/src/routes/cashRequestRoutes.js b/src/routes/cashRequestRoutes.js new file mode 100644 index 0000000..26c5015 --- /dev/null +++ b/src/routes/cashRequestRoutes.js @@ -0,0 +1,80 @@ +import express from 'express'; +import { body, param } from 'express-validator'; +import { + getAllCashRequests, + getCashRequestById, + createCashRequest, + updateCashRequest, + deleteCashRequest, + approveCashRequest, + rejectCashRequest, + getTechnicianStats, + getCashRequestsByTicketId +} from '../controllers/cashRequestController.js'; + +const router = express.Router(); + +// Validation middleware +const validateUUID = (fieldName) => { + return param(fieldName) + .isUUID() + .withMessage(`${fieldName} must be a valid UUID`); +}; + +const validateId = (fieldName) => { + return param(fieldName) + .isInt({ min: 1 }) + .withMessage(`${fieldName} must be a valid positive integer`); +}; + +const createCashRequestValidation = [ + body('technician_id') + .notEmpty() + .withMessage('technician_id is required') + .isInt({ min: 1 }) + .withMessage('technician_id must be a valid positive integer'), + body('ticket_id') + .notEmpty() + .withMessage('ticket_id is required') + .isInt({ min: 1 }) + .withMessage('ticket_id must be a valid positive integer'), + body('amount') + .notEmpty() + .withMessage('amount is required') + .isFloat({ min: 0.01 }) + .withMessage('amount must be a positive number greater than 0'), + body('description') + .notEmpty() + .withMessage('description is required') + .trim() + .isLength({ min: 10, max: 5000 }) + .withMessage('description must be between 10 and 5000 characters') +]; + +const updateCashRequestValidation = [ + body('amount') + .optional() + .isFloat({ min: 0.01 }) + .withMessage('amount must be a positive number greater than 0'), + body('description') + .optional() + .trim() + .isLength({ min: 10, max: 5000 }) + .withMessage('description must be between 10 and 5000 characters'), + body('status') + .optional() + .isIn(['pending', 'approved', 'rejected']) + .withMessage('status must be one of: pending, approved, rejected') +]; + +router.get('/', getAllCashRequests); +router.get('/by-ticket', getCashRequestsByTicketId); +router.get('/stats/:technician_id', validateId('technician_id'), getTechnicianStats); +router.get('/:id', validateUUID('id'), getCashRequestById); +router.post('/', createCashRequestValidation, createCashRequest); +router.put('/:id', validateUUID('id'), updateCashRequestValidation, updateCashRequest); +router.delete('/:id', validateUUID('id'), deleteCashRequest); +router.patch('/:id/approve', validateUUID('id'), approveCashRequest); +router.patch('/:id/reject', validateUUID('id'), rejectCashRequest); + +export default router; diff --git a/src/services/cashRequestService.js b/src/services/cashRequestService.js new file mode 100644 index 0000000..6ba2c18 --- /dev/null +++ b/src/services/cashRequestService.js @@ -0,0 +1,186 @@ +/** + * Cash Request Service + * Business logic layer for petty cash requests + */ + +import PettyCashRequest from '../models/pettyCashRequest.js'; + +/** + * Get all cash requests with optional filtering + * @param {Object} filters - Optional filters (technician_id, status, ticket_id) + * @returns {Promise} Array of cash requests + */ +export const getAllCashRequests = async (filters = {}) => { + try { + const cashRequests = await PettyCashRequest.getAllWithFilters(filters); + return cashRequests.map(req => req.toJSON()); + } catch (error) { + throw new Error(`Service error in getAllCashRequests: ${error.message}`); + } +}; + +/** + * Get a single cash request by ID + * @param {string} id - Cash request UUID + * @returns {Promise} Cash request object or null + */ +export const getCashRequestById = async (id) => { + try { + const cashRequest = await PettyCashRequest.findByPk(id); + return cashRequest ? cashRequest.toJSON() : null; + } catch (error) { + throw new Error(`Service error in getCashRequestById: ${error.message}`); + } +}; + +/** + * Create a new cash request + * @param {Object} cashRequestData - Cash request data + * @returns {Promise} Created cash request + */ +export const createCashRequest = async (cashRequestData) => { + try { + // Validate required fields + const { technician_id, ticket_id, amount, description } = cashRequestData; + + if (!technician_id || !ticket_id || !amount || !description) { + throw new Error('Missing required fields: technician_id, ticket_id, amount, description'); + } + + // Validate amount is positive + if (amount <= 0) { + throw new Error('Amount must be greater than 0'); + } + + // Validate description is not empty + if (!description.trim()) { + throw new Error('Description cannot be empty'); + } + + const newCashRequest = await PettyCashRequest.create(cashRequestData); + return newCashRequest.toJSON(); + } catch (error) { + throw new Error(`Service error in createCashRequest: ${error.message}`); + } +}; + +/** + * Update an existing cash request + * @param {string} id - Cash request UUID + * @param {Object} updateData - Data to update + * @returns {Promise} Updated cash request or null + */ +export const updateCashRequest = async (id, updateData) => { + try { + // Check if cash request exists + const existingRequest = await PettyCashRequest.findByPk(id); + if (!existingRequest) { + return null; + } + + // Validate amount if provided + if (updateData.amount !== undefined && updateData.amount <= 0) { + throw new Error('Amount must be greater than 0'); + } + + // Validate status if provided + if (updateData.status && !['pending', 'approved', 'rejected'].includes(updateData.status)) { + throw new Error('Invalid status. Must be: pending, approved, or rejected'); + } + + // Validate description if provided + if (updateData.description !== undefined && !updateData.description.trim()) { + throw new Error('Description cannot be empty'); + } + + // Update the request + await existingRequest.update(updateData); + return existingRequest.toJSON(); + } catch (error) { + throw new Error(`Service error in updateCashRequest: ${error.message}`); + } +}; + +/** + * Delete a cash request + * @param {string} id - Cash request UUID + * @returns {Promise} True if deleted, false if not found + */ +export const deleteCashRequest = async (id) => { + try { + // Check if cash request exists + const existingRequest = await PettyCashRequest.findByPk(id); + if (!existingRequest) { + return false; + } + + await existingRequest.destroy(); + return true; + } catch (error) { + throw new Error(`Service error in deleteCashRequest: ${error.message}`); + } +}; + +/** + * Approve a cash request + * @param {string} id - Cash request UUID + * @returns {Promise} Updated cash request or null + */ +export const approveCashRequest = async (id) => { + try { + const cashRequest = await PettyCashRequest.findByPk(id); + + if (!cashRequest) { + return null; + } + + // Check if already approved + if (cashRequest.status === 'approved') { + throw new Error('Cash request is already approved'); + } + + const updatedRequest = await PettyCashRequest.updateStatus(id, 'approved'); + return updatedRequest.toJSON(); + } catch (error) { + throw new Error(`Service error in approveCashRequest: ${error.message}`); + } +}; + +/** + * Reject a cash request + * @param {string} id - Cash request UUID + * @returns {Promise} Updated cash request or null + */ +export const rejectCashRequest = async (id) => { + try { + const cashRequest = await PettyCashRequest.findByPk(id); + + if (!cashRequest) { + return null; + } + + // Check if already rejected + if (cashRequest.status === 'rejected') { + throw new Error('Cash request is already rejected'); + } + + const updatedRequest = await PettyCashRequest.updateStatus(id, 'rejected'); + return updatedRequest.toJSON(); + } catch (error) { + throw new Error(`Service error in rejectCashRequest: ${error.message}`); + } +}; + +/** + * Get statistics for a technician's cash requests + * @param {string} technician_id - Technician UUID + * @returns {Promise} Statistics object + */ +export const getTechnicianStats = async (technician_id) => { + try { + const stats = await PettyCashRequest.getStatsByTechnician(technician_id); + return stats; + } catch (error) { + throw new Error(`Service error in getTechnicianStats: ${error.message}`); + } +};