Re_Backend/src/services/summary.service.ts

768 lines
26 KiB
TypeScript

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<RequestSummary> {
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<any> {
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<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
*/
async getSummaryDetails(summaryId: string, userId: string): Promise<any> {
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<SharedSummary[]> {
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<any> {
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<void> {
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<any> {
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<string, string> = {
'APPROVED': 'Approved',
'REJECTED': 'Rejected',
'PENDING': 'Pending',
'IN_PROGRESS': 'In Progress',
'SKIPPED': 'Skipped'
};
return statusMap[status.toUpperCase()] || status;
}
}
export const summaryService = new SummaryService();