import { RequestSummary, SharedSummary, WorkflowRequest, ApprovalLevel, User, ConclusionRemark } from '@models/index'; import '@models/index'; // Ensure associations are loaded import { Op } from 'sequelize'; import logger from '@utils/logger'; import dayjs from 'dayjs'; export class SummaryService { /** * Create a summary for a closed request * Pulls data from workflow_requests, approval_levels, and conclusion_remarks */ async createSummary(requestId: string, initiatorId: string): Promise { try { // Check if request exists and is closed const workflow = await WorkflowRequest.findByPk(requestId, { include: [ { association: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] } ] }); if (!workflow) { throw new Error('Workflow request not found'); } // Verify request is closed const status = (workflow as any).status?.toUpperCase(); if (status !== 'APPROVED' && status !== 'REJECTED' && status !== 'CLOSED') { throw new Error('Request must be closed (APPROVED, REJECTED, or CLOSED) before creating summary'); } // Verify initiator owns the request if ((workflow as any).initiatorId !== initiatorId) { throw new Error('Only the initiator can create a summary for this request'); } // Check if summary already exists - return it if it does (idempotent behavior) const existingSummary = await RequestSummary.findOne({ where: { requestId } }); if (existingSummary) { // 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 const conclusion = await ConclusionRemark.findOne({ where: { requestId } }); // Get all approval levels ordered by level number const approvalLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']], include: [ { model: User, as: 'approver', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] } ] }); // Determine closing remarks let closingRemarks: string | null = null; let isAiGenerated = false; let conclusionId: string | null = null; if (conclusion) { conclusionId = (conclusion as any).conclusionId; // Use final remark if edited, otherwise use AI-generated closingRemarks = (conclusion as any).finalRemark || (conclusion as any).aiGeneratedRemark || null; isAiGenerated = !(conclusion as any).isEdited && !!(conclusion as any).aiGeneratedRemark; } else { // Fallback to workflow's conclusion remark if no conclusion_remarks record closingRemarks = (workflow as any).conclusionRemark || null; isAiGenerated = false; } // Create summary const summary = await RequestSummary.create({ requestId, initiatorId, title: (workflow as any).title || '', description: (workflow as any).description || null, closingRemarks, isAiGenerated, conclusionId }); logger.info(`[Summary] Created summary ${(summary as any).summaryId} for request ${requestId}`); return summary; } catch (error) { logger.error(`[Summary] Failed to create summary for request ${requestId}:`, error); throw error; } } /** * Get summary details by sharedSummaryId (for recipients) */ async getSummaryDetailsBySharedId(sharedSummaryId: string, userId: string): Promise { try { const shared = await SharedSummary.findByPk(sharedSummaryId, { include: [ { model: RequestSummary, as: 'summary', include: [ { model: WorkflowRequest, as: 'request', include: [ { model: User, as: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] } ] }, { model: User, as: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] }, { model: ConclusionRemark, attributes: ['conclusionId', 'aiGeneratedRemark', 'finalRemark', 'isEdited', 'generatedAt', 'finalizedAt'] } ] } ] }); if (!shared) { throw new Error('Shared summary not found'); } // Verify access if ((shared as any).sharedWith !== userId) { throw new Error('Access denied: You do not have permission to view this summary'); } const summary = (shared as any).summary; if (!summary) { throw new Error('Associated summary not found'); } const request = (summary as any).request; if (!request) { throw new Error('Associated workflow request not found'); } // Mark as viewed await shared.update({ viewedAt: new Date(), isRead: true }); // Get all approval levels with approver details const approvalLevels = await ApprovalLevel.findAll({ where: { requestId: (request as any).requestId }, order: [['levelNumber', 'ASC']], include: [ { model: User, as: 'approver', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] } ] }); // Format approver data for summary const approvers = approvalLevels.map((level: any) => { const approver = (level as any).approver || {}; const status = (level.status || '').toString().toUpperCase(); // Determine remarks based on status let remarks: string | null = null; if (status === 'APPROVED') { remarks = level.comments || null; } else if (status === 'REJECTED') { remarks = level.rejectionReason || level.comments || null; } else if (status === 'SKIPPED') { remarks = (level as any).skipReason || 'Skipped' || null; } // Determine timestamp let timestamp: Date | null = null; if (level.actionDate) { timestamp = level.actionDate; } else if (level.levelStartTime) { timestamp = level.levelStartTime; } else { timestamp = level.createdAt; } return { levelNumber: level.levelNumber, levelName: level.levelName || `Approver ${level.levelNumber}`, name: approver.displayName || level.approverName || 'Unknown', designation: approver.designation || 'N/A', department: approver.department || null, email: approver.email || level.approverEmail || 'N/A', status: this.formatStatus(status), timestamp: timestamp, remarks: remarks || '—' }; }); // Format initiator data const initiator = (request as any).initiator || {}; const initiatorTimestamp = (request as any).submissionDate || (request as any).createdAt; return { summaryId: (summary as any).summaryId, requestId: (request as any).requestId, requestNumber: (request as any).requestNumber || 'N/A', title: (summary as any).title || (request as any).title || '', description: (summary as any).description || (request as any).description || '', closingRemarks: (summary as any).closingRemarks || '—', isAiGenerated: (summary as any).isAiGenerated || false, createdAt: (summary as any).createdAt, initiator: { name: initiator.displayName || 'Unknown', designation: initiator.designation || 'N/A', department: initiator.department || null, email: initiator.email || 'N/A', status: 'Initiated', timestamp: initiatorTimestamp, remarks: '—' }, approvers: approvers, workflow: { priority: (request as any).priority || 'STANDARD', status: (request as any).status || 'CLOSED', submissionDate: (request as any).submissionDate, closureDate: (request as any).closureDate } }; } catch (error) { logger.error(`[Summary] Failed to get summary details by shared ID ${sharedSummaryId}:`, error); throw error; } } /** * Get summary by requestId (without creating it) * Returns null if summary doesn't exist */ async getSummaryByRequestId(requestId: string, userId: string): Promise { 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 */ async getSummaryDetails(summaryId: string, userId: string): Promise { try { const summary = await RequestSummary.findByPk(summaryId, { include: [ { model: WorkflowRequest, as: 'request', include: [ { model: User, as: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] } ] }, { model: User, as: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] }, { model: ConclusionRemark, attributes: ['conclusionId', 'aiGeneratedRemark', 'finalRemark', 'isEdited', 'generatedAt', 'finalizedAt'] } ] }); if (!summary) { throw new Error('Summary not found'); } const request = (summary as any).request; if (!request) { throw new Error('Associated workflow request not found'); } // 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, sharedWith: userId } }); if (!isInitiator && !isShared) { throw new Error('Access denied: You do not have permission to view this summary'); } // Get all approval levels with approver details const approvalLevels = await ApprovalLevel.findAll({ where: { requestId: (request as any).requestId }, order: [['levelNumber', 'ASC']], include: [ { model: User, as: 'approver', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] } ] }); // Format approver data for summary const approvers = approvalLevels.map((level: any) => { const approver = (level as any).approver || {}; const status = (level.status || '').toString().toUpperCase(); // Determine remarks based on status let remarks: string | null = null; if (status === 'APPROVED') { remarks = level.comments || null; } else if (status === 'REJECTED') { remarks = level.rejectionReason || level.comments || null; } else if (status === 'SKIPPED') { remarks = (level as any).skipReason || 'Skipped' || null; } // Determine timestamp let timestamp: Date | null = null; if (level.actionDate) { timestamp = level.actionDate; } else if (level.levelStartTime) { timestamp = level.levelStartTime; } else { timestamp = level.createdAt; } return { levelNumber: level.levelNumber, levelName: level.levelName || `Approver ${level.levelNumber}`, name: approver.displayName || level.approverName || 'Unknown', designation: approver.designation || 'N/A', department: approver.department || null, email: approver.email || level.approverEmail || 'N/A', status: this.formatStatus(status), timestamp: timestamp, remarks: remarks || '—' }; }); // Format initiator data const initiator = (request as any).initiator || {}; const initiatorTimestamp = (request as any).submissionDate || (request as any).createdAt; return { summaryId: (summary as any).summaryId, requestId: (request as any).requestId, requestNumber: (request as any).requestNumber || 'N/A', title: (summary as any).title || (request as any).title || '', description: (summary as any).description || (request as any).description || '', closingRemarks: (summary as any).closingRemarks || '—', isAiGenerated: (summary as any).isAiGenerated || false, createdAt: (summary as any).createdAt, initiator: { name: initiator.displayName || 'Unknown', designation: initiator.designation || 'N/A', department: initiator.department || null, email: initiator.email || 'N/A', status: 'Initiated', timestamp: initiatorTimestamp, remarks: '—' }, approvers: approvers, workflow: { priority: (request as any).priority || 'STANDARD', status: (request as any).status || 'CLOSED', submissionDate: (request as any).submissionDate, closureDate: (request as any).closureDate } }; } catch (error) { logger.error(`[Summary] Failed to get summary details for ${summaryId}:`, error); throw error; } } /** * Share summary with users * userIds can be either Okta IDs or internal UUIDs - we'll convert them to internal UUIDs */ async shareSummary(summaryId: string, sharedBy: string, userIds: string[]): Promise { try { // Verify summary exists and user is the initiator const summary = await RequestSummary.findByPk(summaryId); if (!summary) { throw new Error('Summary not found'); } if ((summary as any).initiatorId !== sharedBy) { throw new Error('Only the initiator can share this summary'); } // Remove duplicates const uniqueUserIds = Array.from(new Set(userIds)); // Convert Okta IDs to internal UUIDs // The frontend may send Okta user IDs, but we need internal UUIDs for the database const { UserService } = await import('@services/user.service'); const userService = new UserService(); const internalUserIds: string[] = []; for (const userIdOrOktaId of uniqueUserIds) { // Check if it's already a UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userIdOrOktaId); if (isUUID) { // Already a UUID, verify user exists const user = await User.findByPk(userIdOrOktaId); if (!user) { logger.warn(`[Summary] User with UUID ${userIdOrOktaId} not found, skipping`); continue; } internalUserIds.push(userIdOrOktaId); } else { // Likely an Okta ID, find user by oktaSub let user = await User.findOne({ where: { oktaSub: userIdOrOktaId } }); if (!user) { // User doesn't exist in database, try to fetch from Okta and create them // The userIdOrOktaId is the Okta ID, we need to fetch user info directly from Okta try { // First, try to fetch user directly from Okta by ID const oktaUser = await userService.fetchUserFromOktaById(userIdOrOktaId); if (oktaUser && oktaUser.status === 'ACTIVE') { // Ensure user exists in database const ensuredUser = await userService.ensureUserExists({ userId: oktaUser.id, email: oktaUser.profile.email || oktaUser.profile.login, displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim(), firstName: oktaUser.profile.firstName, lastName: oktaUser.profile.lastName, department: oktaUser.profile.department, phone: oktaUser.profile.mobilePhone }); internalUserIds.push(ensuredUser.userId); logger.info(`[Summary] Created user ${ensuredUser.userId} from Okta ID ${userIdOrOktaId} for sharing`); } else { // Try to find by email if userIdOrOktaId looks like an email if (userIdOrOktaId.includes('@')) { user = await User.findOne({ where: { email: userIdOrOktaId } }); if (user) { internalUserIds.push(user.userId); } else { logger.warn(`[Summary] User with email ${userIdOrOktaId} not found in database or Okta, skipping`); } } else { logger.warn(`[Summary] User with Okta ID ${userIdOrOktaId} not found in Okta or is inactive, skipping`); } } } catch (oktaError: any) { logger.error(`[Summary] Failed to fetch user from Okta for ${userIdOrOktaId}:`, oktaError); // Try to find by email if userIdOrOktaId looks like an email if (userIdOrOktaId.includes('@')) { user = await User.findOne({ where: { email: userIdOrOktaId } }); if (user) { internalUserIds.push(user.userId); } else { logger.warn(`[Summary] User with email ${userIdOrOktaId} not found, skipping`); } } else { logger.warn(`[Summary] User with ID ${userIdOrOktaId} not found, skipping`); } } } else { internalUserIds.push(user.userId); } } } if (internalUserIds.length === 0) { throw new Error('No valid users found to share with'); } // Create shared summary records const sharedSummaries: SharedSummary[] = []; for (const internalUserId of internalUserIds) { // Skip if already shared with this user const existing = await SharedSummary.findOne({ where: { summaryId, sharedWith: internalUserId } }); if (!existing) { const shared = await SharedSummary.create({ summaryId, sharedBy, sharedWith: internalUserId, sharedAt: new Date(), isRead: false }); sharedSummaries.push(shared); } } logger.info(`[Summary] Shared summary ${summaryId} with ${sharedSummaries.length} users`); return sharedSummaries; } catch (error) { logger.error(`[Summary] Failed to share summary ${summaryId}:`, error); throw error; } } /** * List summaries shared with current user * userId can be either Okta ID or internal UUID - we'll convert to UUID */ async listSharedSummaries(userId: string, page: number = 1, limit: number = 20): Promise { try { // Convert Okta ID to internal UUID if needed let internalUserId = userId; // Check if it's already a UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId); if (!isUUID) { // Likely an Okta ID, find user by oktaSub const user = await User.findOne({ where: { oktaSub: userId } }); if (!user) { logger.warn(`[Summary] User with Okta ID ${userId} not found for listing shared summaries`); // Return empty result instead of error return { data: [], pagination: { page, limit, total: 0, totalPages: 0 } }; } internalUserId = user.userId; } else { // Verify UUID user exists const user = await User.findByPk(userId); if (!user) { logger.warn(`[Summary] User with UUID ${userId} not found for listing shared summaries`); return { data: [], pagination: { page, limit, total: 0, totalPages: 0 } }; } } const offset = (page - 1) * limit; const { rows, count } = await SharedSummary.findAndCountAll({ where: { sharedWith: internalUserId }, include: [ { model: RequestSummary, as: 'summary', include: [ { model: WorkflowRequest, as: 'request', attributes: ['requestId', 'requestNumber', 'title', 'status', 'closureDate'] }, { model: User, as: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation'] } ] }, { model: User, as: 'sharedByUser', attributes: ['userId', 'email', 'displayName', 'designation'] } ], order: [['sharedAt', 'DESC']], limit, offset }); const summaries = rows.map((shared: any) => { const summary = (shared as any).summary; const request = summary?.request; const initiator = summary?.initiator; const sharedBy = (shared as any).sharedByUser; return { sharedSummaryId: (shared as any).sharedSummaryId, summaryId: (shared as any).summaryId, requestId: request?.requestId, requestNumber: request?.requestNumber || 'N/A', title: summary?.title || request?.title || 'N/A', initiatorName: initiator?.displayName || 'Unknown', sharedByName: sharedBy?.displayName || 'Unknown', sharedAt: (shared as any).sharedAt, viewedAt: (shared as any).viewedAt, isRead: (shared as any).isRead, closureDate: request?.closureDate }; }); return { data: summaries, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } catch (error) { logger.error(`[Summary] Failed to list shared summaries for user ${userId}:`, error); throw error; } } /** * Mark shared summary as viewed */ async markAsViewed(sharedSummaryId: string, userId: string): Promise { try { const shared = await SharedSummary.findByPk(sharedSummaryId); if (!shared) { throw new Error('Shared summary not found'); } if ((shared as any).sharedWith !== userId) { throw new Error('Access denied'); } await shared.update({ viewedAt: new Date(), isRead: true }); logger.info(`[Summary] Marked shared summary ${sharedSummaryId} as viewed by user ${userId}`); } catch (error) { logger.error(`[Summary] Failed to mark shared summary as viewed:`, error); throw error; } } /** * List summaries created by user */ async listMySummaries(userId: string, page: number = 1, limit: number = 20): Promise { try { const offset = (page - 1) * limit; const { rows, count } = await RequestSummary.findAndCountAll({ where: { initiatorId: userId }, include: [ { model: WorkflowRequest, as: 'request', attributes: ['requestId', 'requestNumber', 'title', 'status', 'closureDate'] } ], order: [['createdAt', 'DESC']], limit, offset }); const summaries = rows.map((summary: any) => { const request = (summary as any).request; return { summaryId: (summary as any).summaryId, requestId: request?.requestId, requestNumber: request?.requestNumber || 'N/A', title: (summary as any).title || request?.title || 'N/A', createdAt: (summary as any).createdAt, closureDate: request?.closureDate }; }); return { data: summaries, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } catch (error) { logger.error(`[Summary] Failed to list summaries for user ${userId}:`, error); throw error; } } /** * Format status for display */ private formatStatus(status: string): string { const statusMap: Record = { 'APPROVED': 'Approved', 'REJECTED': 'Rejected', 'PENDING': 'Pending', 'IN_PROGRESS': 'In Progress', 'SKIPPED': 'Skipped' }; return statusMap[status.toUpperCase()] || status; } } export const summaryService = new SummaryService();