import { Router } from 'express'; import type { Request, Response } from 'express'; import { WorkflowController } from '../controllers/workflow.controller'; import { ApprovalController } from '../controllers/approval.controller'; import { authenticateToken } from '../middlewares/auth.middleware'; import { validateBody, validateParams } from '../middlewares/validate.middleware'; import { createWorkflowSchema, updateWorkflowSchema, workflowParamsSchema } from '../validators/workflow.validator'; import { approvalActionSchema, approvalParamsSchema } from '../validators/approval.validator'; import { asyncHandler } from '../middlewares/errorHandler.middleware'; import { requireParticipantTypes } from '../middlewares/authorization.middleware'; import multer from 'multer'; import path from 'path'; import crypto from 'crypto'; import { ensureUploadDir, UPLOAD_DIR } from '../config/storage'; import { notificationService } from '../services/notification.service'; import { Activity } from '@models/Activity'; import { WorkflowService } from '../services/workflow.service'; import { WorkNoteController } from '../controllers/worknote.controller'; import { workNoteService } from '../services/worknote.service'; import { pauseController } from '../controllers/pause.controller'; import logger from '@utils/logger'; const router = Router(); /** * Helper function to create proper Content-Disposition header * Returns clean filename header that browsers handle correctly */ function createContentDisposition(disposition: 'inline' | 'attachment', filename: string): string { // Clean filename: only remove truly problematic characters for HTTP headers // Keep spaces, dots, hyphens, underscores - these are safe const cleanFilename = filename .replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_') // Only replace truly problematic chars .replace(/\\/g, '_') // Replace backslashes .trim(); // For ASCII-only filenames, use simple format (browsers prefer this) // Only use filename* for non-ASCII characters const hasNonASCII = /[^\x00-\x7F]/.test(filename); if (hasNonASCII) { // Use RFC 5987 encoding for non-ASCII characters const encodedFilename = encodeURIComponent(filename); return `${disposition}; filename="${cleanFilename}"; filename*=UTF-8''${encodedFilename}`; } else { // Simple ASCII filename - use clean version (no filename* needed) // This prevents browsers from showing both filename and filename* return `${disposition}; filename="${cleanFilename}"`; } } const workflowController = new WorkflowController(); const approvalController = new ApprovalController(); const workNoteController = new WorkNoteController(); // Workflow routes router.get('/', authenticateToken, asyncHandler(workflowController.listWorkflows.bind(workflowController)) ); // Filtered lists // /my - All requests where user is a participant (not initiator) - for "All Requests" page (DEPRECATED - use /participant-requests) router.get('/my', authenticateToken, asyncHandler(workflowController.listMyRequests.bind(workflowController)) ); // /participant-requests - All requests where user is a participant (not initiator) - for regular users' "All Requests" page // SEPARATE endpoint from /workflows (admin) to avoid interference router.get('/participant-requests', authenticateToken, asyncHandler(workflowController.listParticipantRequests.bind(workflowController)) ); // /my-initiated - Only requests where user is the initiator - for "My Requests" page router.get('/my-initiated', authenticateToken, asyncHandler(workflowController.listMyInitiatedRequests.bind(workflowController)) ); router.get('/open-for-me', authenticateToken, asyncHandler(workflowController.listOpenForMe.bind(workflowController)) ); router.get('/closed-by-me', authenticateToken, asyncHandler(workflowController.listClosedByMe.bind(workflowController)) ); router.post('/', authenticateToken, validateBody(createWorkflowSchema), asyncHandler(workflowController.createWorkflow.bind(workflowController)) ); // Multipart create (payload + files[]) // Use memory storage for GCS uploads const storage = multer.memoryStorage(); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } }); router.post('/multipart', authenticateToken, upload.array('files'), asyncHandler(workflowController.createWorkflowMultipart.bind(workflowController)) ); router.get('/:id', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(workflowController.getWorkflow.bind(workflowController)) ); router.get('/:id/details', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(workflowController.getWorkflowDetails.bind(workflowController)) ); router.put('/:id', authenticateToken, validateParams(workflowParamsSchema), validateBody(updateWorkflowSchema), asyncHandler(workflowController.updateWorkflow.bind(workflowController)) ); // Multipart update (payload + files[]) for draft updates router.put('/:id/multipart', authenticateToken, validateParams(workflowParamsSchema), upload.array('files'), asyncHandler(workflowController.updateWorkflowMultipart.bind(workflowController)) ); router.patch('/:id/submit', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(workflowController.submitWorkflow.bind(workflowController)) ); // Approval routes router.get('/:id/approvals', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(approvalController.getApprovalLevels.bind(approvalController)) ); router.get('/:id/approvals/current', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(approvalController.getCurrentApprovalLevel.bind(approvalController)) ); router.patch('/:id/approvals/:levelId/approve', authenticateToken, requireParticipantTypes(['APPROVER']), validateParams(approvalParamsSchema), validateBody(approvalActionSchema), asyncHandler(approvalController.approveLevel.bind(approvalController)) ); router.patch('/:id/approvals/:levelId/reject', authenticateToken, requireParticipantTypes(['APPROVER']), validateParams(approvalParamsSchema), validateBody(approvalActionSchema), asyncHandler(approvalController.approveLevel.bind(approvalController)) ); // Notifications router.post('/notifications/subscribe', authenticateToken, asyncHandler(async (req: any, res: Response): Promise => { const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } const ua = req.headers['user-agent'] as string | undefined; await notificationService.addSubscription(userId, req.body, ua); res.json({ success: true }); return; }) ); router.post('/notifications/test', authenticateToken, asyncHandler(async (req: any, res: Response): Promise => { const userId = req.user?.userId; await notificationService.sendToUsers([userId], { title: 'Test', body: 'Push works!' }); res.json({ success: true }); return; }) ); // Activities router.get('/:id/activity', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(async (req: any, res: Response): Promise => { // Resolve requestId UUID from identifier const workflowService = new WorkflowService(); const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); if (!wf) { res.status(404).json({ success: false, error: 'Not found' }); return; } const requestId: string = wf.getDataValue('requestId'); const rows = await Activity.findAll({ where: { requestId }, order: [['created_at', 'ASC']] as any }); res.json({ success: true, data: rows }); return; }) ); // Work Notes router.get('/:id/work-notes', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(workNoteController.list.bind(workNoteController)) ); const noteUpload = upload; // reuse same memory storage/limits router.post('/:id/work-notes', authenticateToken, validateParams(workflowParamsSchema), noteUpload.array('files'), asyncHandler(workNoteController.create.bind(workNoteController)) ); // Preview workflow document router.get('/documents/:documentId/preview', authenticateToken, asyncHandler(async (req: any, res: Response) => { const { documentId } = req.params; const { Document } = require('@models/Document'); const { gcsStorageService } = require('../services/gcsStorage.service'); const fs = require('fs'); const document = await Document.findOne({ where: { documentId } }); if (!document) { res.status(404).json({ success: false, error: 'Document not found' }); return; } const storageUrl = (document as any).storageUrl || (document as any).storage_url; const filePath = (document as any).filePath || (document as any).file_path; const fileName = (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName; const fileType = (document as any).mimeType || (document as any).mime_type; // Check if it's a GCS URL const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://')); if (isGcsUrl) { // Redirect to GCS public URL or use signed URL for private files res.redirect(storageUrl); return; } // If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS if (!storageUrl && filePath && filePath.startsWith('requests/')) { try { // Use the existing GCS storage service instance if (!gcsStorageService.isConfigured()) { throw new Error('GCS not configured'); } // Access the storage instance from the service const { Storage } = require('@google-cloud/storage'); const keyFilePath = process.env.GCP_KEY_FILE || ''; const bucketName = process.env.GCP_BUCKET_NAME || ''; const path = require('path'); const resolvedKeyPath = path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath); const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID || '', keyFilename: resolvedKeyPath, }); const bucket = storage.bucket(bucketName); const file = bucket.file(filePath); // Check if file exists const [exists] = await file.exists(); if (!exists) { res.status(404).json({ success: false, error: 'File not found in GCS' }); return; } // Get file metadata for content type const [metadata] = await file.getMetadata(); const contentType = metadata.contentType || fileType || 'application/octet-stream'; // Set CORS headers const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); res.setHeader('Content-Type', contentType); // For images and PDFs, allow inline viewing const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); const disposition = isPreviewable ? 'inline' : 'attachment'; res.setHeader('Content-Disposition', createContentDisposition(disposition, fileName)); // Stream file from GCS to response file.createReadStream() .on('error', (streamError: Error) => { const logger = require('../utils/logger').default; logger.error('[Workflow] Failed to stream file from GCS', { documentId, filePath, error: streamError.message, }); if (!res.headersSent) { res.status(500).json({ success: false, error: 'Failed to stream file from storage' }); } }) .pipe(res); return; } catch (gcsError) { const logger = require('../utils/logger').default; logger.error('[Workflow] Failed to access GCS file for preview', { documentId, filePath, error: gcsError instanceof Error ? gcsError.message : 'Unknown error', }); res.status(500).json({ success: false, error: 'Failed to access file. Please try again.' }); return; } } // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (storageUrl && storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); // Set appropriate content type res.contentType(fileType || 'application/octet-stream'); // For images and PDFs, allow inline viewing const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); if (isPreviewable) { res.setHeader('Content-Disposition', `inline; filename="${fileName}"`); } else { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); } }); return; } // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed const absolutePath = filePath && !path.isAbsolute(filePath) ? path.join(UPLOAD_DIR, filePath) : filePath; if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); // Set appropriate content type res.contentType(fileType || 'application/octet-stream'); // For images and PDFs, allow inline viewing const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); if (isPreviewable) { res.setHeader('Content-Disposition', `inline; filename="${fileName}"`); } else { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); } }); }) ); // Download workflow document router.get('/documents/:documentId/download', authenticateToken, asyncHandler(async (req: any, res: Response) => { const { documentId } = req.params; const { Document } = require('@models/Document'); const { gcsStorageService } = require('../services/gcsStorage.service'); const fs = require('fs'); const document = await Document.findOne({ where: { documentId } }); if (!document) { res.status(404).json({ success: false, error: 'Document not found' }); return; } const storageUrl = (document as any).storageUrl || (document as any).storage_url; const filePath = (document as any).filePath || (document as any).file_path; const fileName = (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName; // Check if it's a GCS URL const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://')); if (isGcsUrl) { // Redirect to GCS public URL for download res.redirect(storageUrl); return; } // If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS if (!storageUrl && filePath && filePath.startsWith('requests/')) { try { // Use the existing GCS storage service instance if (!gcsStorageService.isConfigured()) { throw new Error('GCS not configured'); } // Access the storage instance from the service const { Storage } = require('@google-cloud/storage'); const keyFilePath = process.env.GCP_KEY_FILE || ''; const bucketName = process.env.GCP_BUCKET_NAME || ''; const path = require('path'); const resolvedKeyPath = path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath); const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID || '', keyFilename: resolvedKeyPath, }); const bucket = storage.bucket(bucketName); const file = bucket.file(filePath); // Check if file exists const [exists] = await file.exists(); if (!exists) { res.status(404).json({ success: false, error: 'File not found in GCS' }); return; } // Get file metadata for content type const [metadata] = await file.getMetadata(); const contentType = metadata.contentType || (document as any).mimeType || (document as any).mime_type || 'application/octet-stream'; // Set CORS headers const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); // Set headers for download res.setHeader('Content-Type', contentType); res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName)); // Stream file from GCS to response file.createReadStream() .on('error', (streamError: Error) => { const logger = require('../utils/logger').default; logger.error('[Workflow] Failed to stream file from GCS for download', { documentId, filePath, error: streamError.message, }); if (!res.headersSent) { res.status(500).json({ success: false, error: 'Failed to stream file from storage' }); } }) .pipe(res); return; } catch (gcsError) { const logger = require('../utils/logger').default; logger.error('[Workflow] Failed to access GCS file for download', { documentId, filePath, error: gcsError instanceof Error ? gcsError.message : 'Unknown error', }); res.status(500).json({ success: false, error: 'Failed to access file. Please try again.' }); return; } } // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (storageUrl && storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } // Set CORS headers const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); // Set headers for download const fileTypeForDownload = (document as any).mimeType || (document as any).mime_type || 'application/octet-stream'; res.setHeader('Content-Type', fileTypeForDownload); res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName)); res.download(absolutePath, fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); } }); return; } // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed const absolutePath = filePath && !path.isAbsolute(filePath) ? path.join(UPLOAD_DIR, filePath) : filePath; if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } res.download(absolutePath, fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); } }); }) ); // Preview work note attachment (serves file for inline viewing) router.get('/work-notes/attachments/:attachmentId/preview', authenticateToken, asyncHandler(async (req: any, res: Response) => { const { attachmentId } = req.params; const fileInfo = await workNoteService.downloadAttachment(attachmentId); const fs = require('fs'); // Check if it's a GCS URL if (fileInfo.isGcsUrl && fileInfo.storageUrl) { // Redirect to GCS public URL res.redirect(fileInfo.storageUrl); return; } // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); // Set appropriate content type res.contentType(fileInfo.fileType || 'application/octet-stream'); // For images and PDFs, allow inline viewing const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf')); if (isPreviewable) { res.setHeader('Content-Disposition', `inline; filename="${fileInfo.fileName}"`); } else { res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`); } res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); } }); return; } // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) ? path.join(UPLOAD_DIR, fileInfo.filePath) : fileInfo.filePath; if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); // Set appropriate content type res.contentType(fileInfo.fileType || 'application/octet-stream'); // For images and PDFs, allow inline viewing const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf')); if (isPreviewable) { res.setHeader('Content-Disposition', `inline; filename="${fileInfo.fileName}"`); } else { res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`); } res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); } }); }) ); // Download work note attachment router.get('/work-notes/attachments/:attachmentId/download', authenticateToken, asyncHandler(async (req: any, res: Response) => { const { attachmentId } = req.params; const fileInfo = await workNoteService.downloadAttachment(attachmentId); const fs = require('fs'); // Check if it's a GCS URL if (fileInfo.isGcsUrl && fileInfo.storageUrl) { // Redirect to GCS public URL for download res.redirect(fileInfo.storageUrl); return; } // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } // Set CORS headers const origin = req.headers.origin; if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); res.download(absolutePath, fileInfo.fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); } }); return; } // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) ? path.join(UPLOAD_DIR, fileInfo.filePath) : fileInfo.filePath; if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } res.download(absolutePath, fileInfo.fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); } }); }) ); // Add participant routes router.post('/:id/participants/approver', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(async (req: any, res: Response) => { const workflowService = new WorkflowService(); const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); if (!wf) { res.status(404).json({ success: false, error: 'Workflow not found' }); return; } const requestId: string = wf.getDataValue('requestId'); const { email } = req.body; if (!email) { res.status(400).json({ success: false, error: 'Email is required' }); return; } const participant = await workflowService.addApprover(requestId, email, req.user?.userId); res.status(201).json({ success: true, data: participant }); }) ); router.post('/:id/participants/spectator', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(async (req: any, res: Response) => { const workflowService = new WorkflowService(); const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); if (!wf) { res.status(404).json({ success: false, error: 'Workflow not found' }); return; } const requestId: string = wf.getDataValue('requestId'); const { email } = req.body; if (!email) { res.status(400).json({ success: false, error: 'Email is required' }); return; } const participant = await workflowService.addSpectator(requestId, email, req.user?.userId); res.status(201).json({ success: true, data: participant }); }) ); // Skip approver endpoint router.post('/:id/approvals/:levelId/skip', authenticateToken, requireParticipantTypes(['INITIATOR', 'APPROVER']), // Only initiator or other approvers can skip validateParams(approvalParamsSchema), asyncHandler(async (req: any, res: Response) => { const workflowService = new WorkflowService(); const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); if (!wf) { res.status(404).json({ success: false, error: 'Workflow not found' }); return; } const requestId: string = wf.getDataValue('requestId'); const { levelId } = req.params; const { reason } = req.body; const result = await workflowService.skipApprover( requestId, levelId, reason || '', req.user?.userId ); res.status(200).json({ success: true, message: 'Approver skipped successfully', data: result }); }) ); // Add approver at specific level with level shifting router.post('/:id/approvers/at-level', authenticateToken, requireParticipantTypes(['INITIATOR', 'APPROVER']), // Only initiator or approvers can add new approvers validateParams(workflowParamsSchema), asyncHandler(async (req: any, res: Response) => { const workflowService = new WorkflowService(); const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); if (!wf) { res.status(404).json({ success: false, error: 'Workflow not found' }); return; } const requestId: string = wf.getDataValue('requestId'); const { email, tatHours, level } = req.body; if (!email || !tatHours || !level) { res.status(400).json({ success: false, error: 'Email, tatHours, and level are required' }); return; } const result = await workflowService.addApproverAtLevel( requestId, email, Number(tatHours), Number(level), req.user?.userId ); res.status(201).json({ success: true, message: 'Approver added successfully', data: result }); }) ); // Pause workflow routes // POST /workflows/:id/pause - Pause a workflow (approver or initiator) router.post('/:id/pause', authenticateToken, requireParticipantTypes(['APPROVER', 'INITIATOR']), // Both approvers and initiators can pause validateParams(workflowParamsSchema), asyncHandler(pauseController.pauseWorkflow.bind(pauseController)) ); // POST /workflows/:id/resume - Resume a paused workflow (approver or initiator) router.post('/:id/resume', authenticateToken, requireParticipantTypes(['APPROVER', 'INITIATOR']), // Both approvers and initiators can resume validateParams(workflowParamsSchema), asyncHandler(pauseController.resumeWorkflow.bind(pauseController)) ); // POST /workflows/:id/pause/retrigger - Retrigger pause (initiator requests approver to resume) router.post('/:id/pause/retrigger', authenticateToken, requireParticipantTypes(['INITIATOR']), // Only initiator can retrigger validateParams(workflowParamsSchema), asyncHandler(pauseController.retriggerPause.bind(pauseController)) ); // GET /workflows/:id/pause - Get pause details router.get('/:id/pause', authenticateToken, validateParams(workflowParamsSchema), asyncHandler(pauseController.getPauseDetails.bind(pauseController)) ); export default router;