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 or rejected if ((request as any).status !== 'APPROVED' && (request as any).status !== 'REJECTED') { return res.status(400).json({ error: 'Conclusion can only be generated for approved or rejected 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) => { const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null ? Number(level.tatPercentageUsed) : (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0); return { 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), tatPercentageUsed: tatPercentage }; }), 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 or rejected if ((request as any).status !== 'APPROVED' && (request as any).status !== 'REJECTED') { return res.status(400).json({ error: 'Only approved or rejected 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`); // Automatically create summary when request is closed (idempotent - returns existing if already exists) // Since the initiator is finalizing, this should always succeed let summaryId = null; try { const { summaryService } = await import('@services/summary.service'); const userRole = (req as any).user?.role || (req as any).auth?.role; const summary = await summaryService.createSummary(requestId, userId, { userRole }); summaryId = (summary as any).summaryId; logger.info(`[Conclusion] ✅ Summary ${summaryId} created automatically for closed request ${requestId}`); } catch (summaryError: any) { // Log error but don't fail the closure if summary creation fails // Frontend can retry summary creation if needed logger.error(`[Conclusion] Failed to create summary for request ${requestId}:`, summaryError.message); } // 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, summaryId: summaryId // Include summaryId in response } }); } 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();