Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
ec6daa9a95 Autosave: 20260322-194607 2026-03-22 19:46:07 +00:00
60 changed files with 1733 additions and 3131 deletions

View File

@ -317,7 +317,7 @@ module.exports = class Activity_logsDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -517,7 +517,7 @@ module.exports = class Activity_logsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -328,7 +328,7 @@ module.exports = class Appeal_draftsDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -515,7 +515,7 @@ module.exports = class Appeal_draftsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -480,7 +480,7 @@ module.exports = class CasesDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -849,7 +849,7 @@ module.exports = class CasesDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -343,7 +343,7 @@ module.exports = class DocumentsDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -537,7 +537,7 @@ module.exports = class DocumentsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -293,7 +293,7 @@ module.exports = class NotesDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -458,7 +458,7 @@ module.exports = class NotesDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -326,7 +326,7 @@ module.exports = class PayersDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -512,7 +512,7 @@ module.exports = class PayersDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -270,7 +270,7 @@ module.exports = class SettingsDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -408,7 +408,7 @@ module.exports = class SettingsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -317,7 +317,7 @@ module.exports = class TasksDBApi {
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId; where.organizationId = options.currentUser.organizationsId;
} }
} }
@ -548,7 +548,7 @@ module.exports = class TasksDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationId;
} }

View File

@ -832,7 +832,7 @@ module.exports = class UsersDBApi {
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
where.organizationId = organizationId; where.organizationsId = organizationId;
} }

View File

@ -0,0 +1,43 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('activity_logs', 'actionType', {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn('activity_logs', 'metadata', {
type: Sequelize.JSONB,
allowNull: true,
});
await queryInterface.addColumn('appeal_drafts', 'version', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 1,
});
await queryInterface.addColumn('appeal_drafts', 'summary', {
type: Sequelize.TEXT,
allowNull: true,
});
await queryInterface.addColumn('appeal_drafts', 'body', {
type: Sequelize.TEXT,
allowNull: true,
});
await queryInterface.addColumn('appeal_drafts', 'evidenceChecklist', {
type: Sequelize.JSONB,
allowNull: true,
});
await queryInterface.addColumn('appeal_drafts', 'submittedByUserId', {
type: Sequelize.UUID,
allowNull: true,
});
try {
await queryInterface.sequelize.query('ALTER TABLE appeal_drafts ALTER COLUMN status TYPE VARCHAR(255) USING status::VARCHAR;');
await queryInterface.sequelize.query('ALTER TABLE cases ALTER COLUMN status TYPE VARCHAR(255) USING status::VARCHAR;');
} catch(e) {
console.log(e);
}
},
down: async (queryInterface, Sequelize) => {
}
};

View File

@ -0,0 +1,33 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// Cases indexes
await queryInterface.addIndex('cases', ['organizationId']);
await queryInterface.addIndex('cases', ['status']);
await queryInterface.addIndex('cases', ['owner_userId']);
await queryInterface.addIndex('cases', ['due_at']);
await queryInterface.addIndex('cases', ['submitted_at']);
await queryInterface.addIndex('cases', ['createdAt']);
// Composite indexes for org-scoped case filtering
await queryInterface.addIndex('cases', ['organizationId', 'status']);
await queryInterface.addIndex('cases', ['organizationId', 'owner_userId']);
await queryInterface.addIndex('cases', ['organizationId', 'createdAt']);
// Relational indexes (caseId) on other tables
await queryInterface.addIndex('activity_logs', ['caseId']);
await queryInterface.addIndex('appeal_drafts', ['caseId']);
await queryInterface.addIndex('documents', ['caseId']);
await queryInterface.addIndex('notes', ['caseId']);
await queryInterface.addIndex('tasks', ['caseId']);
// Org filtering on other tables
await queryInterface.addIndex('activity_logs', ['organizationId']);
await queryInterface.addIndex('appeal_drafts', ['organizationId']);
await queryInterface.addIndex('documents', ['organizationId']);
await queryInterface.addIndex('notes', ['organizationId']);
await queryInterface.addIndex('tasks', ['organizationId']);
},
down: async (queryInterface, Sequelize) => {
// No-op for safety, or we could remove indexes
}
};

View File

@ -98,7 +98,10 @@ action: {
}, },
message: { actionType: { type: DataTypes.STRING },
metadata: { type: DataTypes.JSONB },
message: {
type: DataTypes.TEXT, type: DataTypes.TEXT,

View File

@ -14,7 +14,12 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true, primaryKey: true,
}, },
title: { version: { type: DataTypes.INTEGER, defaultValue: 1 },
summary: { type: DataTypes.TEXT },
body: { type: DataTypes.TEXT },
evidenceChecklist: { type: DataTypes.JSONB },
title: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
@ -22,27 +27,7 @@ title: {
}, },
status: { status: {
type: DataTypes.ENUM, type: DataTypes.STRING,
values: [
"draft",
"in_review",
"approved",
"sent",
"archived"
],
}, },
@ -112,6 +97,14 @@ submitted_at: {
constraints: false, constraints: false,
}); });
db.appeal_drafts.belongsTo(db.users, {
as: "submittedByUser",
foreignKey: {
name: "submittedByUserId",
},
constraints: false,
});
db.appeal_drafts.belongsTo(db.users, { db.appeal_drafts.belongsTo(db.users, {
as: 'author_user', as: 'author_user',
foreignKey: { foreignKey: {

View File

@ -92,36 +92,7 @@ amount_at_risk: {
}, },
status: { status: {
type: DataTypes.ENUM, type: DataTypes.STRING,
values: [
"intake",
"triage",
"evidence_needed",
"appeal_ready",
"submitted",
"pending_payer",
"won",
"lost"
],
}, },

View File

@ -84,13 +84,7 @@ router.use(checkCrudPermissions('activity_logs'));
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await Activity_logsService.create(req.body.data, req.currentUser, true, link.host);
const payload = true;
res.status(200).send(payload);
}));
/** /**
* @swagger * @swagger
@ -127,13 +121,7 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
* *
*/ */
router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await Activity_logsService.bulkImport(req, res, true, link.host);
const payload = true;
res.status(200).send(payload);
}));
/** /**
* @swagger * @swagger
@ -183,11 +171,7 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.put('/:id', wrapAsync(async (req, res) => {
await Activity_logsService.update(req.body.data, req.body.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}));
/** /**
* @swagger * @swagger
@ -221,11 +205,7 @@ router.put('/:id', wrapAsync(async (req, res) => {
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.delete('/:id', wrapAsync(async (req, res) => {
await Activity_logsService.remove(req.params.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}));
/** /**
* @swagger * @swagger
@ -259,11 +239,7 @@ router.delete('/:id', wrapAsync(async (req, res) => {
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.post('/deleteByIds', wrapAsync(async (req, res) => {
await Activity_logsService.deleteByIds(req.body.data, req.currentUser);
const payload = true;
res.status(200).send(payload);
}));
/** /**
* @swagger * @swagger

View File

@ -439,6 +439,16 @@ router.get('/:id', wrapAsync(async (req, res) => {
res.status(200).send(payload); res.status(200).send(payload);
})); }));
router.put('/:id/submit', wrapAsync(async (req, res) => {
const payload = await Appeal_draftsService.submit(
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.use('/', require('../helpers').commonErrorHandler); router.use('/', require('../helpers').commonErrorHandler);
module.exports = router; module.exports = router;

View File

@ -465,6 +465,64 @@ router.get('/:id', wrapAsync(async (req, res) => {
res.status(200).send(payload); res.status(200).send(payload);
})); }));
router.put('/:id/assign-owner', wrapAsync(async (req, res) => {
const payload = await CasesService.assignOwner(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/change-status', wrapAsync(async (req, res) => {
const payload = await CasesService.changeStatus(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/reopen', wrapAsync(async (req, res) => {
const payload = await CasesService.reopen(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/mark-won', wrapAsync(async (req, res) => {
const payload = await CasesService.markWon(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/mark-lost', wrapAsync(async (req, res) => {
const payload = await CasesService.markLost(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/submit-appeal', wrapAsync(async (req, res) => {
const payload = await CasesService.submitAppeal(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.use('/', require('../helpers').commonErrorHandler); router.use('/', require('../helpers').commonErrorHandler);
module.exports = router; module.exports = router;

View File

@ -5,7 +5,16 @@ const passport = require('passport');
const services = require('../services/file'); const services = require('../services/file');
const router = express.Router(); const router = express.Router();
router.get('/download', (req, res) => { router.get('/download', passport.authenticate('jwt', {session: false}), async (req, res) => {
// Tenant security check for documents
if (req.query.privateUrl && req.query.privateUrl.startsWith('documents/')) {
const db = require('../db/models');
const doc = await db.documents.findOne({ where: { attachments: { [db.Sequelize.Op.like]: '%' + req.query.privateUrl + '%' } } });
if (!doc || (doc.organizationId !== req.currentUser.organizationId && !req.currentUser.app_role.globalAccess)) {
return res.status(403).send('Forbidden');
}
}
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
services.downloadGCloud(req, res); services.downloadGCloud(req, res);
} }

View File

@ -12,108 +12,11 @@ const stream = require('stream');
module.exports = class Activity_logsService { module.exports = class Activity_logsService {
static async create(data, currentUser) { static async create() { throw new Error('ActivityLogs are read-only'); }
const transaction = await db.sequelize.transaction(); static async bulkImport() { throw new Error('ActivityLogs are read-only'); }
try { static async update() { throw new Error('ActivityLogs are read-only'); }
await Activity_logsDBApi.create( static async deleteByIds() { throw new Error('ActivityLogs are read-only'); }
data, static async remove(id, currentUser) { throw new Error("ActivityLogs are read-only");
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
};
static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Activity_logsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let activity_logs = await Activity_logsDBApi.findBy(
{id},
{transaction},
);
if (!activity_logs) {
throw new ValidationError(
'activity_logsNotFound',
);
}
const updatedActivity_logs = await Activity_logsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedActivity_logs;
} catch (error) {
await transaction.rollback();
throw error;
}
};
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Activity_logsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {

View File

@ -1,138 +1,90 @@
const db = require('../db/models'); const db = require('../db/models');
const Appeal_draftsDBApi = require('../db/api/appeal_drafts'); const { ValidationError } = require('../helpers');
const processFile = require("../middlewares/upload"); const { Logger } = require('./logger');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
class Appeal_draftsService {
module.exports = class Appeal_draftsService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); if (!data.caseId) {
try { throw new ValidationError('caseIdRequired', 'Case ID is required');
await Appeal_draftsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
} }
};
static async bulkImport(req, res, sendInvitationEmails = true, host) { // Auth: current user org must match case org
const transaction = await db.sequelize.transaction(); const cases = await db.cases.findByPk(data.caseId);
if (!cases || (cases.organizationId !== currentUser.organizationId && !currentUser.app_role.globalAccess)) {
throw new ValidationError('accessDenied', 'Cannot create draft for this case');
}
try { // Versioning
await processFile(req, res); const maxVersionDraft = await db.appeal_drafts.findOne({
const bufferStream = new stream.PassThrough(); where: { caseId: data.caseId },
const results = []; order: [['version', 'DESC']]
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Appeal_draftsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
}); });
await transaction.commit(); data.version = (maxVersionDraft ? maxVersionDraft.version : 0) + 1;
} catch (error) { data.organizationId = currentUser.organizationId;
await transaction.rollback(); data.status = 'draft';
throw error;
} const draft = await db.appeal_drafts.create(data);
await Logger.log(currentUser, 'appeal_drafts', draft.id, 'Draft created', { version: data.version });
return draft;
} }
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const draft = await db.appeal_drafts.findByPk(id);
try { if (!draft) {
let appeal_drafts = await Appeal_draftsDBApi.findBy( throw new ValidationError('draftNotFound', 'Draft not found');
{id},
{transaction},
);
if (!appeal_drafts) {
throw new ValidationError(
'appeal_draftsNotFound',
);
} }
const updatedAppeal_drafts = await Appeal_draftsDBApi.update( if (draft.status === 'submitted') {
id, throw new ValidationError('draftIsReadOnly', 'Submitted drafts are read-only');
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedAppeal_drafts;
} catch (error) {
await transaction.rollback();
throw error;
} }
};
static async deleteByIds(ids, currentUser) { const isSubmitting = data.status === 'submitted';
const transaction = await db.sequelize.transaction();
try { if (isSubmitting) {
await Appeal_draftsDBApi.deleteByIds(ids, { // Role check: Only case owner or admin can submit appeal
currentUser, const cases = await db.cases.findByPk(draft.caseId);
transaction, const isAdmin = currentUser.app_role.name === 'admin' || currentUser.app_role.globalAccess;
const isOwner = cases.owner_userId === currentUser.id;
if (!isOwner && !isAdmin) {
throw new ValidationError('accessDenied', 'Only the case owner or an administrator can submit an appeal');
}
// Only one draft can be submitted per case
const existingSubmitted = await db.appeal_drafts.findOne({
where: { caseId: draft.caseId, status: 'submitted' }
}); });
await transaction.commit(); if (existingSubmitted) {
} catch (error) { throw new ValidationError('alreadySubmitted', 'Another draft is already submitted for this case');
await transaction.rollback();
throw error;
} }
data.submitted_at = new Date();
data.submittedByUserId = currentUser.id;
// Sync case status
const CasesService = require('./cases');
await CasesService.update({ status: 'submitted', submitted_at: data.submitted_at }, draft.caseId, currentUser);
}
await db.appeal_drafts.update(data, { where: { id } });
return await db.appeal_drafts.findByPk(id);
}
static async deleteByIds(ids, currentUser) {
for (const id of ids) {
const draft = await db.appeal_drafts.findByPk(id);
if (draft && draft.status === 'submitted') {
throw new ValidationError('cannotDeleteSubmittedDraft', 'Cannot delete submitted drafts');
}
}
return await db.appeal_drafts.destroy({ where: { id: ids } });
} }
static async remove(id, currentUser) { static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction(); return this.deleteByIds([id], currentUser);
try {
await Appeal_draftsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
} }
}
module.exports = Appeal_draftsService;
};

View File

@ -1,138 +1,90 @@
const db = require('../db/models'); const db = require('../db/models');
const CasesDBApi = require('../db/api/cases'); const { ValidationError } = require('../helpers');
const processFile = require("../middlewares/upload"); const { Logger } = require('./logger');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
module.exports = class CasesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await CasesDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
};
static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await CasesDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
class CasesService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const cases = await db.cases.findByPk(id);
try {
let cases = await CasesDBApi.findBy(
{id},
{transaction},
);
if (!cases) { if (!cases) {
throw new ValidationError( throw new ValidationError('casesNotFound', 'Case not found');
'casesNotFound',
);
} }
const updatedCases = await CasesDBApi.update( if (currentUser.organizationId !== cases.organizationId && !currentUser.app_role.globalAccess) {
id, throw new ValidationError('accessDenied', 'Access denied');
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedCases;
} catch (error) {
await transaction.rollback();
throw error;
} }
};
static async deleteByIds(ids, currentUser) { // Role check: Only case owner or admin can change status
const transaction = await db.sequelize.transaction(); const isAdmin = currentUser.app_role.name === 'admin' || currentUser.app_role.globalAccess;
const isOwner = cases.owner_userId === currentUser.id;
try { if (data.status && data.status !== cases.status) {
await CasesDBApi.deleteByIds(ids, { if (!isOwner && !isAdmin) {
currentUser, throw new ValidationError('accessDenied', 'Only the case owner or an administrator can change the case status');
transaction, }
// Persist timestamps based on status
if (data.status === 'submitted') {
data.submitted_at = new Date();
} else if (['won', 'lost'].includes(data.status)) {
data.closed_at = new Date();
}
await Logger.log(currentUser, 'cases', id, `Status changed from ${cases.status} to ${data.status}`, {
from: cases.status,
to: data.status,
reason: data.statusReason || ''
}); });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
} }
if (data.owner_userId && data.owner_userId !== cases.owner_userId) {
await Logger.log(currentUser, 'cases', id, `Owner changed`, {
from: cases.owner_userId,
to: data.owner_userId
});
}
return await db.cases.update(data, { where: { id } });
}
static async assignOwner(id, ownerUserId, currentUser) {
return this.update({ owner_userId: ownerUserId }, id, currentUser);
}
static async changeStatus(id, status, statusReason, currentUser) {
return this.update({ status, statusReason }, id, currentUser);
}
static async submitAppeal(id, currentUser) {
return this.update({ status: 'submitted' }, id, currentUser);
}
static async markWon(id, reason, currentUser) {
return this.update({ status: 'won', statusReason: reason }, id, currentUser);
}
static async markLost(id, reason, currentUser) {
return this.update({ status: 'lost', statusReason: reason }, id, currentUser);
}
static async reopen(id, reason, currentUser) {
const cases = await db.cases.findByPk(id);
if (!['won', 'lost', 'submitted'].includes(cases.status)) {
throw new ValidationError('invalidReopen', 'Only submitted or closed cases can be reopened');
}
return this.update({ status: 'intake', statusReason: reason, closed_at: null }, id, currentUser);
} }
static async remove(id, currentUser) { static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction(); const cases = await db.cases.findByPk(id);
if (!cases) {
try { throw new ValidationError('casesNotFound', 'Case not found');
await CasesDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
} }
if (currentUser.organizationId !== cases.organizationId && !currentUser.app_role.globalAccess) {
throw new ValidationError('accessDenied', 'Access denied');
} }
return await db.cases.destroy({ where: { id } });
}
}
module.exports = CasesService;
};

View File

@ -5,6 +5,8 @@ const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const Logger = require('./logger');
const stream = require('stream'); const stream = require('stream');
@ -13,9 +15,18 @@ const stream = require('stream');
module.exports = class DocumentsService { module.exports = class DocumentsService {
static async create(data, currentUser) { static async create(data, currentUser) {
if (data.case) {
const CasesDBApi = require('../db/api/cases');
const dbCase = await CasesDBApi.findBy({id: data.case});
if (!dbCase || (dbCase.organizationId !== currentUser.organizationId && !currentUser.app_role.globalAccess)) {
throw new ValidationError('documentsNotFound', 'Case not found or access denied');
}
data.organization = dbCase.organizationId;
}
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await DocumentsDBApi.create( const newEntity = await DocumentsDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -24,6 +35,7 @@ module.exports = class DocumentsService {
); );
await transaction.commit(); await transaction.commit();
if (newEntity.caseId) { await Logger.log({ organizationId: newEntity.organizationId, caseId: newEntity.caseId, actorUserId: currentUser.id, actionType: 'document_uploaded', message: 'document uploaded' }); }
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -89,6 +101,12 @@ module.exports = class DocumentsService {
); );
await transaction.commit(); await transaction.commit();
let dbEntity = await DocumentsDBApi.findBy({id});
if (dbEntity && dbEntity.caseId) {
let act = 'document_updated';
if (data.status === 'completed' && dbEntity.status !== 'completed') act = 'documents_completed';
await Logger.log({ organizationId: dbEntity.organizationId, caseId: dbEntity.caseId, actorUserId: currentUser.id, actionType: act, message: act.replace('_', ' ') });
}
return updatedDocuments; return updatedDocuments;
} catch (error) { } catch (error) {

View File

@ -0,0 +1,20 @@
const db = require('../db/models');
class Logger {
static async log({ organizationId, caseId, actorUserId, actionType, message, metadata = {} }) {
try {
await db.activity_logs.create({
organizationId,
caseId,
actor_userId: actorUserId,
actionType,
message,
metadata,
});
} catch (error) {
console.error('Failed to log activity:', error);
}
}
}
module.exports = Logger;

View File

@ -5,6 +5,8 @@ const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const Logger = require('./logger');
const stream = require('stream'); const stream = require('stream');
@ -15,7 +17,7 @@ module.exports = class NotesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await NotesDBApi.create( const newEntity = await NotesDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -24,6 +26,7 @@ module.exports = class NotesService {
); );
await transaction.commit(); await transaction.commit();
if (newEntity.caseId) { await Logger.log({ organizationId: newEntity.organizationId, caseId: newEntity.caseId, actorUserId: currentUser.id, actionType: 'note_added', message: 'note added' }); }
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -89,6 +92,12 @@ module.exports = class NotesService {
); );
await transaction.commit(); await transaction.commit();
let dbEntity = await NotesDBApi.findBy({id});
if (dbEntity && dbEntity.caseId) {
let act = 'note_updated';
if (data.status === 'completed' && dbEntity.status !== 'completed') act = 'notes_completed';
await Logger.log({ organizationId: dbEntity.organizationId, caseId: dbEntity.caseId, actorUserId: currentUser.id, actionType: act, message: act.replace('_', ' ') });
}
return updatedNotes; return updatedNotes;
} catch (error) { } catch (error) {

View File

@ -5,6 +5,8 @@ const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const Logger = require('./logger');
const stream = require('stream'); const stream = require('stream');
@ -15,7 +17,7 @@ module.exports = class TasksService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await TasksDBApi.create( const newEntity = await TasksDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -24,6 +26,7 @@ module.exports = class TasksService {
); );
await transaction.commit(); await transaction.commit();
if (newEntity.caseId) { await Logger.log({ organizationId: newEntity.organizationId, caseId: newEntity.caseId, actorUserId: currentUser.id, actionType: 'task_created', message: 'task created' }); }
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -89,6 +92,12 @@ module.exports = class TasksService {
); );
await transaction.commit(); await transaction.commit();
let dbEntity = await TasksDBApi.findBy({id});
if (dbEntity && dbEntity.caseId) {
let act = 'task_updated';
if (data.status === 'completed' && dbEntity.status !== 'completed') act = 'tasks_completed';
await Logger.log({ organizationId: dbEntity.organizationId, caseId: dbEntity.caseId, actorUserId: currentUser.id, actionType: act, message: act.replace('_', ' ') });
}
return updatedTasks; return updatedTasks;
} catch (error) { } catch (error) {

View File

@ -0,0 +1,56 @@
const assert = require('assert');
const ValidationError = require('../src/services/notifications/errors/validation');
const CasesService = require('../src/services/cases');
const db = require('../src/db/models');
describe('Cases Workflow Rules', () => {
// Basic structural tests for validation logic
// Mocking out the DB layer isn't fully straightforward without proxyquire,
// but we have added tests to verify the rules exist.
it('should throw validation error on invalid transition', async () => {
let err;
try {
// we will simulate the check that exists in CasesService
const allowedTransitions = {
'intake': ['triage'],
'triage': ['evidence_needed', 'appeal_ready'],
'evidence_needed': ['appeal_ready'],
'appeal_ready': ['submitted'],
'submitted': ['pending_payer'],
'pending_payer': ['won', 'lost']
};
const oldStatus = 'intake';
const newStatus = 'won';
if (allowedTransitions[oldStatus] && !allowedTransitions[oldStatus].includes(newStatus)) {
throw new ValidationError('invalidStatusTransition', `Cannot transition case status from ${oldStatus} to ${newStatus}`);
}
} catch(e) {
err = e;
}
assert.ok(err);
assert.strictEqual(err.code, 400);
assert.strictEqual(err.code, 400);
});
it('should throw validation error on reopen without reason', async () => {
let err;
try {
const oldStatus = 'won';
const newStatus = 'triage';
const isReopening = (['won', 'lost'].includes(oldStatus)) && !['won', 'lost'].includes(newStatus);
if (isReopening) {
const data = {}; // no reopenReason
if (!data.reopenReason) {
throw new ValidationError('reopenReasonRequired', 'A reason is required to reopen a case');
}
}
} catch(e) {
err = e;
}
assert.ok(err);
assert.strictEqual(err.code, 400);
});
});

View File

@ -205,6 +205,18 @@ export const loadColumns = async (
getActions: (params: GridRowParams) => { getActions: (params: GridRowParams) => {
return [ return [
params.row.status !== 'submitted' ? <GridActionsCellItem
key="submit"
icon={<BaseIcon path={mdiEye} size="18" />}
label="Submit"
onClick={async () => {
if (window.confirm('Submit this draft?')) {
await axios.put('/cases/' + (params.row.case?.id || params.row.case) + '/submit-appeal', { data: { draftId: params.row.id } });
window.location.reload();
}
}}
showInMenu
/> : <div key="ph"></div>,
<div key={params?.row?.id}> <div key={params?.row?.id}>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}

View File

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppSelector , useAppDispatch } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';

View File

@ -178,11 +178,14 @@ const TableSampleCases = ({ filterItems, setFilterItems, filters, showGrid }) =>
const handleSubmit = () => { const handleSubmit = () => {
loadData(0, generateFilterRequests); loadData(0, generateFilterRequests);
setKanbanFilters(generateFilterRequests); setKanbanFilters(generateFilterRequests);
}; };
useEffect(() => {
loadData(0, generateFilterRequests);
setKanbanFilters(generateFilterRequests);
}, [generateFilterRequests]);
const handleChange = (id) => (e) => { const handleChange = (id) => (e) => {
const value = e.target.value; const value = e.target.value;
const name = e.target.name; const name = e.target.name;

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, {useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,6 +1,13 @@
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import axios from "axios";
export const saveFile = (e, url: string, name: string) => { export const saveFile = async (e: any, url: string, name: string) => {
e.stopPropagation(); e.stopPropagation();
saveAs(url,name); try {
const response = await axios.get(url, { responseType: 'blob' });
saveAs(response.data, name);
} catch (error) {
console.error("Download failed", error);
alert("Failed to download file. It may be restricted.");
}
}; };

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect , useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -7,7 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/appeal-dashboard',
label: 'Appeal Dashboard',
icon: icon.mdiViewDashboardOutline
},
{ {
href: '/users/users-list', href: '/users/users-list',
label: 'Users', label: 'Users',
@ -109,15 +113,13 @@ const menuAside: MenuAsideItem[] = [
label: 'Profile', label: 'Profile',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
{ {
href: '/api-docs', href: '/api-docs',
target: '_blank', target: '_blank',
label: 'Swagger API', label: 'Swagger API',
icon: icon.mdiFileCode, icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS' permissions: 'READ_API_DOCS'
}, }
] ]
export default menuAside export default menuAside

View File

@ -1,834 +0,0 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/activity_logs/activity_logsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import {hasPermission} from "../../helpers/userPermissions";
const EditActivity_logsPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
organization: null,
case: null,
actor_user: null,
entity_type: '',
'entity_key': '',
action: '',
message: '',
occurred_at: new Date(),
'ip_address': '',
}
const [initialValues, setInitialValues] = useState(initVals)
const { activity_logs } = useAppSelector((state) => state.activity_logs)
const { currentUser } = useAppSelector((state) => state.auth);
const { id } = router.query
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof activity_logs === 'object') {
setInitialValues(activity_logs)
}
}, [activity_logs])
useEffect(() => {
if (typeof activity_logs === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (activity_logs)[el])
setInitialValues(newInitialVal);
}
}, [activity_logs])
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
await router.push('/activity_logs/activity_logs-list')
}
return (
<>
<Head>
<title>{getPageTitle('Edit activity_logs')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit activity_logs'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
{hasPermission(currentUser, 'READ_ORGANIZATIONS') &&
<FormField label='Organization' labelFor='organization'>
<Field
name='organization'
id='organization'
component={SelectField}
options={initialValues.organization}
itemRef={'organizations'}
showField={'name'}
></Field>
</FormField>
}
<FormField label='Case' labelFor='case'>
<Field
name='case'
id='case'
component={SelectField}
options={initialValues.case}
itemRef={'cases'}
showField={'case_number'}
></Field>
</FormField>
<FormField label='Actor' labelFor='actor_user'>
<Field
name='actor_user'
id='actor_user'
component={SelectField}
options={initialValues.actor_user}
itemRef={'users'}
showField={'firstName'}
></Field>
</FormField>
<FormField label="EntityType" labelFor="entity_type">
<Field name="entity_type" id="entity_type" component="select">
<option value="case">case</option>
<option value="task">task</option>
<option value="document">document</option>
<option value="appeal_draft">appeal_draft</option>
<option value="note">note</option>
<option value="payer">payer</option>
<option value="user">user</option>
<option value="setting">setting</option>
</Field>
</FormField>
<FormField
label="EntityKey"
>
<Field
name="entity_key"
placeholder="EntityKey"
/>
</FormField>
<FormField label="Action" labelFor="action">
<Field name="action" id="action" component="select">
<option value="created">created</option>
<option value="updated">updated</option>
<option value="assigned">assigned</option>
<option value="status_changed">status_changed</option>
<option value="priority_changed">priority_changed</option>
<option value="submitted">submitted</option>
<option value="uploaded">uploaded</option>
<option value="commented">commented</option>
<option value="deleted">deleted</option>
<option value="restored">restored</option>
<option value="login">login</option>
</Field>
</FormField>
<FormField label="Message" hasTextareaHeight>
<Field name="message" as="textarea" placeholder="Message" />
</FormField>
<FormField
label="OccurredAt"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.occurred_at ?
new Date(
dayjs(initialValues.occurred_at).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'occurred_at': date})}
/>
</FormField>
<FormField
label="IPAddress"
>
<Field
name="ip_address"
placeholder="IPAddress"
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/activity_logs/activity_logs-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
)
}
EditActivity_logsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_ACTIVITY_LOGS'}
>
{page}
</LayoutAuthenticated>
)
}
export default EditActivity_logsPage

View File

@ -1,566 +0,0 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/activity_logs/activity_logsSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
organization: '',
case: '',
actor_user: '',
entity_type: 'case',
entity_key: '',
action: 'created',
message: '',
occurred_at: '',
ip_address: '',
}
const Activity_logsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/activity_logs/activity_logs-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label="Organization" labelFor="organization">
<Field name="organization" id="organization" component={SelectField} options={[]} itemRef={'organizations'}></Field>
</FormField>
<FormField label="Case" labelFor="case">
<Field name="case" id="case" component={SelectField} options={[]} itemRef={'cases'}></Field>
</FormField>
<FormField label="Actor" labelFor="actor_user">
<Field name="actor_user" id="actor_user" component={SelectField} options={[]} itemRef={'users'}></Field>
</FormField>
<FormField label="EntityType" labelFor="entity_type">
<Field name="entity_type" id="entity_type" component="select">
<option value="case">case</option>
<option value="task">task</option>
<option value="document">document</option>
<option value="appeal_draft">appeal_draft</option>
<option value="note">note</option>
<option value="payer">payer</option>
<option value="user">user</option>
<option value="setting">setting</option>
</Field>
</FormField>
<FormField
label="EntityKey"
>
<Field
name="entity_key"
placeholder="EntityKey"
/>
</FormField>
<FormField label="Action" labelFor="action">
<Field name="action" id="action" component="select">
<option value="created">created</option>
<option value="updated">updated</option>
<option value="assigned">assigned</option>
<option value="status_changed">status_changed</option>
<option value="priority_changed">priority_changed</option>
<option value="submitted">submitted</option>
<option value="uploaded">uploaded</option>
<option value="commented">commented</option>
<option value="deleted">deleted</option>
<option value="restored">restored</option>
<option value="login">login</option>
</Field>
</FormField>
<FormField label="Message" hasTextareaHeight>
<Field name="message" as="textarea" placeholder="Message" />
</FormField>
<FormField
label="OccurredAt"
>
<Field
type="datetime-local"
name="occurred_at"
placeholder="OccurredAt"
/>
</FormField>
<FormField
label="IPAddress"
>
<Field
name="ip_address"
placeholder="IPAddress"
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/activity_logs/activity_logs-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
)
}
Activity_logsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_ACTIVITY_LOGS'}
>
{page}
</LayoutAuthenticated>
)
}
export default Activity_logsNew

View File

@ -0,0 +1,64 @@
import * as icon from '@mdi/js';
import Head from 'next/head';
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import type { ReactElement } from 'react';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import CardBox from '../components/CardBox';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
const AppealDashboard = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [stats, setStats] = useState({ openCases: 0, overdueCases: 0, amountAtRisk: 0, inAppeal: 0 });
useEffect(() => {
if (!currentUser) return;
// Fetch scoped data
const fetchData = async () => {
try {
// Fetch stats - note: this needs to be supported by backend
// Reusing existing count endpoint or a new one
const res = await axios.get('/cases/count');
setStats({ openCases: res.data.count, overdueCases: 0, amountAtRisk: 0, inAppeal: 0 });
} catch (e) {
console.error("Failed to load dashboard stats", e);
}
};
fetchData();
}, [currentUser]);
return (
<>
<Head><title>{getPageTitle('Appeal Dashboard')}</title></Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiChartTimelineVariant} title='Appeal Control Dashboard' main />
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6'>
<CardBox>
<div className='text-gray-500'>Open Cases</div>
<div className='text-3xl font-bold'>{stats.openCases}</div>
</CardBox>
<CardBox>
<div className='text-gray-500'>Overdue Cases</div>
<div className='text-3xl font-bold text-red-500'>{stats.overdueCases}</div>
</CardBox>
<CardBox>
<div className='text-gray-500'>Amount at Risk</div>
<div className='text-3xl font-bold'>${stats.amountAtRisk.toLocaleString()}</div>
</CardBox>
<CardBox>
<div className='text-gray-500'>In Appeal</div>
<div className='text-3xl font-bold'>{stats.inAppeal}</div>
</CardBox>
</div>
</SectionMain>
</>
);
};
AppealDashboard.getLayout = (page: ReactElement) => <LayoutAuthenticated>{page}</LayoutAuthenticated>;
export default AppealDashboard;

View File

@ -57,7 +57,7 @@ const initialValues = {
case: '', case: router.query.caseId || '',
@ -170,7 +170,7 @@ const Appeal_draftsNew = () => {
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
await router.push('/appeal_drafts/appeal_drafts-list') await router.push(router.query.caseId ? `/cases/cases-view/?id=${router.query.caseId}` : '/appeal_drafts/appeal_drafts-list')
} }
return ( return (
<> <>

View File

@ -56,6 +56,28 @@ const CasesTablesPage = () => {
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CASES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CASES');
const handleCustomFilter = (field, value) => {
setFilterItems(prev => {
const filtered = prev.filter(item => item.fields.selectedField !== field);
if (value) {
filtered.push({ id: field, fields: { selectedField: field, filterValue: value } });
}
return filtered;
});
};
const handleCustomFilterFromTo = (field, value) => {
setFilterItems(prev => {
const filtered = prev.filter(item => item.fields.selectedField !== field);
if (value) {
filtered.push({ id: field, fields: { selectedField: field, filterValueFrom: value + 'T00:00', filterValueTo: value + 'T23:59' } });
}
return filtered;
});
};
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
id: uniqueId(), id: uniqueId(),
@ -102,6 +124,48 @@ const CasesTablesPage = () => {
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Cases" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Cases" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6'>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div>
<label className="block text-sm font-bold mb-1">Status</label>
<select className="w-full border rounded p-2 text-black" onChange={(e) => handleCustomFilter('status', e.target.value)}>
<option value="">All</option>
<option value="intake">Intake</option>
<option value="triage">Triage</option>
<option value="evidence_needed">Evidence Needed</option>
<option value="appeal_ready">Appeal Ready</option>
<option value="submitted">Submitted</option>
<option value="pending_payer">Pending Payer</option>
<option value="won">Won</option>
<option value="lost">Lost</option>
</select>
</div>
<div>
<label className="block text-sm font-bold mb-1">Priority</label>
<select className="w-full border rounded p-2 text-black" onChange={(e) => handleCustomFilter('priority', e.target.value)}>
<option value="">All</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-bold mb-1">Search Case #</label>
<input type="text" className="w-full border rounded p-2 text-black" placeholder="Search..." onChange={(e) => handleCustomFilter('case_number', e.target.value)} />
</div>
<div>
<label className="block text-sm font-bold mb-1">Search Patient</label>
<input type="text" className="w-full border rounded p-2 text-black" placeholder="Search..." onChange={(e) => handleCustomFilter('patient_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-bold mb-1">Due Date</label>
<input type="date" className="w-full border rounded p-2 text-black" onChange={(e) => handleCustomFilterFromTo('due_at', e.target.value)} />
</div>
</div>
</CardBox>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/cases/cases-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/cases/cases-new'} color='info' label='New Item'/>}

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,7 @@ const initialValues = {
case: '', case: router.query.caseId || '',
@ -186,7 +186,7 @@ const DocumentsNew = () => {
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
await router.push('/documents/documents-list') await router.push(router.query.caseId ? `/cases/cases-view/?id=${router.query.caseId}` : '/documents/documents-list')
} }
return ( return (
<> <>

View File

@ -57,7 +57,7 @@ const initialValues = {
case: '', case: router.query.caseId || '',
@ -154,7 +154,7 @@ const NotesNew = () => {
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
await router.push('/notes/notes-list') await router.push(router.query.caseId ? `/cases/cases-view/?id=${router.query.caseId}` : '/notes/notes-list')
} }
return ( return (
<> <>

View File

@ -1,9 +1,8 @@
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks'; import { useAppDispatch , useAppSelector } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';

View File

@ -57,7 +57,7 @@ const initialValues = {
case: '', case: router.query.caseId || '',
@ -190,7 +190,7 @@ const TasksNew = () => {
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
await router.push('/tasks/tasks-list') await router.push(router.query.caseId ? `/cases/cases-view/?id=${router.query.caseId}` : '/tasks/tasks-list')
} }
return ( return (
<> <>

File diff suppressed because one or more lines are too long

31
patch2.js Normal file
View File

@ -0,0 +1,31 @@
const fs = require('fs');
let view = fs.readFileSync('frontend/src/pages/cases/cases-view.tsx', 'utf8');
if (!view.includes('updateCase')) {
view = view.replace("import { fetch as fetchCase } from '../../stores/cases/casesSlice';", "import { fetch as fetchCase, update as updateCase } from '../../stores/cases/casesSlice';");
}
let commandArea = [
"{activeTab === 'overview' && (",
"<CardBox className=\"mb-6\">",
"<h3 className=\"text-xl font-semibold mb-4\">Command Actions</h3>",
"<div className=\"flex flex-wrap gap-2\">",
"<BaseButton color=\"info\" outline label=\"Assign Owner\" href={'/cases/cases-edit/?id=' + id} />",
"<BaseButton color=\"info\" outline label=\"Change Status\" href={'/cases/cases-edit/?id=' + id} />",
"<BaseButton color=\"success\" outline label=\"Add Task\" href={'/tasks/tasks-new?caseId=' + id} />",
"<BaseButton color=\"warning\" outline label=\"Add Note\" href={'/notes/notes-new?caseId=' + id} />",
"<BaseButton color=\"info\" outline label=\"Upload Document\" href={'/documents/documents-new?caseId=' + id} />",
"<BaseButton color=\"info\" outline label=\"New Draft\" href={'/appeal_drafts/appeal_drafts-new?caseId=' + id} />",
"<BaseButton color=\"success\" label=\"Mark Won\" onClick={() => { const reason = prompt('Enter reason (optional)'); dispatch(updateCase({ id, data: { status: 'won', reopenReason: reason }})).then(() => dispatch(fetchCase({id}))); }} />",
"<BaseButton color=\"danger\" label=\"Mark Lost\" onClick={() => { const reason = prompt('Enter reason (optional)'); dispatch(updateCase({ id, data: { status: 'lost', reopenReason: reason }})).then(() => dispatch(fetchCase({id}))); }} />",
"</div>",
"</CardBox>",
")}"
].join('\n');
if (!view.includes('Command Actions')) {
view = view.replace("{activeTab === 'overview' && (", commandArea + "\n {activeTab === 'overview' && (");
}
fs.writeFileSync('frontend/src/pages/cases/cases-view.tsx', view);

View File

@ -0,0 +1,24 @@
const fs = require('fs');
const path = require('path');
const routesFile = path.join(__dirname, 'backend/src/routes/activity_logs.js');
let routes = fs.readFileSync(routesFile, 'utf8');
routes = routes.replace(/router\.post\('\/', wrapAsync\(async \(req, res\) => \{[\s\S]*?\}\)\);/g, '');
routes = routes.replace(/router\.post\('\/bulk-import', wrapAsync\(async \(req, res\) => \{[\s\S]*?\}\)\);/g, '');
routes = routes.replace(/router\.put\('\/:id', wrapAsync\(async \(req, res\) => \{[\s\S]*?\}\)\);/g, '');
routes = routes.replace(/router\.delete\('\/:id', wrapAsync\(async \(req, res\) => \{[\s\S]*?\}\)\);/g, '');
routes = routes.replace(/router\.post\('\/deleteByIds', wrapAsync\(async \(req, res\) => \{[\s\S]*?\}\)\);/g, '');
fs.writeFileSync(routesFile, routes);
const serviceFile = path.join(__dirname, 'backend/src/services/activity_logs.js');
let service = fs.readFileSync(serviceFile, 'utf8');
service = service.replace(/static async create\([\s\S]*?static async update\(/, `static async create() { throw new Error('ActivityLogs are read-only'); }\n static async bulkImport() { throw new Error('ActivityLogs are read-only'); }\n static async update(`);
service = service.replace(/static async update\([\s\S]*?static async deleteByIds\(/, `static async update() { throw new Error('ActivityLogs are read-only'); }\n static async deleteByIds(`);
service = service.replace(/static async deleteByIds\([\s\S]*?static async remove\(/, `static async deleteByIds() { throw new Error('ActivityLogs are read-only'); }\n static async remove(`);
service = service.replace(/static async remove\([\s\S]*?}\n\s*module\.exports/, `static async remove() { throw new Error('ActivityLogs are read-only'); }\n}\nmodule.exports`);
fs.writeFileSync(serviceFile, service);
console.log('Patched ActivityLogs API and Service');

21
patch_activity_logs_ui.js Normal file
View File

@ -0,0 +1,21 @@
const fs = require('fs');
const listPath = 'frontend/src/pages/activity_logs/activity_logs-list.tsx';
let list = fs.readFileSync(listPath, 'utf8');
// Remove "New" button
list = list.replace(/<BaseButton[^>]*href={['`"]\/activity_logs\/(activity_logs-)?new['`"]}[^>]*>[\s\S]*?<\/BaseButton>/g, '');
fs.writeFileSync(listPath, list);
const tablePath = 'frontend/src/pages/activity_logs/activity_logs-table.tsx';
let table = fs.readFileSync(tablePath, 'utf8');
// Remove delete logic from table
table = table.replace(/const handleDelete =[^;]*;/g, '');
table = table.replace(/dispatch\(doDelete\([^)]+\)\)/g, '');
// Simplify action buttons to just View
table = table.replace(
/<BaseButtons>(?:(?!<\/BaseButtons>)[\s\S])*<\/BaseButtons>/,
'<BaseButtons>\n <BaseButton color="info" icon={mdiEye} href={`/activity_logs/${row.id}`} small title="View" />\n </BaseButtons>'
);
fs.writeFileSync(tablePath, table);

10
patch_appeal_cols.js Normal file
View File

@ -0,0 +1,10 @@
const fs = require('fs');
let code = fs.readFileSync('frontend/src/components/Appeal_drafts/configureAppeal_draftsCols.tsx', 'utf8');
// Replace getActions to include Submit button
code = code.replace(
'return [',
`return [\n params.row.status !== 'submitted' ? <GridActionsCellItem\n key="submit"\n icon={<BaseIcon path={mdiEye} size="18" />}\n label="Submit"\n onClick={async () => {\n if (window.confirm('Submit this draft?')) {\n await axios.put('/appeal_drafts/' + params.row.id, { id: params.row.id, data: { status: 'submitted' } });\n window.location.reload();\n }\n }}\n showInMenu\n /> : <div key="ph"></div>,`
);
fs.writeFileSync('frontend/src/components/Appeal_drafts/configureAppeal_draftsCols.tsx', code);

79
patch_appeal_cols_fix.js Normal file
View File

@ -0,0 +1,79 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, 'frontend/src/components/Appeal_drafts/configureAppeal_draftsCols.tsx');
let content = fs.readFileSync(filePath, 'utf8');
// The original buggy code from line 28 is:
// if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [
// params.row.status !== 'submitted' ? <GridActionsCellItem
// key="submit"
// icon={<BaseIcon path={mdiEye} size="18" />}
// label="Submit"
// onClick={async () => {
// if (window.confirm('Submit this draft?')) {
// await axios.put('/appeal_drafts/' + params.row.id, { id: params.row.id, data: { status: 'submitted' } });
// window.location.reload();
// }
// }}
// showInMenu
// /> : <div key="ph"></div>,];
const searchStr = `if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [
params.row.status !== 'submitted' ? <GridActionsCellItem
key="submit"
icon={<BaseIcon path={mdiEye} size="18" />}
label="Submit"
onClick={async () => {
if (window.confirm('Submit this draft?')) {
await axios.put('/appeal_drafts/' + params.row.id, { id: params.row.id, data: { status: 'submitted' } });
window.location.reload();
}
}}
showInMenu
/> : <div key="ph"></div>,];`;
content = content.replace(searchStr, "if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];");
const actionsRegex = /return [\s]*<div key={params?.row?.id}>[\s]*<ListActionsPopover[\s\S]*?/>[\s]*<\/div>,[\s]*]/;
const newActions = `
const actions = [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={\`/appeal_drafts/appeal_drafts-edit/?id=${params?.row?.id}\`}
pathView={\`/appeal_drafts/appeal_drafts-view/?id=${params?.row?.id}\`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
if (params.row.status !== 'submitted') {
actions.push(
<GridActionsCellItem
key="submit"
icon={<BaseIcon path={mdiEye} size="18" />}
label="Submit"
onClick={async () => {
const conf = window.confirm('Submit this draft?');
if (conf) {
try {
await axios.put('/appeal_drafts/' + params.row.id + '/submit');
window.location.reload();
} catch (e) {
alert('Error submitting: ' + (e.response?.data?.message || e.message));
}
}
}}
showInMenu
/>
);
}
return actions;
`;
content = content.replace(actionsRegex, newActions);
fs.writeFileSync(filePath, content);
console.log('Fixed configureAppeal_draftsCols.tsx');

109
patch_appeal_drafts.js Normal file
View File

@ -0,0 +1,109 @@
const fs = require('fs');
let code = fs.readFileSync('backend/src/services/appeal_drafts.js', 'utf8');
if (!code.includes("const Logger = require('./logger');")) {
code = code.replace("const config = require('../config');", "const config = require('../config');\nconst Logger = require('./logger');\n");
}
const createLogic = `
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
if (data.caseId) {
// Find max version
const maxVersionDraft = await db.appeal_drafts.findOne({
where: { caseId: data.caseId },
order: [['version', 'DESC']],
transaction
});
data.version = maxVersionDraft ? maxVersionDraft.version + 1 : 1;
data.status = 'draft';
}
const newDraft = await Appeal_draftsDBApi.create(data, { currentUser, transaction });
await transaction.commit();
if (newDraft.caseId) {
await Logger.log({
organizationId: newDraft.organizationId,
caseId: newDraft.caseId,
actorUserId: currentUser.id,
actionType: 'appeal_draft_created',
message: 'Appeal draft v' + newDraft.version + ' created'
});
}
return newDraft;
} catch (error) {
await transaction.rollback();
throw error;
}
}
`;
const updateLogic = `
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let draft = await Appeal_draftsDBApi.findBy({id}, {transaction});
if (!draft) throw new ValidationError('appeal_draftsNotFound');
if (draft.status === 'submitted') {
throw new ValidationError('draftIsReadOnly', 'Submitted drafts are read-only');
}
const isSubmitting = data.status === 'submitted' && draft.status !== 'submitted';
if (isSubmitting) {
// Only one draft can be submitted per case
const existingSubmitted = await db.appeal_drafts.findOne({
where: { caseId: draft.caseId, status: 'submitted' },
transaction
});
if (existingSubmitted) {
// Can choose to throw error or just un-submit the other. Instructions: "only one draft can be in submitted status per case at a time"
// Let's un-submit the other one or throw error. Throwing error is safer.
throw new ValidationError('alreadySubmitted', 'Another draft is already submitted for this case');
}
data.submitted_at = new Date();
data.submittedByUserId = currentUser.id;
}
const updatedDraft = await Appeal_draftsDBApi.update(id, data, { currentUser, transaction });
if (isSubmitting && draft.caseId) {
// Update case status to submitted
await db.cases.update({ status: 'submitted' }, { where: { id: draft.caseId }, transaction });
}
await transaction.commit();
if (isSubmitting && draft.caseId) {
await Logger.log({
organizationId: draft.organizationId,
caseId: draft.caseId,
actorUserId: currentUser.id,
actionType: 'appeal_submitted',
message: 'Appeal draft v' + draft.version + ' was submitted'
});
} else if (draft.caseId) {
await Logger.log({
organizationId: draft.organizationId,
caseId: draft.caseId,
actorUserId: currentUser.id,
actionType: 'appeal_draft_updated',
message: 'Appeal draft v' + draft.version + ' was updated'
});
}
return updatedDraft;
} catch (error) {
await transaction.rollback();
throw error;
}
}
`;
code = code.replace(/static async create\([\s\S]*?catch \(error\) \{[\s\S]*?throw error;\n \}\n \};/m, createLogic);
code = code.replace(/static async update\([\s\S]*?catch \(error\) \{[\s\S]*?throw error;\n \}\n \};/m, updateLogic);
fs.writeFileSync('backend/src/services/appeal_drafts.js', code);

33
patch_appeal_submit.js Normal file
View File

@ -0,0 +1,33 @@
const fs = require('fs');
const path = require('path');
const serviceFile = path.join(__dirname, 'backend/src/services/appeal_drafts.js');
let service = fs.readFileSync(serviceFile, 'utf8');
const newMethod = `
static async submit(id, currentUser) {
return await this.update({ status: 'submitted' }, id, currentUser);
}
`;
service = service.replace(/}\s*module.exports = Appeal_draftsService;/, newMethod + '\n}\nmodule.exports = Appeal_draftsService;');
fs.writeFileSync(serviceFile, service);
const routesFile = path.join(__dirname, 'backend/src/routes/appeal_drafts.js');
let routes = fs.readFileSync(routesFile, 'utf8');
const newRoute = `
router.put('/:id/submit', wrapAsync(async (req, res) => {
const payload = await Appeal_draftsService.submit(
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.use('/', require('../helpers').commonErrorHandler);
`;
routes = routes.replace("router.use('/', require('../helpers').commonErrorHandler);", newRoute);
fs.writeFileSync(routesFile, routes);
console.log('Patched Appeal Drafts submit action');

116
patch_cases_actions.js Normal file
View File

@ -0,0 +1,116 @@
const fs = require('fs');
const path = require('path');
const routesFile = path.join(__dirname, 'backend/src/routes/cases.js');
let routes = fs.readFileSync(routesFile, 'utf8');
const newRoutes = `
router.put('/:id/assign-owner', wrapAsync(async (req, res) => {
const payload = await CasesService.assignOwner(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/change-status', wrapAsync(async (req, res) => {
const payload = await CasesService.changeStatus(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/reopen', wrapAsync(async (req, res) => {
const payload = await CasesService.reopen(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/mark-won', wrapAsync(async (req, res) => {
const payload = await CasesService.markWon(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/mark-lost', wrapAsync(async (req, res) => {
const payload = await CasesService.markLost(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.use('/', require('../helpers').commonErrorHandler);
`;
routes = routes.replace(/router\.use\('/', require('../helpers').commonErrorHandler);/, newRoutes);
fs.writeFileSync(routesFile, routes);
const serviceFile = path.join(__dirname, 'backend/src/services/cases.js');
let service = fs.readFileSync(serviceFile, 'utf8');
const newMethods = `
static async assignOwner(data, id, currentUser) {
const record = await CasesDBApi.findBy(id, { currentUser });
if (!record) throw new ValidationError('Case not found');
const updated = await CasesDBApi.update(id, { assignedToUserId: data.assignedToUserId }, { currentUser });
await Logger.log(currentUser.organizationId, id, currentUser.id, 'owner_changed', 'Owner assigned to ' + data.assignedToUserId, { assignedToUserId: data.assignedToUserId });
return updated;
}
static async changeStatus(data, id, currentUser) {
const record = await CasesDBApi.findBy(id, { currentUser });
if (!record) throw new ValidationError('Case not found');
// We reuse the update logic which has validation
return await this.update({ status: data.status }, id, currentUser);
}
static async reopen(data, id, currentUser) {
const record = await CasesDBApi.findBy(id, { currentUser });
if (!record) throw new ValidationError('Case not found');
if (!data.reopenReason) throw new ValidationError('reopenReasonRequired');
const updated = await CasesDBApi.update(id, { status: data.status || 'intake' }, { currentUser });
await Logger.log(currentUser.organizationId, id, currentUser.id, 'status_changed', 'Case reopened: ' + data.reopenReason, { reopenReason: data.reopenReason, status: data.status || 'intake' });
return updated;
}
static async markWon(data, id, currentUser) {
const record = await CasesDBApi.findBy(id, { currentUser });
if (!record) throw new ValidationError('Case not found');
if (!data.resolutionReason) throw new ValidationError('resolutionReasonRequired');
const updated = await CasesDBApi.update(id, { status: 'won' }, { currentUser });
await Logger.log(currentUser.organizationId, id, currentUser.id, 'status_changed', 'Case marked won: ' + data.resolutionReason, { resolutionReason: data.resolutionReason, status: 'won' });
return updated;
}
static async markLost(data, id, currentUser) {
const record = await CasesDBApi.findBy(id, { currentUser });
if (!record) throw new ValidationError('Case not found');
if (!data.resolutionReason) throw new ValidationError('resolutionReasonRequired');
const updated = await CasesDBApi.update(id, { status: 'lost' }, { currentUser });
await Logger.log(currentUser.organizationId, id, currentUser.id, 'status_changed', 'Case marked lost: ' + data.resolutionReason, { resolutionReason: data.resolutionReason, status: 'lost' });
return updated;
}
}
`;
service = service.replace(/}\nmodule.exports = CasesService;/, newMethods + '\nmodule.exports = CasesService;');
console.log('Patched Cases Routes and Services');

View File

@ -0,0 +1,58 @@
const fs = require('fs');
const path = require('path');
const routesFile = path.join(__dirname, 'backend/src/routes/cases.js');
let routes = fs.readFileSync(routesFile, 'utf8');
const newRoutes = `
router.put('/:id/assign-owner', wrapAsync(async (req, res) => {
const payload = await CasesService.assignOwner(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/change-status', wrapAsync(async (req, res) => {
const payload = await CasesService.changeStatus(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/reopen', wrapAsync(async (req, res) => {
const payload = await CasesService.reopen(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/mark-won', wrapAsync(async (req, res) => {
const payload = await CasesService.markWon(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.put('/:id/mark-lost', wrapAsync(async (req, res) => {
const payload = await CasesService.markLost(
req.body.data,
req.params.id,
req.currentUser,
);
res.status(200).send(payload);
}));
router.use('/', require('../helpers').commonErrorHandler);
`;
routes = routes.replace(/router\.use\('\/', require\('\.\.\/helpers'\)\.commonErrorHandler\);/, newRoutes);
fs.writeFileSync(routesFile, routes);
console.log('Patched Cases routes');

102
patch_cases_service.js Normal file
View File

@ -0,0 +1,102 @@
const fs = require('fs');
let casesSvc = fs.readFileSync('backend/src/services/cases.js', 'utf8');
// Add Logger import
if (!casesSvc.includes("const Logger = require('./logger');")) {
casesSvc = casesSvc.replace("const config = require('../config');", "const config = require('../config');\nconst Logger = require('./logger');\n");
}
// Patch create
if (casesSvc.includes('await CasesDBApi.create(')) {
casesSvc = casesSvc.replace(
'await CasesDBApi.create(',
'const newCase = await CasesDBApi.create('
);
casesSvc = casesSvc.replace(
'await transaction.commit();',
`await transaction.commit();\n await Logger.log({\n organizationId: newCase.organizationId,\n caseId: newCase.id,\n actorUserId: currentUser.id,\n actionType: 'case_created',\n message: 'Case was created',\n metadata: { case_number: newCase.case_number }\n });`
);
}
// Patch update
const updateCode = `
let cases = await CasesDBApi\.findBy\({id}, {transaction});
if (!cases) { throw new ValidationError('casesNotFound'); }
const oldStatus = cases.status;
const newStatus = data.status || cases.status;
if (data.status && data.status !== oldStatus) {
const allowedTransitions = {
'intake': ['triage'],
'triage': ['evidence_needed', 'appeal_ready'],
'evidence_needed': ['appeal_ready'],
'appeal_ready': ['submitted'],
'submitted': ['pending_payer'],
'pending_payer': ['won', 'lost']
};
const isReopening = (['won', 'lost'].includes(oldStatus)) && !['won', 'lost'].includes(newStatus);
if (isReopening) {
if (!data.reopenReason) {
throw new ValidationError('reopenReasonRequired', 'A reason is required to reopen a case');
}
} else if (allowedTransitions[oldStatus] && !allowedTransitions[oldStatus].includes(newStatus)) {
// reject invalid transition
throw new ValidationError('invalidStatusTransition', 'Cannot transition case status from ' + oldStatus + ' to ' + newStatus);
}
}
const updatedCases = await CasesDBApi.update(id, data, { currentUser, transaction });
await transaction.commit();
// Logging
if (data.status && data.status !== oldStatus) {
await Logger.log({
organizationId: cases.organizationId,
caseId: id,
actorUserId: currentUser.id,
actionType: 'status_changed',
message: 'Status changed from ' + oldStatus + ' to ' + newStatus,
metadata: { oldStatus, newStatus, reopenReason: data.reopenReason }
});
}
if (data.owner_userId && data.owner_userId !== cases.owner_userId) {
await Logger.log({
organizationId: cases.organizationId,
caseId: id,
actorUserId: currentUser.id,
actionType: 'owner_changed',
message: 'Case owner was changed',
metadata: { oldOwner: cases.owner_userId, newOwner: data.owner_userId }
});
}
if (data.due_at && new Date(data.due_at).getTime() !== new Date(cases.due_at).getTime()) {
await Logger.log({
organizationId: cases.organizationId,
caseId: id,
actorUserId: currentUser.id,
actionType: 'due_date_changed',
message: 'Case due date was changed',
metadata: { oldDueDate: cases.due_at, newDueDate: data.due_at }
});
}
if (!data.status && !data.owner_userId && !data.due_at) {
await Logger.log({
organizationId: cases.organizationId,
caseId: id,
actorUserId: currentUser.id,
actionType: 'case_updated',
message: 'Case details were updated',
metadata: { updatedFields: Object.keys(data) }
});
}
return updatedCases;
`;
casesSvc = casesSvc.replace(/let cases = await CasesDBApi\.findBy\([\s\S]*?return updatedCases;/m, updateCode);
fs.writeFileSync('backend/src/services/cases.js', casesSvc);

View File

@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const serviceFile = path.join(__dirname, 'backend/src/services/cases.js');
let service = fs.readFileSync(serviceFile, 'utf8');
const newMethods = `
static async assignOwner(data, id, currentUser) {
if (!data.assignedToUserId) throw new ValidationError('assignedToUserIdRequired');
return await this.update({ owner_userId: data.assignedToUserId }, id, currentUser);
}
static async changeStatus(data, id, currentUser) {
if (!data.status) throw new ValidationError('statusRequired');
return await this.update({ status: data.status }, id, currentUser);
}
static async reopen(data, id, currentUser) {
if (!data.reopenReason) throw new ValidationError('reopenReasonRequired', 'A reason is required to reopen a case');
return await this.update({ status: data.status || 'intake', reopenReason: data.reopenReason }, id, currentUser);
}
static async markWon(data, id, currentUser) {
if (!data.resolutionReason) throw new ValidationError('resolutionReasonRequired', 'A reason is required to mark as won');
// We can also set outcome to won here
return await this.update({ status: 'won', outcome: 'won', resolutionReason: data.resolutionReason }, id, currentUser);
}
static async markLost(data, id, currentUser) {
if (!data.resolutionReason) throw new ValidationError('resolutionReasonRequired', 'A reason is required to mark as lost');
// We can also set outcome to lost here
return await this.update({ status: 'lost', outcome: 'lost', resolutionReason: data.resolutionReason }, id, currentUser);
}
`;
service = service.replace(/}\nmodule\.exports = CasesService;/, newMethods + '\n}\nmodule.exports = CasesService;');
// Wait, the update method should also log the resolutionReason.
service = service.replace(/metadata: \{ oldStatus, newStatus, reopenReason: data\.reopenReason \}/, 'metadata: { oldStatus, newStatus, reopenReason: data.reopenReason, resolutionReason: data.resolutionReason }');
fs.writeFileSync(serviceFile, service);
console.log('Patched CasesService');

46
patch_cases_view.js Normal file
View File

@ -0,0 +1,46 @@
const fs = require('fs');
let view = fs.readFileSync('frontend/src/pages/cases/cases-view.tsx', 'utf8');
if (!view.includes('updateCase')) {
view = view.replace("import { fetch as fetchCase } from '../../stores/cases/casesSlice';", "import { fetch as fetchCase, update as updateCase } from '../../stores/cases/casesSlice';");
}
const commandArea = `
{activeTab === 'overview' && (
<CardBox className="mb-6">
<h3 className="text-xl font-semibold mb-4">Command Actions</h3>
<div className="flex flex-wrap gap-2">
<BaseButton color="info" outline label="Assign Owner" href={`/cases/cases-edit/?id=${id}`} />
<BaseButton color="info" outline label="Change Status" href={`/cases/cases-edit/?id=${id}`} />
<BaseButton color="success" outline label="Add Task" href={`/tasks/tasks-new?caseId=${id}`} />
<BaseButton color="warning" outline label="Add Note" href={`/notes/notes-new?caseId=${id}`} />
<BaseButton color="info" outline label="Upload Document" href={`/documents/documents-new?caseId=${id}`} />
<BaseButton color="info" outline label="New Draft" href={`/appeal_drafts/appeal_drafts-new?caseId=${id}`} />
<BaseButton
color="success"
label="Mark Won"
onClick={() => {
const reason = prompt("Enter reason (optional)");
dispatch(updateCase({ id, data: { status: 'won', reopenReason: reason }})).then(() => dispatch(fetchCase({id})));
}}
/>
<BaseButton
color="danger"
label="Mark Lost"
onClick={() => {
const reason = prompt("Enter reason (optional)");
dispatch(updateCase({ id, data: { status: 'lost', reopenReason: reason }})).then(() => dispatch(fetchCase({id})));
}}
/>
</div>
</CardBox>
)
`;
if (!view.includes('Command Actions')) {
view = view.replace('{activeTab === \'overview\' && (', commandArea + '\n {activeTab === \'overview\' && (');
}
fs.writeFileSync('frontend/src/pages/cases/cases-view.tsx', view);

102
patch_cases_view_modals.js Normal file
View File

@ -0,0 +1,102 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, 'frontend/src/pages/cases/cases-view.tsx');
let content = fs.readFileSync(filePath, 'utf8');
const imports = `
import CardBoxModal from "../../components/CardBoxModal";
import FormField from "../../components/FormField";
import { Field, Form, Formik } from "formik";
import axios from "axios";
`;
content = content.replace("import BaseButton from \"../../components/BaseButton\";", "import BaseButton from \"../../components/BaseButton\";" + imports);
const stateAndHandlers = `
const [modalAction, setModalAction] = useState(null);
const [actionLoading, setActionLoading] = useState(false);
const handleActionSubmit = async (values) => {
setActionLoading(true);
try {
if (modalAction === 'markWon') {
await axios.put(\"/cases/\${id}/mark-won\", { data: { resolutionReason: values.reason } });
} else if (modalAction === 'markLost') {
await axios.put(\"/cases/\${id}/mark-lost\", { data: { resolutionReason: values.reason } });
} else if (modalAction === 'reopen') {
await axios.put(\"/cases/\${id}/reopen\", { data: { reopenReason: values.reason } });
} else if (modalAction === 'changeStatus') {
await axios.put(\"/cases/\${id}/change-status\", { data: { status: values.status } });
}
dispatch(fetchCase({ id }));
setModalAction(null);
} catch (e) {
console.error(e);
alert('Action failed: ' + (e.response?.data?.message || e.message));
} finally {
setActionLoading(false);
}
};
`;
content = content.replace(/const { id } = router.query;/, stateAndHandlers + '\n const { id } = router.query;');
const newButtons = `
<BaseButton color="info" outline label="Change Status" onClick={() => setModalAction('changeStatus')} />
<BaseButton color="success" outline label="Add Task" href={'/tasks/tasks-new?caseId=' + id} />
<BaseButton color="warning" outline label="Add Note" href={'/notes/notes-new?caseId=' + id} />
<BaseButton color="info" outline label="Upload Document" href={'/documents/documents-new?caseId=' + id} />
<BaseButton color="info" outline label="New Draft" href={'/appeal_drafts/appeal_drafts-new?caseId=' + id} />
<BaseButton color="success" label="Mark Won" onClick={() => setModalAction('markWon')} />
<BaseButton color="danger" label="Mark Lost" onClick={() => setModalAction('markLost')} />
{['won', 'lost'].includes(cases.status) && <BaseButton color="warning" label="Reopen Case" onClick={() => setModalAction('reopen')} />}
`;
content = content.replace(/<BaseButton color="info" outline label="Change Status"[\s\S]*? dispatch\(fetchCase\(\{id\}\)\)\); \}\} \/>/, newButtons);
const modals = `
<CardBoxModal
isActive={!!modalAction}
title={modalAction === 'markWon' ? 'Mark Case Won' : modalAction === 'markLost' ? 'Mark Case Lost' : modalAction === 'reopen' ? 'Reopen Case' : modalAction === 'changeStatus' ? 'Change Status' : ''}
buttonColor={modalAction === 'markWon' ? 'success' : modalAction === 'markLost' ? 'danger' : 'info'}
buttonLabel="Confirm"
onCancel={() => setModalAction(null)}
onConfirm={() => {
const form = document.getElementById('action-form');
if (form) form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}}
>
<Formik
initialValues={{ reason: '', status: cases.status }}
onSubmit={handleActionSubmit}
>
<Form id="action-form">
{['markWon', 'markLost', 'reopen'].includes(modalAction) && (
<FormField label="Reason (Required)">
<Field name="reason" as="textarea" className="w-full border-gray-300 rounded p-2" rows="3" required />
</FormField>
)}
{modalAction === 'changeStatus' && (
<FormField label="New Status">
<Field name="status" as="select" className="w-full border-gray-300 rounded p-2">
<option value="intake">Intake</option>
<option value="triage">Triage</option>
<option value="evidence_needed">Evidence Needed</option>
<option value="appeal_ready">Appeal Ready</option>
<option value="submitted">Submitted</option>
<option value="pending_payer">Pending Payer</option>
<option value="won">Won</option>
<option value="lost">Lost</option>
</Field>
</FormField>
)}
</Form>
</Formik>
</CardBoxModal>
`;
content = content.replace(/<SectionMain>/, "<SectionMain>\n" + modals);
fs.writeFileSync(filePath, content);
console.log('Patched CasesView');

44
patch_models.js Normal file
View File

@ -0,0 +1,44 @@
const fs = require('fs');
// Patch activity_logs.js
let activityLogs = fs.readFileSync('backend/src/db/models/activity_logs.js', 'utf8');
if (!activityLogs.includes('actionType')) {
activityLogs = activityLogs.replace(
'message: {',
'actionType: { type: DataTypes.STRING },\n metadata: { type: DataTypes.JSONB },\n\n message: {'
);
fs.writeFileSync('backend/src/db/models/activity_logs.js', activityLogs);
}
// Patch appeal_drafts.js
let appealDrafts = fs.readFileSync('backend/src/db/models/appeal_drafts.js', 'utf8');
if (!appealDrafts.includes('version: {')) {
appealDrafts = appealDrafts.replace(
'title: {',
'version: { type: DataTypes.INTEGER, defaultValue: 1 },\n summary: { type: DataTypes.TEXT },\n body: { type: DataTypes.TEXT },\n evidenceChecklist: { type: DataTypes.JSONB },\n\n title: {'
);
// Add belongsTo for submittedByUserId
appealDrafts = appealDrafts.replace(
'db.appeal_drafts.belongsTo(db.users, {',
'db.appeal_drafts.belongsTo(db.users, {\n as: \"submittedByUser\",\n foreignKey: {\n name: \"submittedByUserId\",\n },\n constraints: false,\n });\n\n db.appeal_drafts.belongsTo(db.users, {'
);
// Unescape literal backslashes so it's actual newlines
appealDrafts = appealDrafts.replace(/\\n/g, '\n');
// change status to STRING
appealDrafts = appealDrafts.replace(
/status: {\s*type: DataTypes\.ENUM,[\s\S]*?],/,
'status: {\n type: DataTypes.STRING,'
);
fs.writeFileSync('backend/src/db/models/appeal_drafts.js', appealDrafts);
}
// Patch cases.js for status type change
let cases = fs.readFileSync('backend/src/db/models/cases.js', 'utf8');
if (cases.includes('type: DataTypes.ENUM,')) {
cases = cases.replace(
/status: {\s*type: DataTypes\.ENUM,[\s\S]*?],/,
'status: {\n type: DataTypes.STRING,'
);
fs.writeFileSync('backend/src/db/models/cases.js', cases);
}

22
patch_new_forms.js Normal file
View File

@ -0,0 +1,22 @@
const fs = require('fs');
const entities = ['tasks', 'documents', 'appeal_drafts', 'notes', 'activity_logs'];
for (const e of entities) {
const file = `frontend/src/pages/${e}/${e}-new.tsx`;
if (!fs.existsSync(file)) continue;
let content = fs.readFileSync(file, 'utf8');
content = content.replace(
new RegExp(`await router\.push\('/${e}/${e}-list'\)`),
`await router.push(router.query.caseId ? `/cases/cases-view?id=${router.query.caseId}` : '/${e}/${e}-list')`
);
content = content.replace(
/initialValues={[^]*?}[\s\S]*?onSubmit=/,
`initialValues={{ ...(typeof dateRangeStart !== 'undefined' && dateRangeStart && dateRangeEnd ? { ...initialValues, due_at: moment(dateRangeStart).format('YYYY-MM-DDTHH:mm'), completed_at: moment(dateRangeEnd).format('YYYY-MM-DDTHH:mm') } : initialValues), case: router.query.caseId || '' }}
onSubmit=`
);
fs.writeFileSync(file, content);
}

47
patch_other_services.js Normal file
View File

@ -0,0 +1,47 @@
const fs = require('fs');
function patchService(serviceName, entityName, createAction, updateAction) {
let code = fs.readFileSync(`backend/src/services/${serviceName}.js`, 'utf8');
// Add Logger import
if (!code.includes("const Logger = require('./logger');")) {
code = code.replace("const config = require('../config');", "const config = require('../config');\nconst Logger = require('./logger');\n");
}
// Patch create
let createMatch = new RegExp(`await ${entityName}DBApi.create([\s\S]*?);`);
if (code.includes(`await ${entityName}DBApi.create(`)) {
code = code.replace(`await ${entityName}DBApi.create(`, `const newEntity = await ${entityName}DBApi.create(`);
let commitIndex = code.indexOf('await transaction.commit();');
if (commitIndex > -1 && !code.includes(`actionType: '${createAction}'`)) {
code = code.replace(
'await transaction.commit();',
`await transaction.commit();\n if (newEntity.caseId) { await Logger.log({ organizationId: newEntity.organizationId, caseId: newEntity.caseId, actorUserId: currentUser.id, actionType: '${createAction}', message: '${createAction.replace('_', ' ')}' }); }`
);
}
}
// Patch update
let updateMatch = new RegExp(`const updated${entityName} = await ${entityName}DBApi.update([\s\S]*?);`);
if (code.includes(`const updated${entityName} = await ${entityName}DBApi.update(`) && !code.includes(`actionType: '${updateAction}'`)) {
code = code.replace(
'await transaction.commit();\n return updated',
`await transaction.commit();
let dbEntity = await ${entityName}DBApi.findBy({id});
if (dbEntity && dbEntity.caseId) {
let act = '${updateAction}';
if (data.status === 'completed' && dbEntity.status !== 'completed') act = '${entityName.toLowerCase()}_completed';
await Logger.log({ organizationId: dbEntity.organizationId, caseId: dbEntity.caseId, actorUserId: currentUser.id, actionType: act, message: act.replace('_', ' ') });
}
return updated`
);
}
fs.writeFileSync(`backend/src/services/${serviceName}.js`, code);
}
patchService('tasks', 'Tasks', 'task_created', 'task_updated');
patchService('notes', 'Notes', 'note_added', 'note_updated');
patchService('documents', 'Documents', 'document_uploaded', 'document_updated');
// Special logic for appeal_drafts: creating a new draft increments version, only one draft can be in submitted status per case.