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:
Talal Sharabi
2026-03-11 16:57:40 +04:00
parent 18c13cdf7c
commit 4c139429e2
14 changed files with 2623 additions and 17 deletions

View 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;