Re_Backend/src/routes/workflow.routes.ts

878 lines
32 KiB
TypeScript

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<void> => {
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<void> => {
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<void> => {
// 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;