405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { Request, Response } from 'express';
|
|
import { WorkflowRequest, ApprovalLevel, WorkNote, Document, Activity, ConclusionRemark } from '@models/index';
|
|
import { aiService } from '@services/ai.service';
|
|
import { activityService } from '@services/activity.service';
|
|
import logger from '@utils/logger';
|
|
import { getRequestMetadata } from '@utils/requestUtils';
|
|
|
|
export class ConclusionController {
|
|
/**
|
|
* Generate AI conclusion remark for a request
|
|
* POST /api/v1/conclusions/:requestId/generate
|
|
*/
|
|
async generateConclusion(req: Request, res: Response) {
|
|
try {
|
|
const { requestId } = req.params;
|
|
const userId = (req as any).user?.userId;
|
|
|
|
// Fetch request with all related data
|
|
const request = await WorkflowRequest.findOne({
|
|
where: { requestId },
|
|
include: [
|
|
{ association: 'initiator', attributes: ['userId', 'displayName', 'email'] }
|
|
]
|
|
});
|
|
|
|
if (!request) {
|
|
return res.status(404).json({ error: 'Request not found' });
|
|
}
|
|
|
|
// Check if user is the initiator
|
|
if ((request as any).initiatorId !== userId) {
|
|
return res.status(403).json({ error: 'Only the initiator can generate conclusion remarks' });
|
|
}
|
|
|
|
// Check if request is approved
|
|
if ((request as any).status !== 'APPROVED') {
|
|
return res.status(400).json({ error: 'Conclusion can only be generated for approved requests' });
|
|
}
|
|
|
|
// Check if AI features are enabled in admin config
|
|
const { getConfigValue } = await import('../services/configReader.service');
|
|
const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true';
|
|
const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true';
|
|
|
|
if (!aiEnabled) {
|
|
logger.warn(`[Conclusion] AI features disabled in admin config for request ${requestId}`);
|
|
return res.status(400).json({
|
|
error: 'AI features disabled',
|
|
message: 'AI features are currently disabled by administrator. Please write the conclusion manually.',
|
|
canContinueManually: true
|
|
});
|
|
}
|
|
|
|
if (!remarkGenerationEnabled) {
|
|
logger.warn(`[Conclusion] AI remark generation disabled in admin config for request ${requestId}`);
|
|
return res.status(400).json({
|
|
error: 'AI remark generation disabled',
|
|
message: 'AI-powered conclusion generation is currently disabled by administrator. Please write the conclusion manually.',
|
|
canContinueManually: true
|
|
});
|
|
}
|
|
|
|
// Check if AI service is available
|
|
if (!aiService.isAvailable()) {
|
|
logger.warn(`[Conclusion] AI service unavailable for request ${requestId}`);
|
|
return res.status(503).json({
|
|
error: 'AI service not available',
|
|
message: 'AI features are currently unavailable. Please configure an AI provider (Claude, OpenAI, or Gemini) in the admin panel, or write the conclusion manually.',
|
|
canContinueManually: true
|
|
});
|
|
}
|
|
|
|
// Gather context for AI generation
|
|
const approvalLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
const workNotes = await WorkNote.findAll({
|
|
where: { requestId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: 20 // Last 20 work notes
|
|
});
|
|
|
|
const documents = await Document.findAll({
|
|
where: { requestId },
|
|
order: [['uploadedAt', 'DESC']]
|
|
});
|
|
|
|
const activities = await Activity.findAll({
|
|
where: { requestId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: 50 // Last 50 activities
|
|
});
|
|
|
|
// Build context object
|
|
const context = {
|
|
requestTitle: (request as any).title,
|
|
requestDescription: (request as any).description,
|
|
requestNumber: (request as any).requestNumber,
|
|
priority: (request as any).priority,
|
|
approvalFlow: approvalLevels.map((level: any) => ({
|
|
levelNumber: level.levelNumber,
|
|
approverName: level.approverName,
|
|
status: level.status,
|
|
comments: level.comments,
|
|
actionDate: level.actionDate,
|
|
tatHours: Number(level.tatHours || 0),
|
|
elapsedHours: Number(level.elapsedHours || 0)
|
|
})),
|
|
workNotes: workNotes.map((note: any) => ({
|
|
userName: note.userName,
|
|
message: note.message,
|
|
createdAt: note.createdAt
|
|
})),
|
|
documents: documents.map((doc: any) => ({
|
|
fileName: doc.originalFileName || doc.fileName,
|
|
uploadedBy: doc.uploadedBy,
|
|
uploadedAt: doc.uploadedAt
|
|
})),
|
|
activities: activities.map((activity: any) => ({
|
|
type: activity.activityType,
|
|
action: activity.activityDescription,
|
|
details: activity.activityDescription,
|
|
timestamp: activity.createdAt
|
|
}))
|
|
};
|
|
|
|
logger.info(`[Conclusion] Generating AI remark for request ${requestId}...`);
|
|
|
|
// Generate AI conclusion
|
|
const aiResult = await aiService.generateConclusionRemark(context);
|
|
|
|
// Check if conclusion already exists
|
|
let conclusionInstance = await ConclusionRemark.findOne({ where: { requestId } });
|
|
|
|
const conclusionData = {
|
|
aiGeneratedRemark: aiResult.remark,
|
|
aiModelUsed: aiResult.provider,
|
|
aiConfidenceScore: aiResult.confidence,
|
|
approvalSummary: {
|
|
totalLevels: approvalLevels.length,
|
|
approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length,
|
|
averageTatUsage: approvalLevels.reduce((sum: number, l: any) =>
|
|
sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1)
|
|
},
|
|
documentSummary: {
|
|
totalDocuments: documents.length,
|
|
documentNames: documents.map((d: any) => d.originalFileName || d.fileName)
|
|
},
|
|
keyDiscussionPoints: aiResult.keyPoints,
|
|
generatedAt: new Date()
|
|
};
|
|
|
|
if (conclusionInstance) {
|
|
// Update existing conclusion (allow regeneration)
|
|
await conclusionInstance.update(conclusionData as any);
|
|
logger.info(`[Conclusion] ✅ AI conclusion regenerated for request ${requestId}`);
|
|
} else {
|
|
// Create new conclusion
|
|
conclusionInstance = await ConclusionRemark.create({
|
|
requestId,
|
|
...conclusionData,
|
|
finalRemark: null,
|
|
editedBy: null,
|
|
isEdited: false,
|
|
editCount: 0,
|
|
finalizedAt: null
|
|
} as any);
|
|
logger.info(`[Conclusion] ✅ AI conclusion generated for request ${requestId}`);
|
|
}
|
|
|
|
// Log activity
|
|
const requestMeta = getRequestMetadata(req);
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'ai_conclusion_generated',
|
|
user: { userId, name: (request as any).initiator?.displayName || 'Initiator' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'AI Conclusion Generated',
|
|
details: 'AI-powered conclusion remark generated for review',
|
|
ipAddress: requestMeta.ipAddress,
|
|
userAgent: requestMeta.userAgent
|
|
});
|
|
|
|
return res.status(200).json({
|
|
message: 'Conclusion generated successfully',
|
|
data: {
|
|
conclusionId: (conclusionInstance as any).conclusionId,
|
|
aiGeneratedRemark: aiResult.remark,
|
|
keyDiscussionPoints: aiResult.keyPoints,
|
|
confidence: aiResult.confidence,
|
|
provider: aiResult.provider,
|
|
generatedAt: new Date()
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
logger.error('[Conclusion] Error generating conclusion:', error);
|
|
|
|
// Provide helpful error messages
|
|
const isConfigError = error.message?.includes('not configured') ||
|
|
error.message?.includes('not available') ||
|
|
error.message?.includes('not initialized');
|
|
|
|
return res.status(isConfigError ? 503 : 500).json({
|
|
error: isConfigError ? 'AI service not configured' : 'Failed to generate conclusion',
|
|
message: error.message || 'An unexpected error occurred',
|
|
canContinueManually: true // User can still write manual conclusion
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update conclusion remark (edit by initiator)
|
|
* PUT /api/v1/conclusions/:requestId
|
|
*/
|
|
async updateConclusion(req: Request, res: Response) {
|
|
try {
|
|
const { requestId } = req.params;
|
|
const { finalRemark } = req.body;
|
|
const userId = (req as any).user?.userId;
|
|
|
|
if (!finalRemark || typeof finalRemark !== 'string') {
|
|
return res.status(400).json({ error: 'Final remark is required' });
|
|
}
|
|
|
|
// Fetch request
|
|
const request = await WorkflowRequest.findOne({ where: { requestId } });
|
|
if (!request) {
|
|
return res.status(404).json({ error: 'Request not found' });
|
|
}
|
|
|
|
// Check if user is the initiator
|
|
if ((request as any).initiatorId !== userId) {
|
|
return res.status(403).json({ error: 'Only the initiator can update conclusion remarks' });
|
|
}
|
|
|
|
// Find conclusion
|
|
const conclusion = await ConclusionRemark.findOne({ where: { requestId } });
|
|
if (!conclusion) {
|
|
return res.status(404).json({ error: 'Conclusion not found. Generate it first.' });
|
|
}
|
|
|
|
// Update conclusion
|
|
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
|
|
|
await conclusion.update({
|
|
finalRemark: finalRemark,
|
|
editedBy: userId,
|
|
isEdited: wasEdited,
|
|
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount
|
|
} as any);
|
|
|
|
logger.info(`[Conclusion] Updated conclusion for request ${requestId} (edited: ${wasEdited})`);
|
|
|
|
return res.status(200).json({
|
|
message: 'Conclusion updated successfully',
|
|
data: conclusion
|
|
});
|
|
} catch (error: any) {
|
|
logger.error('[Conclusion] Error updating conclusion:', error);
|
|
return res.status(500).json({ error: 'Failed to update conclusion' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finalize conclusion and close request
|
|
* POST /api/v1/conclusions/:requestId/finalize
|
|
*/
|
|
async finalizeConclusion(req: Request, res: Response) {
|
|
try {
|
|
const { requestId } = req.params;
|
|
const { finalRemark } = req.body;
|
|
const userId = (req as any).user?.userId;
|
|
|
|
if (!finalRemark || typeof finalRemark !== 'string') {
|
|
return res.status(400).json({ error: 'Final remark is required' });
|
|
}
|
|
|
|
// Fetch request
|
|
const request = await WorkflowRequest.findOne({
|
|
where: { requestId },
|
|
include: [
|
|
{ association: 'initiator', attributes: ['userId', 'displayName', 'email'] }
|
|
]
|
|
});
|
|
|
|
if (!request) {
|
|
return res.status(404).json({ error: 'Request not found' });
|
|
}
|
|
|
|
// Check if user is the initiator
|
|
if ((request as any).initiatorId !== userId) {
|
|
return res.status(403).json({ error: 'Only the initiator can finalize conclusion remarks' });
|
|
}
|
|
|
|
// Check if request is approved
|
|
if ((request as any).status !== 'APPROVED') {
|
|
return res.status(400).json({ error: 'Only approved requests can be closed' });
|
|
}
|
|
|
|
// Find or create conclusion
|
|
let conclusion = await ConclusionRemark.findOne({ where: { requestId } });
|
|
|
|
if (!conclusion) {
|
|
// Create if doesn't exist (manual conclusion without AI)
|
|
conclusion = await ConclusionRemark.create({
|
|
requestId,
|
|
aiGeneratedRemark: null,
|
|
aiModelUsed: null,
|
|
aiConfidenceScore: null,
|
|
finalRemark: finalRemark,
|
|
editedBy: userId,
|
|
isEdited: false,
|
|
editCount: 0,
|
|
approvalSummary: {},
|
|
documentSummary: {},
|
|
keyDiscussionPoints: [],
|
|
generatedAt: null,
|
|
finalizedAt: new Date()
|
|
} as any);
|
|
} else {
|
|
// Update existing conclusion
|
|
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
|
|
|
await conclusion.update({
|
|
finalRemark: finalRemark,
|
|
editedBy: userId,
|
|
isEdited: wasEdited,
|
|
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount,
|
|
finalizedAt: new Date()
|
|
} as any);
|
|
}
|
|
|
|
// Update request status to CLOSED
|
|
await request.update({
|
|
status: 'CLOSED',
|
|
conclusionRemark: finalRemark,
|
|
closureDate: new Date()
|
|
} as any);
|
|
|
|
logger.info(`[Conclusion] ✅ Request ${requestId} finalized and closed`);
|
|
|
|
// Log activity
|
|
const requestMeta = getRequestMetadata(req);
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'closed',
|
|
user: { userId, name: (request as any).initiator?.displayName || 'Initiator' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Request Closed',
|
|
details: `Request closed with conclusion remark by ${(request as any).initiator?.displayName}`,
|
|
ipAddress: requestMeta.ipAddress,
|
|
userAgent: requestMeta.userAgent
|
|
});
|
|
|
|
return res.status(200).json({
|
|
message: 'Request finalized and closed successfully',
|
|
data: {
|
|
conclusionId: (conclusion as any).conclusionId,
|
|
requestNumber: (request as any).requestNumber,
|
|
status: 'CLOSED',
|
|
finalRemark: finalRemark,
|
|
finalizedAt: (conclusion as any).finalizedAt
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
logger.error('[Conclusion] Error finalizing conclusion:', error);
|
|
return res.status(500).json({ error: 'Failed to finalize conclusion' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get conclusion for a request
|
|
* GET /api/v1/conclusions/:requestId
|
|
*/
|
|
async getConclusion(req: Request, res: Response) {
|
|
try {
|
|
const { requestId } = req.params;
|
|
|
|
const conclusion = await ConclusionRemark.findOne({
|
|
where: { requestId },
|
|
include: [
|
|
{ association: 'editor', attributes: ['userId', 'displayName', 'email'] }
|
|
]
|
|
});
|
|
|
|
if (!conclusion) {
|
|
return res.status(404).json({ error: 'Conclusion not found' });
|
|
}
|
|
|
|
return res.status(200).json({
|
|
message: 'Conclusion retrieved successfully',
|
|
data: conclusion
|
|
});
|
|
} catch (error: any) {
|
|
logger.error('[Conclusion] Error getting conclusion:', error);
|
|
return res.status(500).json({ error: 'Failed to get conclusion' });
|
|
}
|
|
}
|
|
}
|
|
|
|
export const conclusionController = new ConclusionController();
|
|
|