Re_Backend/src/services/summary.service.ts

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