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' });
|
||||
}
|
||||
|
||||
// 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 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
|
||||
@ -99,15 +99,21 @@ export class ConclusionController {
|
||||
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)
|
||||
})),
|
||||
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,
|
||||
@ -294,9 +300,9 @@ export class ConclusionController {
|
||||
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' });
|
||||
// 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
|
||||
@ -341,6 +347,19 @@ export class ConclusionController {
|
||||
|
||||
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
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
await activityService.log({
|
||||
@ -361,7 +380,8 @@ export class ConclusionController {
|
||||
requestNumber: (request as any).requestNumber,
|
||||
status: 'CLOSED',
|
||||
finalRemark: finalRemark,
|
||||
finalizedAt: (conclusion as any).finalizedAt
|
||||
finalizedAt: (conclusion as any).finalizedAt,
|
||||
summaryId: summaryId // Include summaryId in response
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@ -130,6 +130,32 @@ export class SummaryController {
|
||||
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();
|
||||
|
||||
@ -273,11 +273,12 @@ export class WorkflowController {
|
||||
const status = req.query.status as string | undefined;
|
||||
const priority = req.query.priority 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 startDate = req.query.startDate 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);
|
||||
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
||||
|
||||
@ -32,6 +32,12 @@ router.get(
|
||||
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)
|
||||
router.post(
|
||||
'/:summaryId/share',
|
||||
|
||||
@ -422,7 +422,9 @@ class AIService {
|
||||
approvalFlow,
|
||||
workNotes,
|
||||
documents,
|
||||
activities
|
||||
activities,
|
||||
rejectionReason,
|
||||
rejectedBy
|
||||
} = context;
|
||||
|
||||
// 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`);
|
||||
|
||||
// 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
|
||||
.filter((a: any) => a.status === 'APPROVED' || a.status === 'REJECTED')
|
||||
.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
|
||||
? ` (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');
|
||||
|
||||
@ -455,6 +473,11 @@ class AIService {
|
||||
.map((d: any) => `- ${d.fileName} (by ${d.uploadedBy})`)
|
||||
.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.
|
||||
|
||||
**Request:**
|
||||
@ -463,7 +486,7 @@ Description: ${requestDescription}
|
||||
Priority: ${priority}
|
||||
|
||||
**What Happened:**
|
||||
${approvalSummary || 'No approvals recorded'}
|
||||
${approvalSummary || 'No approvals recorded'}${rejectionContext}
|
||||
|
||||
**Discussions (if any):**
|
||||
${workNoteSummary || 'No work notes'}
|
||||
@ -473,12 +496,22 @@ ${documentSummary || 'No documents'}
|
||||
|
||||
**YOUR TASK:**
|
||||
Write a brief, professional conclusion (approximately ${targetWordCount} words, max ${maxLength} characters) that:
|
||||
- Summarizes what was requested and the final decision
|
||||
- Mentions who approved it and any key comments
|
||||
- Notes the outcome and next steps (if applicable)
|
||||
${isRejected
|
||||
? `- Summarizes what was requested and explains that it was rejected
|
||||
- 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
|
||||
- Is suitable for permanent archiving and future reference
|
||||
- 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:**
|
||||
- Be concise and direct
|
||||
@ -487,7 +520,7 @@ Write a brief, professional conclusion (approximately ${targetWordCount} words,
|
||||
- No corporate jargon or buzzwords
|
||||
- No emojis or excessive formatting
|
||||
- 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
|
||||
|
||||
Write the conclusion now (remember: max ${maxLength} characters):`;
|
||||
|
||||
@ -140,15 +140,21 @@ export class ApprovalService {
|
||||
requestDescription: (wf as any).description,
|
||||
requestNumber: (wf as any).requestNumber,
|
||||
priority: (wf as any).priority,
|
||||
approvalFlow: approvalLevels.map((l: any) => ({
|
||||
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)
|
||||
})),
|
||||
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,
|
||||
@ -363,11 +369,11 @@ export class ApprovalService {
|
||||
}
|
||||
}
|
||||
} 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(
|
||||
{
|
||||
status: WorkflowStatus.REJECTED,
|
||||
closureDate: now
|
||||
status: WorkflowStatus.REJECTED
|
||||
// Note: closureDate will be set when initiator finalizes the conclusion
|
||||
},
|
||||
{ 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
|
||||
if (wf) {
|
||||
const participants = await Participant.findAll({ where: { requestId: level.requestId } });
|
||||
@ -402,17 +423,146 @@ export class ApprovalService {
|
||||
requestNumber: (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`);
|
||||
|
||||
@ -57,6 +57,39 @@ class NotificationService {
|
||||
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
|
||||
*/
|
||||
@ -119,8 +152,14 @@ class NotificationService {
|
||||
await webpush.sendNotification(sub, message);
|
||||
await notification.update({ pushSent: true });
|
||||
logger.info(`[Notification] Push sent to user ${userId}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to send push to ${userId}:`, err);
|
||||
} catch (err: any) {
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Check if summary already exists
|
||||
// Check if summary already exists - return it if it does (idempotent behavior)
|
||||
const existingSummary = await RequestSummary.findOne({
|
||||
where: { requestId }
|
||||
});
|
||||
|
||||
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
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -1432,6 +1432,7 @@ export class WorkflowService {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
department?: string;
|
||||
slaCompliance?: string;
|
||||
dateRange?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
@ -1534,6 +1535,66 @@ export class WorkflowService {
|
||||
|
||||
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({
|
||||
where,
|
||||
offset,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user