Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/controllers/statusController.js
Original file line number Diff line number Diff line change
@@ -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();
76 changes: 76 additions & 0 deletions src/database/migrations/20251027183315-create-statuses-table.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
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',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},

user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
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.literal('CURRENT_TIMESTAMP')
},

updatedAt: {
allowNull: false,
type: Sequelize.DATE,
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;');
}
};
32 changes: 30 additions & 2 deletions src/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand All @@ -195,8 +223,8 @@ const models = {
BranchManager,
MaintenanceExecutive,
ThirdParty,
Status,
sequelize
};


export default models;
export default models;
71 changes: 71 additions & 0 deletions src/models/status.js
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/routes/issues.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router } from 'express';
import issueController from '../controllers/issueController.js';
import statusController from '../controllers/statusController.js';

const router = Router();

Expand Down Expand Up @@ -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;
92 changes: 92 additions & 0 deletions src/services/statusService.js
Original file line number Diff line number Diff line change
@@ -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();
Loading