summary tab added for closed request and for rejected request cclosure flow added
This commit is contained in:
parent
eeeec90c89
commit
262b06453d
@ -32,9 +32,9 @@ export class ConclusionController {
|
|||||||
return res.status(403).json({ error: 'Only the initiator can generate conclusion remarks' });
|
return res.status(403).json({ error: 'Only the initiator can generate conclusion remarks' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if request is approved
|
// Check if request is approved or rejected
|
||||||
if ((request as any).status !== 'APPROVED') {
|
if ((request as any).status !== 'APPROVED' && (request as any).status !== 'REJECTED') {
|
||||||
return res.status(400).json({ error: 'Conclusion can only be generated for approved requests' });
|
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
|
// Check if AI features are enabled in admin config
|
||||||
@ -99,15 +99,21 @@ export class ConclusionController {
|
|||||||
requestDescription: (request as any).description,
|
requestDescription: (request as any).description,
|
||||||
requestNumber: (request as any).requestNumber,
|
requestNumber: (request as any).requestNumber,
|
||||||
priority: (request as any).priority,
|
priority: (request as any).priority,
|
||||||
approvalFlow: approvalLevels.map((level: any) => ({
|
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,
|
levelNumber: level.levelNumber,
|
||||||
approverName: level.approverName,
|
approverName: level.approverName,
|
||||||
status: level.status,
|
status: level.status,
|
||||||
comments: level.comments,
|
comments: level.comments,
|
||||||
actionDate: level.actionDate,
|
actionDate: level.actionDate,
|
||||||
tatHours: Number(level.tatHours || 0),
|
tatHours: Number(level.tatHours || 0),
|
||||||
elapsedHours: Number(level.elapsedHours || 0)
|
elapsedHours: Number(level.elapsedHours || 0),
|
||||||
})),
|
tatPercentageUsed: tatPercentage
|
||||||
|
};
|
||||||
|
}),
|
||||||
workNotes: workNotes.map((note: any) => ({
|
workNotes: workNotes.map((note: any) => ({
|
||||||
userName: note.userName,
|
userName: note.userName,
|
||||||
message: note.message,
|
message: note.message,
|
||||||
@ -294,9 +300,9 @@ export class ConclusionController {
|
|||||||
return res.status(403).json({ error: 'Only the initiator can finalize conclusion remarks' });
|
return res.status(403).json({ error: 'Only the initiator can finalize conclusion remarks' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if request is approved
|
// Check if request is approved or rejected
|
||||||
if ((request as any).status !== 'APPROVED') {
|
if ((request as any).status !== 'APPROVED' && (request as any).status !== 'REJECTED') {
|
||||||
return res.status(400).json({ error: 'Only approved requests can be closed' });
|
return res.status(400).json({ error: 'Only approved or rejected requests can be closed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create conclusion
|
// Find or create conclusion
|
||||||
@ -341,6 +347,19 @@ export class ConclusionController {
|
|||||||
|
|
||||||
logger.info(`[Conclusion] ✅ Request ${requestId} finalized and closed`);
|
logger.info(`[Conclusion] ✅ Request ${requestId} finalized and closed`);
|
||||||
|
|
||||||
|
// Automatically create summary when request is closed (idempotent - returns existing if already exists)
|
||||||
|
let summaryId = null;
|
||||||
|
try {
|
||||||
|
const { summaryService } = await import('@services/summary.service');
|
||||||
|
const summary = await summaryService.createSummary(requestId, userId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
const requestMeta = getRequestMetadata(req);
|
const requestMeta = getRequestMetadata(req);
|
||||||
await activityService.log({
|
await activityService.log({
|
||||||
@ -361,7 +380,8 @@ export class ConclusionController {
|
|||||||
requestNumber: (request as any).requestNumber,
|
requestNumber: (request as any).requestNumber,
|
||||||
status: 'CLOSED',
|
status: 'CLOSED',
|
||||||
finalRemark: finalRemark,
|
finalRemark: finalRemark,
|
||||||
finalizedAt: (conclusion as any).finalizedAt
|
finalizedAt: (conclusion as any).finalizedAt,
|
||||||
|
summaryId: summaryId // Include summaryId in response
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -130,6 +130,32 @@ export class SummaryController {
|
|||||||
ResponseHandler.error(res, 'Failed to list summaries', 500, errorMessage);
|
ResponseHandler.error(res, 'Failed to list summaries', 500, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary by requestId (checks if exists without creating)
|
||||||
|
* GET /api/v1/summaries/request/:requestId
|
||||||
|
*/
|
||||||
|
async getSummaryByRequestId(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||||
|
const { requestId } = req.params;
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
ResponseHandler.error(res, 'requestId is required', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await summaryService.getSummaryByRequestId(requestId, userId);
|
||||||
|
if (summary) {
|
||||||
|
ResponseHandler.success(res, summary, 'Summary retrieved successfully');
|
||||||
|
} else {
|
||||||
|
ResponseHandler.error(res, 'Summary not found', 404);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
ResponseHandler.error(res, 'Failed to get summary', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const summaryController = new SummaryController();
|
export const summaryController = new SummaryController();
|
||||||
|
|||||||
@ -273,11 +273,12 @@ export class WorkflowController {
|
|||||||
const status = req.query.status as string | undefined;
|
const status = req.query.status as string | undefined;
|
||||||
const priority = req.query.priority as string | undefined;
|
const priority = req.query.priority as string | undefined;
|
||||||
const department = req.query.department as string | undefined;
|
const department = req.query.department as string | undefined;
|
||||||
|
const slaCompliance = req.query.slaCompliance as string | undefined;
|
||||||
const dateRange = req.query.dateRange as string | undefined;
|
const dateRange = req.query.dateRange as string | undefined;
|
||||||
const startDate = req.query.startDate as string | undefined;
|
const startDate = req.query.startDate as string | undefined;
|
||||||
const endDate = req.query.endDate as string | undefined;
|
const endDate = req.query.endDate as string | undefined;
|
||||||
|
|
||||||
const filters = { search, status, priority, department, dateRange, startDate, endDate };
|
const filters = { search, status, priority, department, slaCompliance, dateRange, startDate, endDate };
|
||||||
|
|
||||||
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||||
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
||||||
|
|||||||
@ -32,6 +32,12 @@ router.get(
|
|||||||
asyncHandler(summaryController.listMySummaries.bind(summaryController))
|
asyncHandler(summaryController.listMySummaries.bind(summaryController))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get summary by requestId (MUST come before /:summaryId)
|
||||||
|
router.get(
|
||||||
|
'/request/:requestId',
|
||||||
|
asyncHandler(summaryController.getSummaryByRequestId.bind(summaryController))
|
||||||
|
);
|
||||||
|
|
||||||
// Share summary with users (MUST come before /:summaryId)
|
// Share summary with users (MUST come before /:summaryId)
|
||||||
router.post(
|
router.post(
|
||||||
'/:summaryId/share',
|
'/:summaryId/share',
|
||||||
|
|||||||
@ -422,7 +422,9 @@ class AIService {
|
|||||||
approvalFlow,
|
approvalFlow,
|
||||||
workNotes,
|
workNotes,
|
||||||
documents,
|
documents,
|
||||||
activities
|
activities,
|
||||||
|
rejectionReason,
|
||||||
|
rejectedBy
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
// Get max remark length from admin configuration
|
// Get max remark length from admin configuration
|
||||||
@ -433,14 +435,30 @@ class AIService {
|
|||||||
|
|
||||||
logger.info(`[AI Service] Using max remark length: ${maxLength} characters (≈${targetWordCount} words) from admin config`);
|
logger.info(`[AI Service] Using max remark length: ${maxLength} characters (≈${targetWordCount} words) from admin config`);
|
||||||
|
|
||||||
// Summarize approvals
|
// Check if this is a rejected request
|
||||||
|
const isRejected = rejectionReason || rejectedBy || approvalFlow.some((a: any) => a.status === 'REJECTED');
|
||||||
|
|
||||||
|
// Helper function to determine TAT risk status
|
||||||
|
const getTATRiskStatus = (tatPercentage: number): string => {
|
||||||
|
if (tatPercentage < 50) return 'ON_TRACK';
|
||||||
|
if (tatPercentage < 75) return 'AT_RISK';
|
||||||
|
if (tatPercentage < 100) return 'CRITICAL';
|
||||||
|
return 'BREACHED';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summarize approvals with TAT risk information
|
||||||
const approvalSummary = approvalFlow
|
const approvalSummary = approvalFlow
|
||||||
.filter((a: any) => a.status === 'APPROVED' || a.status === 'REJECTED')
|
.filter((a: any) => a.status === 'APPROVED' || a.status === 'REJECTED')
|
||||||
.map((a: any) => {
|
.map((a: any) => {
|
||||||
|
const tatPercentage = a.tatPercentageUsed !== undefined && a.tatPercentageUsed !== null
|
||||||
|
? Number(a.tatPercentageUsed)
|
||||||
|
: (a.elapsedHours && a.tatHours ? (Number(a.elapsedHours) / Number(a.tatHours)) * 100 : 0);
|
||||||
|
const riskStatus = getTATRiskStatus(tatPercentage);
|
||||||
const tatInfo = a.elapsedHours && a.tatHours
|
const tatInfo = a.elapsedHours && a.tatHours
|
||||||
? ` (completed in ${a.elapsedHours.toFixed(1)}h of ${a.tatHours}h TAT)`
|
? ` (completed in ${a.elapsedHours.toFixed(1)}h of ${a.tatHours}h TAT, ${tatPercentage.toFixed(1)}% used)`
|
||||||
: '';
|
: '';
|
||||||
return `- Level ${a.levelNumber}: ${a.approverName} ${a.status}${tatInfo}${a.comments ? `\n Comment: "${a.comments}"` : ''}`;
|
const riskInfo = riskStatus !== 'ON_TRACK' ? ` [${riskStatus}]` : '';
|
||||||
|
return `- Level ${a.levelNumber}: ${a.approverName} ${a.status}${tatInfo}${riskInfo}${a.comments ? `\n Comment: "${a.comments}"` : ''}`;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
@ -455,6 +473,11 @@ class AIService {
|
|||||||
.map((d: any) => `- ${d.fileName} (by ${d.uploadedBy})`)
|
.map((d: any) => `- ${d.fileName} (by ${d.uploadedBy})`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
|
// Build rejection context if applicable
|
||||||
|
const rejectionContext = isRejected
|
||||||
|
? `\n**Rejection Details:**\n- Rejected by: ${rejectedBy || 'Approver'}\n- Rejection reason: ${rejectionReason || 'Not specified'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
const prompt = `You are writing a closure summary for a workflow request at Royal Enfield. Write a practical, realistic conclusion that an employee would write when closing a request.
|
const prompt = `You are writing a closure summary for a workflow request at Royal Enfield. Write a practical, realistic conclusion that an employee would write when closing a request.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@ -463,7 +486,7 @@ Description: ${requestDescription}
|
|||||||
Priority: ${priority}
|
Priority: ${priority}
|
||||||
|
|
||||||
**What Happened:**
|
**What Happened:**
|
||||||
${approvalSummary || 'No approvals recorded'}
|
${approvalSummary || 'No approvals recorded'}${rejectionContext}
|
||||||
|
|
||||||
**Discussions (if any):**
|
**Discussions (if any):**
|
||||||
${workNoteSummary || 'No work notes'}
|
${workNoteSummary || 'No work notes'}
|
||||||
@ -473,12 +496,22 @@ ${documentSummary || 'No documents'}
|
|||||||
|
|
||||||
**YOUR TASK:**
|
**YOUR TASK:**
|
||||||
Write a brief, professional conclusion (approximately ${targetWordCount} words, max ${maxLength} characters) that:
|
Write a brief, professional conclusion (approximately ${targetWordCount} words, max ${maxLength} characters) that:
|
||||||
- Summarizes what was requested and the final decision
|
${isRejected
|
||||||
- Mentions who approved it and any key comments
|
? `- Summarizes what was requested and explains that it was rejected
|
||||||
- Notes the outcome and next steps (if applicable)
|
- Mentions who rejected it and the rejection reason
|
||||||
|
- Notes the outcome and any learnings or next steps
|
||||||
|
- Mentions if any approval levels were AT_RISK, CRITICAL, or BREACHED (if applicable)
|
||||||
- Uses clear, factual language without time-specific references
|
- Uses clear, factual language without time-specific references
|
||||||
- Is suitable for permanent archiving and future reference
|
- Is suitable for permanent archiving and future reference
|
||||||
- Sounds natural and human-written (not AI-generated)
|
- Sounds natural and human-written (not AI-generated)
|
||||||
|
- Maintains a professional and constructive tone even for rejections`
|
||||||
|
: `- Summarizes what was requested and the final decision
|
||||||
|
- Mentions who approved it and any key comments
|
||||||
|
- Mentions if any approval levels were AT_RISK, CRITICAL, or BREACHED (if applicable)
|
||||||
|
- Notes the outcome and next steps (if applicable)
|
||||||
|
- Uses clear, factual language without time-specific references
|
||||||
|
- Is suitable for permanent archiving and future reference
|
||||||
|
- Sounds natural and human-written (not AI-generated)`}
|
||||||
|
|
||||||
**IMPORTANT:**
|
**IMPORTANT:**
|
||||||
- Be concise and direct
|
- Be concise and direct
|
||||||
@ -487,7 +520,7 @@ Write a brief, professional conclusion (approximately ${targetWordCount} words,
|
|||||||
- No corporate jargon or buzzwords
|
- No corporate jargon or buzzwords
|
||||||
- No emojis or excessive formatting
|
- No emojis or excessive formatting
|
||||||
- Write like a professional documenting a completed process
|
- Write like a professional documenting a completed process
|
||||||
- Focus on facts: what was requested, who approved, what was decided
|
- Focus on facts: what was requested, who ${isRejected ? 'rejected' : 'approved'}, what was decided
|
||||||
- Use past tense for completed actions
|
- Use past tense for completed actions
|
||||||
|
|
||||||
Write the conclusion now (remember: max ${maxLength} characters):`;
|
Write the conclusion now (remember: max ${maxLength} characters):`;
|
||||||
|
|||||||
@ -140,15 +140,21 @@ export class ApprovalService {
|
|||||||
requestDescription: (wf as any).description,
|
requestDescription: (wf as any).description,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
priority: (wf as any).priority,
|
priority: (wf as any).priority,
|
||||||
approvalFlow: approvalLevels.map((l: any) => ({
|
approvalFlow: approvalLevels.map((l: any) => {
|
||||||
|
const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null
|
||||||
|
? Number(l.tatPercentageUsed)
|
||||||
|
: (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0);
|
||||||
|
return {
|
||||||
levelNumber: l.levelNumber,
|
levelNumber: l.levelNumber,
|
||||||
approverName: l.approverName,
|
approverName: l.approverName,
|
||||||
status: l.status,
|
status: l.status,
|
||||||
comments: l.comments,
|
comments: l.comments,
|
||||||
actionDate: l.actionDate,
|
actionDate: l.actionDate,
|
||||||
tatHours: Number(l.tatHours || 0),
|
tatHours: Number(l.tatHours || 0),
|
||||||
elapsedHours: Number(l.elapsedHours || 0)
|
elapsedHours: Number(l.elapsedHours || 0),
|
||||||
})),
|
tatPercentageUsed: tatPercentage
|
||||||
|
};
|
||||||
|
}),
|
||||||
workNotes: workNotes.map((note: any) => ({
|
workNotes: workNotes.map((note: any) => ({
|
||||||
userName: note.userName,
|
userName: note.userName,
|
||||||
message: note.message,
|
message: note.message,
|
||||||
@ -363,11 +369,11 @@ export class ApprovalService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (action.action === 'REJECT') {
|
} else if (action.action === 'REJECT') {
|
||||||
// Rejection - close workflow and mark all remaining levels as skipped
|
// Rejection - mark workflow as REJECTED (closure will happen when initiator finalizes conclusion)
|
||||||
await WorkflowRequest.update(
|
await WorkflowRequest.update(
|
||||||
{
|
{
|
||||||
status: WorkflowStatus.REJECTED,
|
status: WorkflowStatus.REJECTED
|
||||||
closureDate: now
|
// Note: closureDate will be set when initiator finalizes the conclusion
|
||||||
},
|
},
|
||||||
{ where: { requestId: level.requestId } }
|
{ where: { requestId: level.requestId } }
|
||||||
);
|
);
|
||||||
@ -387,7 +393,22 @@ export class ApprovalService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`Level ${level.levelNumber} rejected. Workflow ${level.requestId} closed as REJECTED`);
|
logger.info(`Level ${level.levelNumber} rejected. Workflow ${level.requestId} marked as REJECTED. Awaiting closure from initiator.`);
|
||||||
|
|
||||||
|
// Log rejection activity first (so it's included in AI context)
|
||||||
|
if (wf) {
|
||||||
|
activityService.log({
|
||||||
|
requestId: level.requestId,
|
||||||
|
type: 'rejection',
|
||||||
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
action: 'Rejected',
|
||||||
|
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}. Awaiting closure from initiator.`,
|
||||||
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||||
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Notify initiator and all participants
|
// Notify initiator and all participants
|
||||||
if (wf) {
|
if (wf) {
|
||||||
const participants = await Participant.findAll({ where: { requestId: level.requestId } });
|
const participants = await Participant.findAll({ where: { requestId: level.requestId } });
|
||||||
@ -402,17 +423,146 @@ export class ApprovalService {
|
|||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
url: `/request/${(wf as any).requestNumber}`
|
url: `/request/${(wf as any).requestNumber}`
|
||||||
});
|
});
|
||||||
activityService.log({
|
|
||||||
requestId: level.requestId,
|
|
||||||
type: 'rejection',
|
|
||||||
user: { userId: level.approverId, name: level.approverName },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
action: 'Rejected',
|
|
||||||
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
|
|
||||||
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
||||||
userAgent: requestMetadata?.userAgent || undefined
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved)
|
||||||
|
// This runs in the background without blocking the rejection response
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { aiService } = await import('./ai.service');
|
||||||
|
const { ConclusionRemark } = await import('@models/index');
|
||||||
|
const { ApprovalLevel } = await import('@models/ApprovalLevel');
|
||||||
|
const { WorkNote } = await import('@models/WorkNote');
|
||||||
|
const { Document } = await import('@models/Document');
|
||||||
|
const { Activity } = await import('@models/Activity');
|
||||||
|
const { getConfigValue } = await import('./configReader.service');
|
||||||
|
|
||||||
|
// Check if AI features and remark generation are enabled in admin config
|
||||||
|
const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true';
|
||||||
|
const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true';
|
||||||
|
|
||||||
|
if (!aiEnabled || !remarkGenerationEnabled) {
|
||||||
|
logger.info(`[Approval] AI conclusion generation skipped for rejected request ${level.requestId} (AI disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if AI service is available
|
||||||
|
const { aiService: aiSvc } = await import('./ai.service');
|
||||||
|
if (!aiSvc.isAvailable()) {
|
||||||
|
logger.warn(`[Approval] AI service unavailable for rejected request ${level.requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather context for AI generation (similar to approved flow)
|
||||||
|
const approvalLevels = await ApprovalLevel.findAll({
|
||||||
|
where: { requestId: level.requestId },
|
||||||
|
order: [['levelNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const workNotes = await WorkNote.findAll({
|
||||||
|
where: { requestId: level.requestId },
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = await Document.findAll({
|
||||||
|
where: { requestId: level.requestId },
|
||||||
|
order: [['uploadedAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const activities = await Activity.findAll({
|
||||||
|
where: { requestId: level.requestId },
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build context object (include rejection reason)
|
||||||
|
const context = {
|
||||||
|
requestTitle: (wf as any).title,
|
||||||
|
requestDescription: (wf as any).description,
|
||||||
|
requestNumber: (wf as any).requestNumber,
|
||||||
|
priority: (wf as any).priority,
|
||||||
|
rejectionReason: action.rejectionReason || action.comments || 'No reason provided',
|
||||||
|
rejectedBy: level.approverName || level.approverEmail,
|
||||||
|
approvalFlow: approvalLevels.map((l: any) => {
|
||||||
|
const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null
|
||||||
|
? Number(l.tatPercentageUsed)
|
||||||
|
: (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0);
|
||||||
|
return {
|
||||||
|
levelNumber: l.levelNumber,
|
||||||
|
approverName: l.approverName,
|
||||||
|
status: l.status,
|
||||||
|
comments: l.comments,
|
||||||
|
actionDate: l.actionDate,
|
||||||
|
tatHours: Number(l.tatHours || 0),
|
||||||
|
elapsedHours: Number(l.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(`[Approval] Generating AI conclusion for rejected request ${level.requestId}...`);
|
||||||
|
|
||||||
|
// Generate AI conclusion (will adapt to rejection context)
|
||||||
|
const aiResult = await aiSvc.generateConclusionRemark(context);
|
||||||
|
|
||||||
|
// Create or update conclusion remark
|
||||||
|
let conclusionInstance = await ConclusionRemark.findOne({ where: { requestId: level.requestId } });
|
||||||
|
|
||||||
|
const conclusionData = {
|
||||||
|
aiGeneratedRemark: aiResult.remark,
|
||||||
|
aiModelUsed: aiResult.provider,
|
||||||
|
aiConfidenceScore: aiResult.confidence,
|
||||||
|
approvalSummary: {
|
||||||
|
totalLevels: approvalLevels.length,
|
||||||
|
rejectedLevel: level.levelNumber,
|
||||||
|
rejectedBy: level.approverName || level.approverEmail,
|
||||||
|
rejectionReason: action.rejectionReason || action.comments
|
||||||
|
},
|
||||||
|
documentSummary: {
|
||||||
|
totalDocuments: documents.length,
|
||||||
|
documentNames: documents.map((d: any) => d.originalFileName || d.fileName)
|
||||||
|
},
|
||||||
|
keyDiscussionPoints: aiResult.keyPoints,
|
||||||
|
generatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (conclusionInstance) {
|
||||||
|
await conclusionInstance.update(conclusionData as any);
|
||||||
|
logger.info(`[Approval] ✅ AI conclusion updated for rejected request ${level.requestId}`);
|
||||||
|
} else {
|
||||||
|
await ConclusionRemark.create({
|
||||||
|
requestId: level.requestId,
|
||||||
|
...conclusionData,
|
||||||
|
finalRemark: null,
|
||||||
|
editedBy: null,
|
||||||
|
isEdited: false,
|
||||||
|
editCount: 0,
|
||||||
|
finalizedAt: null
|
||||||
|
} as any);
|
||||||
|
logger.info(`[Approval] ✅ AI conclusion generated for rejected request ${level.requestId}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[Approval] Failed to generate AI conclusion for rejected request ${level.requestId}:`, error);
|
||||||
|
// Don't fail the rejection if AI generation fails
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`);
|
logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`);
|
||||||
|
|||||||
@ -57,6 +57,39 @@ class NotificationService {
|
|||||||
logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`);
|
logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove expired/invalid subscription from database and memory cache
|
||||||
|
*/
|
||||||
|
private async removeExpiredSubscription(userId: string, endpoint: string) {
|
||||||
|
try {
|
||||||
|
// Remove from database
|
||||||
|
await Subscription.destroy({ where: { endpoint } });
|
||||||
|
logger.info(`[Notification] Removed expired subscription from DB for user ${userId}, endpoint: ${endpoint.substring(0, 50)}...`);
|
||||||
|
|
||||||
|
// Remove from memory cache
|
||||||
|
const list = this.userIdToSubscriptions.get(userId) || [];
|
||||||
|
const filtered = list.filter((s) => s.endpoint !== endpoint);
|
||||||
|
if (filtered.length !== list.length) {
|
||||||
|
this.userIdToSubscriptions.set(userId, filtered);
|
||||||
|
logger.info(`[Notification] Removed expired subscription from memory cache for user ${userId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Notification] Failed to remove expired subscription for user ${userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error indicates expired/invalid subscription
|
||||||
|
* webpush returns status codes: 410 (Gone), 404 (Not Found), 403 (Forbidden)
|
||||||
|
*/
|
||||||
|
private isExpiredSubscriptionError(err: any): boolean {
|
||||||
|
const statusCode = err?.statusCode || err?.status || err?.response?.statusCode;
|
||||||
|
// 410 Gone = subscription expired
|
||||||
|
// 404 Not Found = subscription doesn't exist
|
||||||
|
// 403 Forbidden = subscription invalid
|
||||||
|
return statusCode === 410 || statusCode === 404 || statusCode === 403;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification to users - saves to DB and sends via push/socket
|
* Send notification to users - saves to DB and sends via push/socket
|
||||||
*/
|
*/
|
||||||
@ -119,8 +152,14 @@ class NotificationService {
|
|||||||
await webpush.sendNotification(sub, message);
|
await webpush.sendNotification(sub, message);
|
||||||
await notification.update({ pushSent: true });
|
await notification.update({ pushSent: true });
|
||||||
logger.info(`[Notification] Push sent to user ${userId}`);
|
logger.info(`[Notification] Push sent to user ${userId}`);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
logger.error(`Failed to send push to ${userId}:`, err);
|
// Check if subscription is expired/invalid
|
||||||
|
if (this.isExpiredSubscriptionError(err)) {
|
||||||
|
logger.warn(`[Notification] Expired subscription detected for user ${userId}, removing...`);
|
||||||
|
await this.removeExpiredSubscription(userId, sub.endpoint);
|
||||||
|
} else {
|
||||||
|
logger.error(`[Notification] Failed to send push to user ${userId}:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,13 +33,18 @@ export class SummaryService {
|
|||||||
throw new Error('Only the initiator can create a summary for this request');
|
throw new Error('Only the initiator can create a summary for this request');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if summary already exists
|
// Check if summary already exists - return it if it does (idempotent behavior)
|
||||||
const existingSummary = await RequestSummary.findOne({
|
const existingSummary = await RequestSummary.findOne({
|
||||||
where: { requestId }
|
where: { requestId }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSummary) {
|
if (existingSummary) {
|
||||||
throw new Error('Summary already exists for this request');
|
// Verify the existing summary belongs to the current initiator
|
||||||
|
if ((existingSummary as any).initiatorId !== initiatorId) {
|
||||||
|
throw new Error('Only the initiator can create a summary for this request');
|
||||||
|
}
|
||||||
|
logger.info(`Summary already exists for request ${requestId}, returning existing summary`);
|
||||||
|
return existingSummary as RequestSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get conclusion remarks
|
// Get conclusion remarks
|
||||||
@ -243,6 +248,40 @@ export class SummaryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary by requestId (without creating it)
|
||||||
|
* Returns null if summary doesn't exist
|
||||||
|
*/
|
||||||
|
async getSummaryByRequestId(requestId: string, userId: string): Promise<RequestSummary | null> {
|
||||||
|
try {
|
||||||
|
const summary = await RequestSummary.findOne({
|
||||||
|
where: { requestId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access: user must be initiator or have been shared with
|
||||||
|
const isInitiator = (summary as any).initiatorId === userId;
|
||||||
|
const isShared = await SharedSummary.findOne({
|
||||||
|
where: {
|
||||||
|
summaryId: (summary as any).summaryId,
|
||||||
|
sharedWith: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isInitiator && !isShared) {
|
||||||
|
return null; // No access, return null instead of throwing error
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary as RequestSummary;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Summary] Failed to get summary by requestId ${requestId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get summary details with all approver information
|
* Get summary details with all approver information
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1432,6 +1432,7 @@ export class WorkflowService {
|
|||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
|
slaCompliance?: string;
|
||||||
dateRange?: string;
|
dateRange?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
@ -1534,6 +1535,66 @@ export class WorkflowService {
|
|||||||
|
|
||||||
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
||||||
|
|
||||||
|
// If SLA compliance filter is active, fetch all, enrich, filter, then paginate
|
||||||
|
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
||||||
|
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
||||||
|
where,
|
||||||
|
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
include: [
|
||||||
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedData = await this.enrichForCards(allRows);
|
||||||
|
|
||||||
|
// Filter by SLA compliance
|
||||||
|
const slaFilteredData = enrichedData.filter((req: any) => {
|
||||||
|
const slaCompliance = filters.slaCompliance || '';
|
||||||
|
const slaStatus = req.currentLevelSLA?.status ||
|
||||||
|
req.currentApprover?.sla?.status ||
|
||||||
|
req.sla?.status ||
|
||||||
|
req.summary?.sla?.status;
|
||||||
|
|
||||||
|
if (slaCompliance.toLowerCase() === 'compliant') {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
||||||
|
if (!isCompleted) return false;
|
||||||
|
if (!slaStatus) return true;
|
||||||
|
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slaStatus) {
|
||||||
|
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
'on-track': 'on_track',
|
||||||
|
'on_track': 'on_track',
|
||||||
|
'approaching': 'approaching',
|
||||||
|
'critical': 'critical',
|
||||||
|
'breached': 'breached'
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase();
|
||||||
|
return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalFiltered = slaFilteredData.length;
|
||||||
|
const paginatedData = slaFilteredData.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: paginatedData,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalFiltered,
|
||||||
|
totalPages: Math.ceil(totalFiltered / limit) || 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal pagination (no SLA filter)
|
||||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
offset,
|
offset,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user