942 lines
34 KiB
TypeScript
942 lines
34 KiB
TypeScript
import { RequestSummary, SharedSummary, WorkflowRequest, ApprovalLevel, User, ConclusionRemark, Participant } 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
|
|
*
|
|
* Access Control:
|
|
* - 'system': Allows system-level auto-generation on final approval
|
|
* - initiator: The request initiator can create/regenerate
|
|
* - admin/management: Admin or management role users can create/regenerate via API
|
|
*
|
|
* @param requestId - The workflow request ID
|
|
* @param userId - The user ID requesting the summary (or 'system' for auto-generation)
|
|
* @param options - Optional parameters
|
|
* @param options.isSystemGeneration - Set to true for system-level auto-generation
|
|
* @param options.userRole - The role of the user (for admin access check)
|
|
* @param options.regenerate - Set to true to regenerate (delete existing and create new)
|
|
*/
|
|
async createSummary(
|
|
requestId: string,
|
|
userId: string,
|
|
options?: { isSystemGeneration?: boolean; userRole?: string; regenerate?: boolean }
|
|
): Promise<RequestSummary> {
|
|
try {
|
|
const { isSystemGeneration = false, userRole, regenerate = false } = options || {};
|
|
|
|
// 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 (APPROVED, REJECTED, or 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');
|
|
}
|
|
|
|
const initiatorId = (workflow as any).initiatorId;
|
|
const isInitiator = initiatorId === userId;
|
|
const isAdmin = userRole && ['admin', 'super_admin', 'management'].includes(userRole.toLowerCase());
|
|
|
|
// Access control: Allow system generation, initiator, or admin users
|
|
if (!isSystemGeneration && !isInitiator && !isAdmin) {
|
|
throw new Error('Only the initiator or admin users can create a summary for this request');
|
|
}
|
|
|
|
// Check if summary already exists
|
|
const existingSummary = await RequestSummary.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
if (existingSummary) {
|
|
// If regenerate is requested by initiator or admin, delete existing and create new
|
|
if (regenerate && (isInitiator || isAdmin)) {
|
|
logger.info(`[Summary] Regenerating summary for request ${requestId}`);
|
|
await existingSummary.destroy();
|
|
} else {
|
|
// Return existing summary (idempotent behavior)
|
|
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 - always use the actual initiator from the workflow
|
|
const summary = await RequestSummary.create({
|
|
requestId,
|
|
initiatorId: initiatorId, // Use workflow's initiator, not the requesting user
|
|
title: (workflow as any).title || '',
|
|
description: (workflow as any).description || null,
|
|
closingRemarks,
|
|
isAiGenerated,
|
|
conclusionId
|
|
});
|
|
|
|
const generationType = isSystemGeneration ? 'system' : (isAdmin ? 'admin' : 'initiator');
|
|
logger.info(`[Summary] Created summary ${(summary as any).summaryId} for request ${requestId} (generated by: ${generationType})`);
|
|
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;
|
|
|
|
// Get conclusion remark if available
|
|
let conclusionRemark = (summary as any).ConclusionRemark || (summary as any).conclusionRemark;
|
|
|
|
// If not loaded and we have conclusionId, fetch by conclusionId
|
|
if (!conclusionRemark && (summary as any).conclusionId) {
|
|
conclusionRemark = await ConclusionRemark.findByPk((summary as any).conclusionId);
|
|
}
|
|
|
|
// If still not found, fetch by requestId (summary may have been created before conclusion)
|
|
if (!conclusionRemark) {
|
|
conclusionRemark = await ConclusionRemark.findOne({
|
|
where: { requestId: (request as any).requestId }
|
|
});
|
|
}
|
|
|
|
// Determine effective final remark:
|
|
// - If user edited: use finalRemark
|
|
// - If user closed without editing: use aiGeneratedRemark (becomes final)
|
|
// - Otherwise: use closingRemarks from summary snapshot
|
|
const effectiveFinalRemark = conclusionRemark?.finalRemark ||
|
|
conclusionRemark?.aiGeneratedRemark ||
|
|
(summary as any).closingRemarks ||
|
|
'—';
|
|
|
|
logger.info(`[Summary] SharedSummary ${sharedSummaryId}: Effective final remark length: ${effectiveFinalRemark?.length || 0} chars (isEdited: ${conclusionRemark?.isEdited}, hasAI: ${!!conclusionRemark?.aiGeneratedRemark}, hasFinal: ${!!conclusionRemark?.finalRemark})`);
|
|
|
|
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: effectiveFinalRemark, // ✅ Effective final remark (edited or AI)
|
|
isAiGenerated: (summary as any).isAiGenerated || false,
|
|
createdAt: (summary as any).createdAt,
|
|
// Include conclusion remark data for detailed view
|
|
conclusionRemark: conclusionRemark ? {
|
|
aiGeneratedRemark: conclusionRemark.aiGeneratedRemark,
|
|
finalRemark: conclusionRemark.finalRemark,
|
|
effectiveFinalRemark: effectiveFinalRemark, // ✅ Computed field for convenience
|
|
isEdited: conclusionRemark.isEdited,
|
|
generatedAt: conclusionRemark.generatedAt,
|
|
finalizedAt: conclusionRemark.finalizedAt
|
|
} : null,
|
|
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,
|
|
conclusionRemark: effectiveFinalRemark // ✅ Use effective final remark
|
|
}
|
|
};
|
|
} 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: initiator, participants, management, or explicitly shared users
|
|
const isInitiator = (summary as any).initiatorId === userId;
|
|
|
|
// Check if user is a participant (approver or spectator)
|
|
const isParticipant = await Participant.findOne({
|
|
where: { requestId, userId }
|
|
});
|
|
|
|
// Check if user has management/admin role
|
|
const currentUser = await User.findByPk(userId);
|
|
const userRole = (currentUser as any)?.role?.toUpperCase();
|
|
const isManagement = userRole && ['ADMIN', 'SUPER_ADMIN', 'MANAGEMENT'].includes(userRole);
|
|
|
|
// Check if explicitly shared
|
|
const isShared = await SharedSummary.findOne({
|
|
where: {
|
|
summaryId: (summary as any).summaryId,
|
|
sharedWith: userId
|
|
}
|
|
});
|
|
|
|
if (!isInitiator && !isParticipant && !isManagement && !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: initiator, participants, management, or explicitly shared users
|
|
const isInitiator = (summary as any).initiatorId === userId;
|
|
|
|
// Check if user is a participant (approver or spectator) in the request
|
|
const isParticipant = await Participant.findOne({
|
|
where: {
|
|
requestId: (request as any).requestId,
|
|
userId
|
|
}
|
|
});
|
|
|
|
// Check if user has management/admin role
|
|
const currentUser = await User.findByPk(userId);
|
|
const userRole = (currentUser as any)?.role?.toUpperCase();
|
|
const isManagement = userRole && ['ADMIN', 'SUPER_ADMIN', 'MANAGEMENT'].includes(userRole);
|
|
|
|
// Check if explicitly shared
|
|
const isShared = await SharedSummary.findOne({
|
|
where: {
|
|
summaryId,
|
|
sharedWith: userId
|
|
}
|
|
});
|
|
|
|
if (!isInitiator && !isParticipant && !isManagement && !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;
|
|
|
|
// Get conclusion remark if available
|
|
let conclusionRemark = (summary as any).ConclusionRemark || (summary as any).conclusionRemark;
|
|
|
|
// If not loaded and we have conclusionId, fetch by conclusionId
|
|
if (!conclusionRemark && (summary as any).conclusionId) {
|
|
conclusionRemark = await ConclusionRemark.findByPk((summary as any).conclusionId);
|
|
}
|
|
|
|
// If still not found, fetch by requestId (summary may have been created before conclusion)
|
|
if (!conclusionRemark) {
|
|
conclusionRemark = await ConclusionRemark.findOne({
|
|
where: { requestId: (request as any).requestId }
|
|
});
|
|
}
|
|
|
|
// Determine effective final remark:
|
|
// - If user edited: use finalRemark
|
|
// - If user closed without editing: use aiGeneratedRemark (becomes final)
|
|
// - Otherwise: use closingRemarks from summary snapshot
|
|
const effectiveFinalRemark = conclusionRemark?.finalRemark ||
|
|
conclusionRemark?.aiGeneratedRemark ||
|
|
(summary as any).closingRemarks ||
|
|
'—';
|
|
|
|
logger.info(`[Summary] Summary ${summaryId}: Effective final remark length: ${effectiveFinalRemark?.length || 0} chars (isEdited: ${conclusionRemark?.isEdited}, hasAI: ${!!conclusionRemark?.aiGeneratedRemark}, hasFinal: ${!!conclusionRemark?.finalRemark})`);
|
|
|
|
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: effectiveFinalRemark, // ✅ Effective final remark (edited or AI)
|
|
isAiGenerated: (summary as any).isAiGenerated || false,
|
|
createdAt: (summary as any).createdAt,
|
|
// Include conclusion remark data for detailed view
|
|
conclusionRemark: conclusionRemark ? {
|
|
aiGeneratedRemark: conclusionRemark.aiGeneratedRemark,
|
|
finalRemark: conclusionRemark.finalRemark,
|
|
effectiveFinalRemark: effectiveFinalRemark, // ✅ Computed field: finalRemark || aiGeneratedRemark
|
|
isEdited: conclusionRemark.isEdited,
|
|
generatedAt: conclusionRemark.generatedAt,
|
|
finalizedAt: conclusionRemark.finalizedAt
|
|
} : null,
|
|
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,
|
|
conclusionRemark: effectiveFinalRemark // ✅ Use effective final remark
|
|
}
|
|
};
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of users who received shared summary for a specific summary
|
|
* Only accessible by the initiator
|
|
*/
|
|
async getSharedRecipients(summaryId: string, userId: string): Promise<any[]> {
|
|
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 !== userId) {
|
|
throw new Error('Only the initiator can view shared recipients');
|
|
}
|
|
|
|
// Get all shared summaries for this summary with user details
|
|
const sharedSummaries = await SharedSummary.findAll({
|
|
where: { summaryId },
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'sharedWithUser',
|
|
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
|
}
|
|
],
|
|
order: [['sharedAt', 'DESC']]
|
|
});
|
|
|
|
// Format the response
|
|
return sharedSummaries.map((shared: any) => {
|
|
const user = (shared as any).sharedWithUser || {};
|
|
return {
|
|
userId: user.userId || (shared as any).sharedWith,
|
|
email: user.email || 'N/A',
|
|
displayName: user.displayName || 'Unknown',
|
|
designation: user.designation || null,
|
|
department: user.department || null,
|
|
sharedAt: (shared as any).sharedAt,
|
|
viewedAt: (shared as any).viewedAt,
|
|
isRead: (shared as any).isRead || false
|
|
};
|
|
});
|
|
} catch (error) {
|
|
logger.error(`[Summary] Failed to get shared recipients for 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();
|
|
|