feat(tenders): add Tender Management module (SRS, backend, frontend)
- SRS document: docs/SRS_TENDER_MANAGEMENT.md - Prisma: Tender, TenderDirective models; Deal.sourceTenderId; Attachment.tenderId/tenderDirectiveId - Backend: tenders module (CRUD, duplicate check, directives, notifications, file upload, convert-to-deal) - Frontend: tenders list, detail, create/edit forms, directives, convert to deal, i18n (en/ar), dashboard card - Seed: tenders permissions for admin and sales positions - Auth: admin.service findFirst for email check (Prisma compatibility) Made-with: Cursor
This commit is contained in:
175
backend/src/modules/tenders/tenders.routes.ts
Normal file
175
backend/src/modules/tenders/tenders.routes.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { tendersController } from './tenders.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
import { config } from '../../config';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const uploadDir = path.join(config.upload.path, 'tenders');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
||||
},
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: config.upload.maxFileSize },
|
||||
});
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// Enum/lookup routes (no resource id) - place before /:id routes
|
||||
router.get(
|
||||
'/source-values',
|
||||
authorize('tenders', 'tenders', 'read'),
|
||||
tendersController.getSourceValues
|
||||
);
|
||||
router.get(
|
||||
'/announcement-type-values',
|
||||
authorize('tenders', 'tenders', 'read'),
|
||||
tendersController.getAnnouncementTypeValues
|
||||
);
|
||||
router.get(
|
||||
'/directive-type-values',
|
||||
authorize('tenders', 'tenders', 'read'),
|
||||
tendersController.getDirectiveTypeValues
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/check-duplicates',
|
||||
authorize('tenders', 'tenders', 'create'),
|
||||
[
|
||||
body('issuingBodyName').optional().trim(),
|
||||
body('title').optional().trim(),
|
||||
body('tenderNumber').optional().trim(),
|
||||
body('termsValue').optional().isNumeric(),
|
||||
body('bondValue').optional().isNumeric(),
|
||||
body('announcementDate').optional().isISO8601(),
|
||||
body('closingDate').optional().isISO8601(),
|
||||
],
|
||||
validate,
|
||||
tendersController.checkDuplicates
|
||||
);
|
||||
|
||||
// List & create tenders
|
||||
router.get(
|
||||
'/',
|
||||
authorize('tenders', 'tenders', 'read'),
|
||||
tendersController.findAll
|
||||
);
|
||||
router.post(
|
||||
'/',
|
||||
authorize('tenders', 'tenders', 'create'),
|
||||
[
|
||||
body('tenderNumber').notEmpty().trim(),
|
||||
body('issuingBodyName').notEmpty().trim(),
|
||||
body('title').notEmpty().trim(),
|
||||
body('termsValue').isNumeric(),
|
||||
body('bondValue').isNumeric(),
|
||||
body('announcementDate').isISO8601(),
|
||||
body('closingDate').isISO8601(),
|
||||
body('source').notEmpty(),
|
||||
body('announcementType').notEmpty(),
|
||||
],
|
||||
validate,
|
||||
tendersController.create
|
||||
);
|
||||
|
||||
// Tender by id
|
||||
router.get(
|
||||
'/:id',
|
||||
authorize('tenders', 'tenders', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
tendersController.findById
|
||||
);
|
||||
router.put(
|
||||
'/:id',
|
||||
authorize('tenders', 'tenders', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
tendersController.update
|
||||
);
|
||||
|
||||
// Tender history
|
||||
router.get(
|
||||
'/:id/history',
|
||||
authorize('tenders', 'tenders', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
tendersController.getHistory
|
||||
);
|
||||
|
||||
// Convert to deal
|
||||
router.post(
|
||||
'/:id/convert-to-deal',
|
||||
authorize('tenders', 'tenders', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('contactId').isUUID(),
|
||||
body('pipelineId').isUUID(),
|
||||
body('ownerId').optional().isUUID(),
|
||||
],
|
||||
validate,
|
||||
tendersController.convertToDeal
|
||||
);
|
||||
|
||||
// Directives
|
||||
router.post(
|
||||
'/:id/directives',
|
||||
authorize('tenders', 'directives', 'create'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('type').isIn(['BUY_TERMS', 'VISIT_CLIENT', 'MEET_COMMITTEE', 'PREPARE_TO_BID']),
|
||||
body('assignedToEmployeeId').isUUID(),
|
||||
body('notes').optional().trim(),
|
||||
],
|
||||
validate,
|
||||
tendersController.createDirective
|
||||
);
|
||||
|
||||
// Update directive (e.g. complete task) - route with directiveId
|
||||
router.put(
|
||||
'/directives/:directiveId',
|
||||
authorize('tenders', 'directives', 'update'),
|
||||
[
|
||||
param('directiveId').isUUID(),
|
||||
body('status').optional().isIn(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
|
||||
body('completionNotes').optional().trim(),
|
||||
],
|
||||
validate,
|
||||
tendersController.updateDirective
|
||||
);
|
||||
|
||||
// File uploads
|
||||
router.post(
|
||||
'/:id/attachments',
|
||||
authorize('tenders', 'tenders', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
upload.single('file'),
|
||||
tendersController.uploadTenderAttachment
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/directives/:directiveId/attachments',
|
||||
authorize('tenders', 'directives', 'update'),
|
||||
param('directiveId').isUUID(),
|
||||
validate,
|
||||
upload.single('file'),
|
||||
tendersController.uploadDirectiveAttachment
|
||||
);
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user