773 lines
28 KiB
TypeScript
773 lines
28 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/')) {
|
|
// File is served by express.static middleware, redirect to the storage URL
|
|
res.redirect(storageUrl);
|
|
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/')) {
|
|
// File is served by express.static middleware, redirect to the storage URL
|
|
res.redirect(storageUrl);
|
|
return;
|
|
}
|
|
|
|
// Legacy local file handling (absolute path stored in filePath)
|
|
// Resolve relative path if needed
|
|
const path = require('path');
|
|
const { UPLOAD_DIR } = require('../config/storage');
|
|
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/')) {
|
|
// File is served by express.static middleware, redirect to the storage URL
|
|
res.redirect(fileInfo.storageUrl);
|
|
return;
|
|
}
|
|
|
|
// Legacy local file handling (absolute path stored in filePath)
|
|
// Resolve relative path if needed
|
|
const path = require('path');
|
|
const { UPLOAD_DIR } = require('../config/storage');
|
|
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/')) {
|
|
// File is served by express.static middleware, redirect to the storage URL
|
|
res.redirect(fileInfo.storageUrl);
|
|
return;
|
|
}
|
|
|
|
// Legacy local file handling (absolute path stored in filePath)
|
|
// Resolve relative path if needed
|
|
const path = require('path');
|
|
const { UPLOAD_DIR } = require('../config/storage');
|
|
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;
|