From 11c52b3c48e45644c3765bf989e1add7c171024c Mon Sep 17 00:00:00 2001 From: Vindya Kodithuwakku Date: Tue, 28 Oct 2025 00:16:04 +0530 Subject: [PATCH 1/2] feat: add Statuses table migration --- .../20251027183315-create-statuses-table.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/database/migrations/20251027183315-create-statuses-table.js diff --git a/src/database/migrations/20251027183315-create-statuses-table.js b/src/database/migrations/20251027183315-create-statuses-table.js new file mode 100644 index 0000000..d37a3fb --- /dev/null +++ b/src/database/migrations/20251027183315-create-statuses-table.js @@ -0,0 +1,65 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +export default { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('Statuses', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + issue_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Issues', // name of your Issues table + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Users', // name of your Users table + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + description: { + type: Sequelize.TEXT, + allowNull: false + }, + image_url: { + type: Sequelize.STRING, + allowNull: true + }, + status_type: { + type: Sequelize.ENUM('Open', 'Assigned', 'In Progress', 'Resolved', 'Closed'), + allowNull: false, + defaultValue: 'Open' + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW') + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW') + } + }); + + await queryInterface.addIndex('Statuses', ['issue_id'], { name: 'idx_status_issue_id' }); + await queryInterface.addIndex('Statuses', ['user_id'], { name: 'idx_status_user_id' }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('Statuses'); + } +}; From a6871214862465c3f4f158392f9ca65d6ef63836 Mon Sep 17 00:00:00 2001 From: Vindya Kodithuwakku Date: Mon, 2 Mar 2026 11:11:16 +0530 Subject: [PATCH 2/2] feat: add status history feature --- src/controllers/statusController.js | 65 +++++++++++++ ... 20251027183315-create-statuses-table.cjs} | 23 +++-- src/models/index.js | 32 ++++++- src/models/status.js | 71 ++++++++++++++ src/routes/issues.js | 10 ++ src/services/statusService.js | 92 +++++++++++++++++++ 6 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 src/controllers/statusController.js rename src/database/migrations/{20251027183315-create-statuses-table.js => 20251027183315-create-statuses-table.cjs} (75%) create mode 100644 src/models/status.js create mode 100644 src/services/statusService.js diff --git a/src/controllers/statusController.js b/src/controllers/statusController.js new file mode 100644 index 0000000..c4f8e30 --- /dev/null +++ b/src/controllers/statusController.js @@ -0,0 +1,65 @@ +import statusService from '../services/statusService.js'; + +class StatusController { + // POST /api/v1/issues/:id/statuses + async createStatusForIssue(req, res) { + try { + const { id } = req.params; + const { user_id, description, image_url, status_type } = req.body; + + if (!id || isNaN(id)) { + return res.status(400).json({ success: false, message: 'Valid issue ID is required' }); + } + + const result = await statusService.createStatusForIssue(parseInt(id), { + user_id, + description, + image_url, + status_type + }); + + if (result.success) return res.status(201).json(result); + + if (result.message === 'Issue not found' || result.message === 'User not found') { + return res.status(404).json(result); + } + + 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/issues/:id/statuses + async getStatusesByIssue(req, res) { + try { + const { id } = req.params; + + if (!id || isNaN(id)) { + return res.status(400).json({ success: false, message: 'Valid issue ID is required' }); + } + + const result = await statusService.getStatusesByIssue(parseInt(id)); + + if (result.success) return res.status(200).json(result); + + if (result.message === 'Issue not found') { + return res.status(404).json(result); + } + + return res.status(500).json(result); + } catch (error) { + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message + }); + } + } +} + +export default new StatusController(); \ No newline at end of file diff --git a/src/database/migrations/20251027183315-create-statuses-table.js b/src/database/migrations/20251027183315-create-statuses-table.cjs similarity index 75% rename from src/database/migrations/20251027183315-create-statuses-table.js rename to src/database/migrations/20251027183315-create-statuses-table.cjs index d37a3fb..ab99bac 100644 --- a/src/database/migrations/20251027183315-create-statuses-table.js +++ b/src/database/migrations/20251027183315-create-statuses-table.cjs @@ -1,7 +1,7 @@ 'use strict'; /** @type {import('sequelize-cli').Migration} */ -export default { +module.exports = { async up(queryInterface, Sequelize) { await queryInterface.createTable('Statuses', { id: { @@ -10,56 +10,67 @@ export default { primaryKey: true, type: Sequelize.INTEGER }, + issue_id: { type: Sequelize.INTEGER, allowNull: false, references: { - model: 'Issues', // name of your Issues table + model: 'Issues', key: 'id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' }, + user_id: { type: Sequelize.INTEGER, allowNull: false, references: { - model: 'Users', // name of your Users table + model: 'Users', key: 'id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' }, + description: { type: Sequelize.TEXT, allowNull: false }, + image_url: { type: Sequelize.STRING, allowNull: true }, + status_type: { type: Sequelize.ENUM('Open', 'Assigned', 'In Progress', 'Resolved', 'Closed'), allowNull: false, defaultValue: 'Open' }, + createdAt: { allowNull: false, type: Sequelize.DATE, - defaultValue: Sequelize.fn('NOW') + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + updatedAt: { allowNull: false, type: Sequelize.DATE, - defaultValue: Sequelize.fn('NOW') + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') } }); await queryInterface.addIndex('Statuses', ['issue_id'], { name: 'idx_status_issue_id' }); await queryInterface.addIndex('Statuses', ['user_id'], { name: 'idx_status_user_id' }); + await queryInterface.addIndex('Statuses', ['createdAt'], { name: 'idx_status_createdAt' }); }, async down(queryInterface, Sequelize) { await queryInterface.dropTable('Statuses'); + + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_Statuses_status_type";'); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS enum_Statuses_status_type;'); } -}; +}; \ No newline at end of file diff --git a/src/models/index.js b/src/models/index.js index 2230cd0..f1a8de7 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -8,6 +8,7 @@ import Technician from './technician.js'; import BranchManager from './branchManager.js'; import MaintenanceExecutive from './maintenanceExecutive.js'; import ThirdParty from './thirdParty.js'; +import Status from './status.js'; const sequelize = getSequelizeInstance(); @@ -184,6 +185,33 @@ PettyCashRequest.belongsTo(Technician, { as: 'technician' }); +// +// ✅ NEW: Status associations +// +Issue.hasMany(Status, { + foreignKey: 'issue_id', + as: 'statuses', + onDelete: 'CASCADE' +}); + +Status.belongsTo(Issue, { + foreignKey: 'issue_id', + as: 'issue', + onDelete: 'CASCADE' +}); + +User.hasMany(Status, { + foreignKey: 'user_id', + as: 'statuses', + onDelete: 'CASCADE' +}); + +Status.belongsTo(User, { + foreignKey: 'user_id', + as: 'user', + onDelete: 'CASCADE' +}); + // Initialize all models const models = { Issue, @@ -195,8 +223,8 @@ const models = { BranchManager, MaintenanceExecutive, ThirdParty, + Status, sequelize }; - -export default models; +export default models; \ No newline at end of file diff --git a/src/models/status.js b/src/models/status.js new file mode 100644 index 0000000..db19fda --- /dev/null +++ b/src/models/status.js @@ -0,0 +1,71 @@ +import { DataTypes } from 'sequelize'; +import { getSequelizeInstance } from '../services/connectionService.js'; + +const sequelize = getSequelizeInstance(); + +const Status = sequelize.define( + 'Status', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + + issue_id: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + notNull: { msg: 'Issue ID is required' }, + isInt: { msg: 'Issue ID must be an integer' } + } + }, + + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + notNull: { msg: 'User ID is required' }, + isInt: { msg: 'User ID must be an integer' } + } + }, + + description: { + type: DataTypes.TEXT, + allowNull: false, + validate: { + notNull: { msg: 'Description is required' }, + notEmpty: { msg: 'Description cannot be empty' } + } + }, + + image_url: { + type: DataTypes.STRING, + allowNull: true + }, + + status_type: { + type: DataTypes.ENUM('Open', 'Assigned', 'In Progress', 'Resolved', 'Closed'), + allowNull: false, + defaultValue: 'Open', + validate: { + isIn: { + args: [['Open', 'Assigned', 'In Progress', 'Resolved', 'Closed']], + msg: 'Status type must be one of: Open, Assigned, In Progress, Resolved, Closed' + } + } + } + }, + { + tableName: 'Statuses', + timestamps: true, + indexes: [ + { fields: ['issue_id'] }, + { fields: ['user_id'] }, + { fields: ['createdAt'] } + ] + } +); + +export default Status; \ No newline at end of file diff --git a/src/routes/issues.js b/src/routes/issues.js index 985f669..5d3d950 100644 --- a/src/routes/issues.js +++ b/src/routes/issues.js @@ -1,5 +1,6 @@ import { Router } from 'express'; import issueController from '../controllers/issueController.js'; +import statusController from '../controllers/statusController.js'; const router = Router(); @@ -30,4 +31,13 @@ router.post('/:id/assign-third-party', issueController.assignThirdParty); // PUT /api/v1/issues/:id/status - Update issue status router.put('/:id/status', issueController.updateStatus); +// +// ✅ NEW: Status history endpoints +// +// GET /api/v1/issues/:id/statuses - Get all status updates for an issue +router.get('/:id/statuses', statusController.getStatusesByIssue); + +// POST /api/v1/issues/:id/statuses - Create a new status update for an issue +router.post('/:id/statuses', statusController.createStatusForIssue); + export default router; \ No newline at end of file diff --git a/src/services/statusService.js b/src/services/statusService.js new file mode 100644 index 0000000..2d2982c --- /dev/null +++ b/src/services/statusService.js @@ -0,0 +1,92 @@ +import models from '../models/index.js'; + +const { Status, Issue, User } = models; + +class StatusService { + async createStatusForIssue(issueId, statusData) { + try { + const issue = await Issue.findByPk(issueId); + if (!issue) { + return { success: false, message: 'Issue not found' }; + } + + const { user_id, description, image_url, status_type } = statusData; + + if (!user_id) return { success: false, message: 'user_id is required' }; + if (!description) return { success: false, message: 'description is required' }; + + const parsedUserId = parseInt(user_id); + if (isNaN(parsedUserId)) { + return { success: false, message: 'user_id must be a valid integer' }; + } + + const user = await User.findByPk(parsedUserId); + if (!user) { + return { success: false, message: 'User not found' }; + } + + const validTypes = ['Open', 'Assigned', 'In Progress', 'Resolved', 'Closed']; + if (status_type && !validTypes.includes(status_type)) { + return { success: false, message: `status_type must be one of: ${validTypes.join(', ')}` }; + } + + const created = await Status.create({ + issue_id: parseInt(issueId), + user_id: parsedUserId, + description, + image_url: image_url || null, + status_type: status_type || 'Open' + }); + + return { + success: true, + message: 'Status update created successfully', + data: created + }; + } catch (error) { + console.error('Error creating status update:', error); + return { + success: false, + message: 'Failed to create status update', + error: error.message + }; + } + } + + async getStatusesByIssue(issueId) { + try { + const issue = await Issue.findByPk(issueId); + if (!issue) { + return { success: false, message: 'Issue not found' }; + } + + const statuses = await Status.findAll({ + where: { issue_id: parseInt(issueId) }, + include: [ + { + model: User, + as: 'user', + attributes: ['id', 'name', 'email'] + } + ], + order: [['createdAt', 'ASC']] + }); + + return { + success: true, + message: 'Status updates retrieved successfully', + data: statuses, + count: statuses.length + }; + } catch (error) { + console.error('Error retrieving status updates:', error); + return { + success: false, + message: 'Failed to retrieve status updates', + error: error.message + }; + } + } +} + +export default new StatusService(); \ No newline at end of file