summary tab added for closed request and for rejected request cclosure flow added

This commit is contained in:
laxmanhalaki 2025-11-25 10:38:09 +05:30
parent eeeec90c89
commit 262b06453d
9 changed files with 428 additions and 53 deletions

View File

@ -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) => {
levelNumber: level.levelNumber, const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null
approverName: level.approverName, ? Number(level.tatPercentageUsed)
status: level.status, : (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0);
comments: level.comments, return {
actionDate: level.actionDate, levelNumber: level.levelNumber,
tatHours: Number(level.tatHours || 0), approverName: level.approverName,
elapsedHours: Number(level.elapsedHours || 0) 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) => ({ 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) {

View File

@ -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();

View File

@ -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');

View File

@ -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',

View File

@ -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):`;

View File

@ -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) => {
levelNumber: l.levelNumber, const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null
approverName: l.approverName, ? Number(l.tatPercentageUsed)
status: l.status, : (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0);
comments: l.comments, return {
actionDate: l.actionDate, levelNumber: l.levelNumber,
tatHours: Number(l.tatHours || 0), approverName: l.approverName,
elapsedHours: Number(l.elapsedHours || 0) 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) => ({ 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`);

View File

@ -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);
}
} }
} }
} }

View File

@ -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
*/ */

View File

@ -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,