import { Request, Response } from 'express'; import { WorkflowService } from '@services/workflow.service'; import { validateCreateWorkflow, validateUpdateWorkflow } from '@validators/workflow.validator'; import { ResponseHandler } from '@utils/responseHandler'; import type { AuthenticatedRequest } from '../types/express'; import { Priority } from '../types/common.types'; import type { UpdateWorkflowRequest } from '../types/workflow.types'; import { Document } from '@models/Document'; import { User } from '@models/User'; import { gcsStorageService } from '@services/gcsStorage.service'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { getRequestMetadata } from '@utils/requestUtils'; import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service'; import logger from '@utils/logger'; const workflowService = new WorkflowService(); export class WorkflowController { async createWorkflow(req: AuthenticatedRequest, res: Response): Promise { try { const validatedData = validateCreateWorkflow(req.body); // Validate initiator exists await validateInitiator(req.user.userId); // Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility let approvalLevels = validatedData.approvalLevels || []; if (!approvalLevels.length && (req.body as any).approvers) { const approvers = (req.body as any).approvers || []; approvalLevels = approvers.map((a: any, index: number) => ({ levelNumber: index + 1, email: a.email || a.approverEmail, tatHours: a.tatType === 'days' ? (a.tat || 0) * 24 : (a.tat || a.tatHours || 24), isFinalApprover: index === approvers.length - 1, })); } // Normalize approval levels: map approverEmail -> email for backward compatibility const normalizedApprovalLevels = approvalLevels.map((level: any) => ({ ...level, email: level.email || level.approverEmail, // Support both formats })); // Enrich approval levels with user data (auto-lookup from AD if not in DB) logger.info(`[WorkflowController] Enriching ${normalizedApprovalLevels.length} approval levels`); const enrichedApprovalLevels = await enrichApprovalLevels(normalizedApprovalLevels as any); // Enrich spectators if provided // Normalize spectators: map userEmail -> email for backward compatibility // Filter participants to only include SPECTATOR type (exclude INITIATOR and APPROVER) const allParticipants = validatedData.spectators || validatedData.participants || []; const spectators = allParticipants.filter((p: any) => !p.participantType || p.participantType === 'SPECTATOR' ); const normalizedSpectators = spectators.map((spec: any) => ({ ...spec, email: spec.email || spec.userEmail, // Support both formats })).filter((spec: any) => spec.email); // Only include entries with email const enrichedSpectators = normalizedSpectators.length > 0 ? await enrichSpectators(normalizedSpectators as any) : []; // Build complete participants array automatically // This includes: INITIATOR + all APPROVERs + all SPECTATORs const initiator = await User.findByPk(req.user.userId); const initiatorEmail = (initiator as any).email; const initiatorName = (initiator as any).displayName || (initiator as any).email; const autoGeneratedParticipants = [ // Add initiator { userId: req.user.userId, userEmail: initiatorEmail, userName: initiatorName, participantType: 'INITIATOR' as const, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, }, // Add all approvers from approval levels ...enrichedApprovalLevels.map((level: any) => ({ userId: level.approverId, userEmail: level.approverEmail, userName: level.approverName, participantType: 'APPROVER' as const, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, })), // Add all spectators ...enrichedSpectators, ]; // Convert string literal priority to enum const workflowData = { ...validatedData, priority: validatedData.priority as Priority, approvalLevels: enrichedApprovalLevels, participants: autoGeneratedParticipants, }; const requestMeta = getRequestMetadata(req); const workflow = await workflowService.createWorkflow(req.user.userId, workflowData, { ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent }); ResponseHandler.success(res, workflow, 'Workflow created successfully', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('[WorkflowController] Failed to create workflow:', error); ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage); } } // Multipart create: accepts payload JSON and files[] async createWorkflowMultipart(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; if (!userId) { ResponseHandler.error(res, 'Unauthorized', 401); return; } const raw = String(req.body?.payload || ''); if (!raw) { ResponseHandler.error(res, 'payload is required', 400); return; } let parsed; try { parsed = JSON.parse(raw); } catch (parseError) { ResponseHandler.error(res, 'Invalid JSON in payload', 400, parseError instanceof Error ? parseError.message : 'JSON parse error'); return; } // Transform frontend format to backend format BEFORE validation // Map 'approvers' -> 'approvalLevels' for backward compatibility if (!parsed.approvalLevels && parsed.approvers) { const approvers = parsed.approvers || []; parsed.approvalLevels = approvers.map((a: any, index: number) => ({ levelNumber: index + 1, email: a.email || a.approverEmail, tatHours: a.tatType === 'days' ? (a.tat || 0) * 24 : (a.tat || a.tatHours || 24), isFinalApprover: index === approvers.length - 1, })); } let validated; try { validated = validateCreateWorkflow(parsed); } catch (validationError: any) { // Zod validation errors provide detailed information const errorMessage = validationError?.errors ? validationError.errors.map((e: any) => `${e.path.join('.')}: ${e.message}`).join('; ') : (validationError instanceof Error ? validationError.message : 'Validation failed'); logger.error(`[WorkflowController] Validation failed:`, errorMessage); ResponseHandler.error(res, 'Validation failed', 400, errorMessage); return; } // Validate initiator exists await validateInitiator(userId); // Use the approval levels from validation (already transformed above) let approvalLevels = validated.approvalLevels || []; // Normalize approval levels: map approverEmail -> email for backward compatibility const normalizedApprovalLevels = approvalLevels.map((level: any) => ({ ...level, email: level.email || level.approverEmail, // Support both formats })); // Enrich approval levels with user data (auto-lookup from AD if not in DB) logger.info(`[WorkflowController] Enriching ${normalizedApprovalLevels.length} approval levels`); const enrichedApprovalLevels = await enrichApprovalLevels(normalizedApprovalLevels as any); // Enrich spectators if provided // Normalize spectators: map userEmail -> email for backward compatibility // Filter participants to only include SPECTATOR type (exclude INITIATOR and APPROVER) const allParticipants = validated.spectators || validated.participants || []; const spectators = allParticipants.filter((p: any) => !p.participantType || p.participantType === 'SPECTATOR' ); const normalizedSpectators = spectators.map((spec: any) => ({ ...spec, email: spec.email || spec.userEmail, // Support both formats })).filter((spec: any) => spec.email); // Only include entries with email const enrichedSpectators = normalizedSpectators.length > 0 ? await enrichSpectators(normalizedSpectators as any) : []; // Build complete participants array automatically // This includes: INITIATOR + all APPROVERs + all SPECTATORs const initiator = await User.findByPk(userId); const initiatorEmail = (initiator as any).email; const initiatorName = (initiator as any).displayName || (initiator as any).email; const autoGeneratedParticipants = [ // Add initiator { userId: userId, userEmail: initiatorEmail, userName: initiatorName, participantType: 'INITIATOR' as const, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, }, // Add all approvers from approval levels ...enrichedApprovalLevels.map((level: any) => ({ userId: level.approverId, userEmail: level.approverEmail, userName: level.approverName, participantType: 'APPROVER' as const, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, })), // Add all spectators ...enrichedSpectators, ]; const workflowData = { ...validated, priority: validated.priority as Priority, approvalLevels: enrichedApprovalLevels, participants: autoGeneratedParticipants, } as any; const requestMeta = getRequestMetadata(req); const workflow = await workflowService.createWorkflow(userId, workflowData, { ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent }); // Attach files as documents (category defaults to SUPPORTING) const files = (req as any).files as Express.Multer.File[] | undefined; const category = (req.body?.category as string) || 'OTHER'; const docs: any[] = []; if (files && files.length > 0) { const { activityService } = require('../services/activity.service'); const user = await User.findByPk(userId); const uploaderName = (user as any)?.displayName || (user as any)?.email || 'User'; for (const file of files) { // Get file buffer - multer.memoryStorage provides buffer, not path const fileBuffer = (file as any).buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); // Upload with automatic fallback to local storage const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number; const uploadResult = await gcsStorageService.uploadFileWithFallback({ buffer: fileBuffer, originalName: file.originalname, mimeType: file.mimetype, requestNumber: requestNumber, fileType: 'documents' }); const storageUrl = uploadResult.storageUrl; const gcsFilePath = uploadResult.filePath; // Clean up local temporary file if it exists (from multer disk storage) if (file.path && fs.existsSync(file.path)) { try { fs.unlinkSync(file.path); } catch (unlinkError) { logger.warn('[Workflow] Failed to delete local temporary file:', unlinkError); } } const doc = await Document.create({ requestId: workflow.requestId, uploadedBy: userId, fileName: path.basename(file.filename || file.originalname), originalFileName: file.originalname, fileType: extension, fileExtension: extension, fileSize: file.size, filePath: gcsFilePath, // Store GCS path or local path storageUrl: storageUrl, // Store GCS URL or local URL mimeType: file.mimetype, checksum, isGoogleDoc: false, googleDocUrl: null as any, category: category || 'OTHER', version: 1, parentDocumentId: null as any, isDeleted: false, downloadCount: 0, } as any); docs.push(doc); // Log document upload activity const requestMeta = getRequestMetadata(req); activityService.log({ requestId: workflow.requestId, type: 'document_added', user: { userId, name: uploaderName }, timestamp: new Date().toISOString(), action: 'Document Added', details: `Added ${file.originalname} as supporting document by ${uploaderName}`, metadata: { fileName: file.originalname, fileSize: file.size, fileType: extension }, ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent }); } } ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage); } } async getWorkflow(req: Request, res: Response): Promise { try { const { id } = req.params; const workflow = await workflowService.getWorkflowById(id); if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; } ResponseHandler.success(res, workflow, 'Workflow retrieved successfully'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to get workflow', 500, errorMessage); } } async getWorkflowDetails(req: AuthenticatedRequest, res: Response): Promise { try { const { id } = req.params as any; const userId = req.user?.userId; if (!userId) { ResponseHandler.error(res, 'Authentication required', 401); return; } // Check if user has access to this request const accessCheck = await workflowService.checkUserRequestAccess(userId, id); if (!accessCheck.hasAccess) { ResponseHandler.error(res, accessCheck.reason || 'Access denied', 403); return; } const result = await workflowService.getWorkflowDetails(id); if (!result) { ResponseHandler.notFound(res, 'Workflow not found'); return; } ResponseHandler.success(res, result, 'Workflow details fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to fetch workflow details', 500, errorMessage); } } async listWorkflows(req: Request, res: Response): Promise { try { const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); // Extract filter parameters const filters = { search: req.query.search as string | undefined, status: req.query.status as string | undefined, priority: req.query.priority as string | undefined, department: req.query.department as string | undefined, initiator: req.query.initiator as string | undefined, approver: req.query.approver as string | undefined, approverType: req.query.approverType as 'current' | 'any' | undefined, slaCompliance: req.query.slaCompliance as string | undefined, dateRange: req.query.dateRange as string | undefined, startDate: req.query.startDate as string | undefined, endDate: req.query.endDate as string | undefined, }; const result = await workflowService.listWorkflows(page, limit, filters); ResponseHandler.success(res, result, 'Workflows fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to list workflows', 500, errorMessage); } } async listMyRequests(req: Request, res: Response): Promise { try { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); // Extract filter parameters (same as listWorkflows) const search = req.query.search as string | undefined; const status = req.query.status as string | undefined; const priority = req.query.priority as string | undefined; const department = req.query.department as string | undefined; const initiator = req.query.initiator as string | undefined; const approver = req.query.approver as string | undefined; const approverType = req.query.approverType as 'current' | 'any' | undefined; const slaCompliance = req.query.slaCompliance as string | undefined; const dateRange = req.query.dateRange as string | undefined; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; const result = await workflowService.listMyRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My requests fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to fetch my requests', 500, errorMessage); } } /** * List requests where user is a PARTICIPANT (not initiator) - for regular users' "All Requests" page * Completely separate from listWorkflows (admin) to avoid interference */ async listParticipantRequests(req: Request, res: Response): Promise { try { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); // Extract filter parameters (same as listWorkflows) const search = req.query.search as string | undefined; const status = req.query.status as string | undefined; const priority = req.query.priority as string | undefined; const department = req.query.department as string | undefined; const initiator = req.query.initiator as string | undefined; const approver = req.query.approver as string | undefined; const approverType = req.query.approverType as 'current' | 'any' | undefined; const slaCompliance = req.query.slaCompliance as string | undefined; const dateRange = req.query.dateRange as string | undefined; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; const result = await workflowService.listParticipantRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'Participant requests fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to fetch participant requests', 500, errorMessage); } } /** * List requests where user is the initiator (for "My Requests" page) */ async listMyInitiatedRequests(req: Request, res: Response): Promise { try { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); // Extract filter parameters const search = req.query.search as string | undefined; const status = req.query.status as string | undefined; const priority = req.query.priority as string | undefined; const department = req.query.department as string | undefined; const slaCompliance = req.query.slaCompliance as string | undefined; const dateRange = req.query.dateRange as string | undefined; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; const filters = { search, status, priority, department, slaCompliance, dateRange, startDate, endDate }; const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My initiated requests fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to fetch my initiated requests', 500, errorMessage); } } async listOpenForMe(req: Request, res: Response): Promise { try { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); // Extract filter parameters const filters = { search: req.query.search as string | undefined, status: req.query.status as string | undefined, priority: req.query.priority as string | undefined }; // Extract sorting parameters const sortBy = req.query.sortBy as string | undefined; const sortOrder = (req.query.sortOrder as string | undefined) || 'desc'; const result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder); ResponseHandler.success(res, result, 'Open requests for user fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to fetch open requests for user', 500, errorMessage); } } async listClosedByMe(req: Request, res: Response): Promise { try { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); // Extract filter parameters const filters = { search: req.query.search as string | undefined, status: req.query.status as string | undefined, priority: req.query.priority as string | undefined }; // Extract sorting parameters const sortBy = req.query.sortBy as string | undefined; const sortOrder = (req.query.sortOrder as string | undefined) || 'desc'; const result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder); ResponseHandler.success(res, result, 'Closed requests by user fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to fetch closed requests by user', 500, errorMessage); } } async updateWorkflow(req: Request, res: Response): Promise { try { const { id } = req.params; const validatedData = validateUpdateWorkflow(req.body); // Build a strongly-typed payload for the service layer const updateData: UpdateWorkflowRequest = { ...validatedData } as any; if (validatedData.priority) { // Map string literal to enum value explicitly updateData.priority = validatedData.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD; } const workflow = await workflowService.updateWorkflow(id, updateData); if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; } ResponseHandler.success(res, workflow, 'Workflow updated successfully'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage); } } // Multipart update for drafts: accepts payload JSON and files[] async updateWorkflowMultipart(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; if (!userId) { ResponseHandler.error(res, 'Unauthorized', 401); return; } const { id } = req.params; const raw = String(req.body?.payload || ''); if (!raw) { ResponseHandler.error(res, 'payload is required', 400); return; } const parsed = JSON.parse(raw); const validated = validateUpdateWorkflow(parsed); const updateData: UpdateWorkflowRequest = { ...validated } as any; if (validated.priority) { updateData.priority = validated.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD; } // Update workflow const workflow = await workflowService.updateWorkflow(id, updateData); if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; } // Attach new files as documents const files = (req as any).files as Express.Multer.File[] | undefined; const category = (req.body?.category as string) || 'SUPPORTING'; const docs: any[] = []; if (files && files.length > 0) { const actualRequestId = (workflow as any).requestId; for (const file of files) { // Get file buffer - multer.memoryStorage provides buffer, not path const fileBuffer = (file as any).buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); // Upload with automatic fallback to local storage const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number; const uploadResult = await gcsStorageService.uploadFileWithFallback({ buffer: fileBuffer, originalName: file.originalname, mimeType: file.mimetype, requestNumber: requestNumber, fileType: 'documents' }); const storageUrl = uploadResult.storageUrl; const gcsFilePath = uploadResult.filePath; // Clean up local temporary file if it exists (from multer disk storage) if (file.path && fs.existsSync(file.path)) { try { fs.unlinkSync(file.path); } catch (unlinkError) { logger.warn('[Workflow] Failed to delete local temporary file:', unlinkError); } } logger.info('[Workflow] Creating document record', { fileName: file.originalname, filePath: gcsFilePath, storageUrl: storageUrl, requestId: actualRequestId }); const doc = await Document.create({ requestId: actualRequestId, uploadedBy: userId, fileName: path.basename(file.filename || file.originalname), originalFileName: file.originalname, fileType: extension, fileExtension: extension, fileSize: file.size, filePath: gcsFilePath, // Store GCS path or local path storageUrl: storageUrl, // Store GCS URL or local URL mimeType: file.mimetype, checksum, isGoogleDoc: false, googleDocUrl: null as any, category: category || 'OTHER', version: 1, parentDocumentId: null as any, isDeleted: false, downloadCount: 0, } as any); docs.push(doc); } } ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage); } } async submitWorkflow(req: Request, res: Response): Promise { try { const { id } = req.params; const workflow = await workflowService.submitWorkflow(id); if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; } ResponseHandler.success(res, workflow, 'Workflow submitted successfully'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage); } } }