diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md
index 853352a..d116276 100644
--- a/BACKEND_TASKS.md
+++ b/BACKEND_TASKS.md
@@ -246,6 +246,11 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| [x] | PUT | `/grn/:id` | edit | Update GRN |
| [x] | POST | `/grn/:id/cancel` | edit | Cancel GRN |
| [x] | GET | `/grn/:id/pdf` | view | PDF export |
+| [x] | GET | `/grn/:grnId/attachments` | view | List GRN attachments |
+| [x] | POST | `/grn/:grnId/attachments` | edit | Upload file (multipart `file`) |
+| [x] | GET | `/grn/:grnId/attachments/:attachmentId` | view | Attachment metadata |
+| [x] | GET | `/grn/:grnId/attachments/:attachmentId/download` | view | Download file (authenticated) |
+| [x] | DELETE | `/grn/:grnId/attachments/:attachmentId` | delete | Delete attachment + file |
---
@@ -329,7 +334,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Masters | 70 | 70 | [x] Done |
| Vendors | 27 | 27 | [x] Done |
| Purchase Orders | 11 | 11 | [x] Done |
-| GRN | 6 | 6 | [x] Done |
+| GRN | 11 | 11 | [x] Done |
| Assets | 24 | 24 | [x] Done |
| Settings | 5 | 5 | [x] Done |
| **Total** | **168** | **168** | **[x] Phase 1 APIs + Asset extensions** |
diff --git a/docs/MODULE_DATA_FLOW.html b/docs/MODULE_DATA_FLOW.html
index bd10b60..e7a2ac7 100644
--- a/docs/MODULE_DATA_FLOW.html
+++ b/docs/MODULE_DATA_FLOW.html
@@ -665,6 +665,57 @@ stateDiagram-v2
+
GRN goods receiving concept flow
+ End-to-end warehouse receipt: PO-backed GRN posting, optional supporting documents, PO qty update, and asset auto-creation.
+
+
+flowchart TD
+ subgraph Physical["Physical receipt at warehouse"]
+ P1[Vendor delivery arrives]
+ P2[Verify PO / invoice / LR / vehicle]
+ P3[Inspect qty quality batch]
+ end
+
+ subgraph System["System — GRN posting"]
+ S1["GET open PO
status APPROVED / PARTIALLY_RECEIVED"]
+ S2["POST /grn
header + line items"]
+ S3["GRN txn: grn POSTED"]
+ S4["grn_items per accepted/rejected qty"]
+ S5["PO received_qty += accepted_qty"]
+ S6{Item is_asset_item?}
+ S7["Auto-create assets
1 per accepted unit"]
+ S8["Recalculate PO status"]
+ end
+
+ subgraph Docs["Supporting documents"]
+ D1["POST /grn/:grnId/attachments"]
+ D2["grn_attachments row"]
+ D3["File in uploads/grn/{grnId}/"]
+ D4["GET .../download authenticated"]
+ end
+
+ P1 --> P2 --> P3 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
+ S6 -->|Yes| S7 --> S8
+ S6 -->|No| S8
+ S3 --> D1 --> D2 --> D3 --> D4
+
+ S8 --> OUT1{More qty pending?}
+ OUT1 -->|Yes| PARTIAL[PO PARTIALLY_RECEIVED]
+ OUT1 -->|No| FULL[PO FULLY_RECEIVED]
+
+
+
+ | Step | API / table | Notes |
+
+ | 1 | POST /grn | Requires APPROVED PO with pending qty |
+ | 2 | grn, grn_items | accepted_qty + rejected_qty = current_qty |
+ | 3 | purchase_order_items.received_qty | Incremented in same transaction |
+ | 4 | assets | When items.is_asset_item = true |
+ | 5 | POST /grn/:grnId/attachments | Invoice PDF, LR copy, photos |
+ | 6 | GET .../attachments/:id/download | RBAC download — not public /uploads |
+
+
+
GRN auto-asset creation (when items.is_asset_item = true)
| Field on Asset | Source |
@@ -971,6 +1022,7 @@ erDiagram
purchase_orders ||--o{ assets : sourced_from
grn ||--o{ grn_items : lines
+ grn ||--o{ grn_attachments : documents
grn_items ||--o{ assets : auto_created
items ||--o{ purchase_order_items : ordered
diff --git a/docs/MODULE_DATA_FLOW.md b/docs/MODULE_DATA_FLOW.md
index 2a78eb0..30572bb 100644
--- a/docs/MODULE_DATA_FLOW.md
+++ b/docs/MODULE_DATA_FLOW.md
@@ -254,6 +254,63 @@ stateDiagram-v2
APPROVED --> CANCELLED: cancel if no receipts
```
+### GRN goods receiving concept flow
+
+End-to-end warehouse receipt: PO-backed GRN posting, optional supporting documents, PO qty update, and asset auto-creation.
+
+```mermaid
+flowchart TD
+ subgraph Physical["Physical receipt at warehouse"]
+ P1[Vendor delivery arrives]
+ P2[Verify PO / invoice / LR / vehicle]
+ P3[Inspect qty quality batch]
+ end
+
+ subgraph System["System — GRN posting"]
+ S1["GET open PO
status APPROVED / PARTIALLY_RECEIVED"]
+ S2["POST /grn
header + line items"]
+ S3["GRN txn: grn POSTED"]
+ S4["grn_items per accepted/rejected qty"]
+ S5["PO received_qty += accepted_qty"]
+ S6{Item is_asset_item?}
+ S7["Auto-create assets
1 per accepted unit"]
+ S8["Recalculate PO status"]
+ end
+
+ subgraph Docs["Supporting documents (after or alongside GRN)"]
+ D1["POST /grn/:grnId/attachments
multipart file"]
+ D2["grn_attachments row"]
+ D3["File stored under uploads/grn/{grnId}/"]
+ D4["GET .../download — authenticated"]
+ end
+
+ P1 --> P2 --> P3 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
+ S6 -->|Yes| S7 --> S8
+ S6 -->|No| S8
+ S3 --> D1 --> D2 --> D3
+ D3 --> D4
+
+ S8 --> OUT1{More qty pending?}
+ OUT1 -->|Yes| PARTIAL[PO PARTIALLY_RECEIVED]
+ OUT1 -->|No| FULL[PO FULLY_RECEIVED]
+
+ CANCEL["POST /grn/:id/cancel"] --> REV1[Reverse PO received_qty]
+ REV1 --> REV2[Soft-delete linked assets]
+ REV2 --> REV3[GRN CANCELLED — attachments remain on record]
+```
+
+| Step | Actor | API / table | Notes |
+|------|-------|-------------|-------|
+| 1 | Store / procurement | `POST /grn` | Requires APPROVED PO with pending qty |
+| 2 | System | `grn`, `grn_items` | `accepted_qty + rejected_qty = current_qty` per line |
+| 3 | System | `purchase_order_items.received_qty` | Incremented in same transaction |
+| 4 | System | `assets` | Created when `items.is_asset_item = true` |
+| 5 | Store user | `POST /grn/:grnId/attachments` | Invoice PDF, LR copy, photos (PDF/JPEG/PNG/WebP) |
+| 6 | Any authorized user | `GET .../attachments/:id/download` | RBAC-protected download — not public `/uploads` |
+| 7 | GRN detail | `GET /grn/:id` | Includes `attachments[]` metadata |
+
+**Typical attachment types:** vendor tax invoice, LR/eway bill, packing list, QC rejection photos, delivery challan.
+
### GRN auto-asset creation (when `items.is_asset_item = true`)
| Field on Asset | Source |
@@ -568,6 +625,7 @@ erDiagram
purchase_orders ||--o{ assets : sourced_from
grn ||--o{ grn_items : lines
+ grn ||--o{ grn_attachments : documents
grn_items ||--o{ assets : auto_created
items ||--o{ purchase_order_items : ordered
@@ -622,6 +680,7 @@ erDiagram
| Create PO | Masters dropdowns + `POST /purchase-orders` |
| Approve PO | `POST /purchase-orders/:id/approve` |
| GRN receipt | `POST /grn` (auto assets for asset items) |
+| GRN attachments | `POST /grn/:grnId/attachments`, `GET .../download` |
| Asset list/detail | `GET /assets`, `GET /assets/:id` |
| Asset create/edit form | Masters + `POST /depreciation/calculate` (preview) + `POST/PUT /assets` |
| AMC tab | `/assets/:id/amc/*` + renew |
diff --git a/src/docs/grn-routes.yaml b/src/docs/grn-routes.yaml
index 85e7799..e1a38db 100644
--- a/src/docs/grn-routes.yaml
+++ b/src/docs/grn-routes.yaml
@@ -60,6 +60,22 @@ components:
required: [cancellation_reason]
properties:
cancellation_reason: { type: string, example: 'Posted against wrong PO' }
+ GrnAttachmentResponse:
+ type: object
+ properties:
+ id: { type: string, example: '1' }
+ grn_id: { type: string, example: '1' }
+ file_name: { type: string, example: 'vendor-invoice.pdf' }
+ file_type: { type: string, example: 'application/pdf' }
+ file_size: { type: integer, example: 245760 }
+ uploaded_by_user:
+ type: object
+ nullable: true
+ properties:
+ id: { type: string }
+ full_name: { type: string }
+ employee_code: { type: string, nullable: true }
+ created_at: { type: string, format: date-time }
paths:
/grn:
@@ -154,3 +170,98 @@ paths:
content:
application/pdf:
schema: { type: string, format: binary }
+ /grn/{grnId}/attachments:
+ get:
+ tags: [GRN]
+ summary: List GRN attachments
+ description: Returns supporting documents for a GRN (vendor invoice, LR copy, delivery photos, etc.)
+ parameters:
+ - { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
+ responses:
+ '200':
+ description: Attachments fetched
+ content:
+ application/json:
+ schema:
+ allOf:
+ - { $ref: '#/components/schemas/ApiResponse' }
+ - type: object
+ properties:
+ data:
+ type: array
+ items: { $ref: '#/components/schemas/GrnAttachmentResponse' }
+ '404': { description: GRN not found }
+ post:
+ tags: [GRN]
+ summary: Upload GRN attachment
+ description: Multipart upload. Allowed types PDF, JPEG, PNG, WebP. Only POSTED GRN accepts uploads.
+ parameters:
+ - { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required: [file]
+ properties:
+ file:
+ type: string
+ format: binary
+ description: Vendor invoice, LR copy, packing list, or receipt photo
+ responses:
+ '201':
+ description: Attachment uploaded
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ApiResponse' }
+ '400': { description: Missing file or unsupported MIME type }
+ '409': { description: GRN is not POSTED }
+ /grn/{grnId}/attachments/{attachmentId}:
+ get:
+ tags: [GRN]
+ summary: Get GRN attachment metadata
+ parameters:
+ - { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
+ - { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } }
+ responses:
+ '200':
+ description: Attachment fetched
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ApiResponse' }
+ '404': { description: Not found }
+ delete:
+ tags: [GRN]
+ summary: Delete GRN attachment
+ description: Removes DB record and file from disk. Only POSTED GRN.
+ parameters:
+ - { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
+ - { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } }
+ responses:
+ '200':
+ description: Attachment deleted
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ApiResponse' }
+ '404': { description: Not found }
+ '409': { description: GRN is not POSTED }
+ /grn/{grnId}/attachments/{attachmentId}/download:
+ get:
+ tags: [GRN]
+ summary: Download GRN attachment file
+ description: Authenticated file download (do not use public /uploads URL)
+ parameters:
+ - { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
+ - { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } }
+ responses:
+ '200':
+ description: File stream
+ content:
+ application/pdf:
+ schema: { type: string, format: binary }
+ image/jpeg:
+ schema: { type: string, format: binary }
+ image/png:
+ schema: { type: string, format: binary }
+ '404': { description: Not found }
diff --git a/src/modules/grn/grn.attachments.service.js b/src/modules/grn/grn.attachments.service.js
new file mode 100644
index 0000000..298eb7c
--- /dev/null
+++ b/src/modules/grn/grn.attachments.service.js
@@ -0,0 +1,150 @@
+const fs = require('fs');
+const path = require('path');
+const prisma = require('../../config/prisma');
+const ApiError = require('../../utils/ApiError');
+const auditLog = require('../../utils/auditLog');
+const env = require('../../config/env');
+
+const attachmentInclude = {
+ users: { select: { id: true, full_name: true, employee_code: true } },
+};
+
+const sanitizeAttachment = (row) => {
+ if (!row) return null;
+ const { users, ...rest } = row;
+ return {
+ ...rest,
+ uploaded_by_user: users || null,
+ users: undefined,
+ };
+};
+
+const getGrnOrThrow = async (grnId) => {
+ const grn = await prisma.grn.findFirst({
+ where: { id: BigInt(grnId), deleted_at: null },
+ });
+ if (!grn) throw new ApiError(404, 'GRN not found');
+ return grn;
+};
+
+const getAttachmentOrThrow = async (grnId, attachmentId) => {
+ const row = await prisma.grn_attachments.findFirst({
+ where: {
+ id: BigInt(attachmentId),
+ grn_id: BigInt(grnId),
+ },
+ include: attachmentInclude,
+ });
+ if (!row) throw new ApiError(404, 'GRN attachment not found');
+ return row;
+};
+
+const assertGrnAllowsAttachments = (grn) => {
+ if (grn.status !== 'POSTED') {
+ throw new ApiError(409, 'Attachments can only be managed on POSTED GRN');
+ }
+};
+
+const resolveFilePath = (storedPath) => {
+ const absolute = path.isAbsolute(storedPath)
+ ? storedPath
+ : path.resolve(env.UPLOAD_DIR, storedPath);
+ const uploadRoot = path.resolve(env.UPLOAD_DIR);
+ if (!absolute.startsWith(uploadRoot)) {
+ throw new ApiError(400, 'Invalid attachment file path');
+ }
+ return absolute;
+};
+
+const listGrnAttachments = async (grnId) => {
+ await getGrnOrThrow(grnId);
+ const rows = await prisma.grn_attachments.findMany({
+ where: { grn_id: BigInt(grnId) },
+ include: attachmentInclude,
+ orderBy: { created_at: 'desc' },
+ });
+ return rows.map(sanitizeAttachment);
+};
+
+const uploadGrnAttachment = async (grnId, file, userId, requestId) => {
+ const grn = await getGrnOrThrow(grnId);
+ assertGrnAllowsAttachments(grn);
+
+ if (!file) throw new ApiError(400, 'file is required');
+
+ const relativePath = path.relative(path.resolve(env.UPLOAD_DIR), file.path);
+ const created = await prisma.grn_attachments.create({
+ data: {
+ grn_id: BigInt(grnId),
+ file_name: file.originalname,
+ file_path: relativePath.split(path.sep).join('/'),
+ file_type: file.mimetype,
+ file_size: file.size,
+ uploaded_by: userId ? BigInt(userId) : null,
+ },
+ include: attachmentInclude,
+ });
+
+ await auditLog({
+ tableName: 'grn_attachments',
+ recordId: created.id,
+ action: 'CREATE',
+ oldValue: null,
+ newValue: sanitizeAttachment(created),
+ userId,
+ requestId,
+ });
+
+ return sanitizeAttachment(created);
+};
+
+const getGrnAttachmentById = async (grnId, attachmentId) =>
+ sanitizeAttachment(await getAttachmentOrThrow(grnId, attachmentId));
+
+const downloadGrnAttachment = async (grnId, attachmentId) => {
+ await getGrnOrThrow(grnId);
+ const attachment = await getAttachmentOrThrow(grnId, attachmentId);
+ const absolutePath = resolveFilePath(attachment.file_path);
+
+ if (!fs.existsSync(absolutePath)) {
+ throw new ApiError(404, 'Attachment file not found on server');
+ }
+
+ return {
+ attachment: sanitizeAttachment(attachment),
+ absolutePath,
+ };
+};
+
+const deleteGrnAttachment = async (grnId, attachmentId, userId, requestId) => {
+ const grn = await getGrnOrThrow(grnId);
+ assertGrnAllowsAttachments(grn);
+
+ const existing = await getAttachmentOrThrow(grnId, attachmentId);
+ const absolutePath = resolveFilePath(existing.file_path);
+
+ await prisma.grn_attachments.delete({ where: { id: existing.id } });
+
+ if (fs.existsSync(absolutePath)) {
+ fs.unlinkSync(absolutePath);
+ }
+
+ await auditLog({
+ tableName: 'grn_attachments',
+ recordId: attachmentId,
+ action: 'DELETE',
+ oldValue: sanitizeAttachment(existing),
+ newValue: null,
+ userId,
+ requestId,
+ });
+};
+
+module.exports = {
+ listGrnAttachments,
+ uploadGrnAttachment,
+ getGrnAttachmentById,
+ downloadGrnAttachment,
+ deleteGrnAttachment,
+ sanitizeAttachment,
+};
diff --git a/src/modules/grn/grn.controller.js b/src/modules/grn/grn.controller.js
index 2cb5e16..3fc888a 100644
--- a/src/modules/grn/grn.controller.js
+++ b/src/modules/grn/grn.controller.js
@@ -1,6 +1,8 @@
const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const service = require('./grn.service');
+const attachmentService = require('./grn.attachments.service');
+const path = require('path');
const create = asyncHandler(async (req, res) => {
const data = await service.createGrn(req.body, req.user?.id, req.id);
@@ -34,4 +36,62 @@ const pdf = asyncHandler(async (req, res) => {
res.send(buffer);
});
-module.exports = { create, list, getOne, update, cancel, pdf };
+const listAttachments = asyncHandler(async (req, res) => {
+ const data = await attachmentService.listGrnAttachments(req.params.grnId);
+ res.json(new ApiResponse(200, data, 'GRN attachments fetched'));
+});
+
+const uploadAttachment = asyncHandler(async (req, res) => {
+ const data = await attachmentService.uploadGrnAttachment(
+ req.params.grnId,
+ req.file,
+ req.user?.id,
+ req.id
+ );
+ res.status(201).json(new ApiResponse(201, data, 'GRN attachment uploaded successfully'));
+});
+
+const getAttachment = asyncHandler(async (req, res) => {
+ const data = await attachmentService.getGrnAttachmentById(
+ req.params.grnId,
+ req.params.attachmentId
+ );
+ res.json(new ApiResponse(200, data, 'GRN attachment fetched'));
+});
+
+const downloadAttachment = asyncHandler(async (req, res) => {
+ const { attachment, absolutePath } = await attachmentService.downloadGrnAttachment(
+ req.params.grnId,
+ req.params.attachmentId
+ );
+ res.setHeader('Content-Type', attachment.file_type || 'application/octet-stream');
+ res.setHeader(
+ 'Content-Disposition',
+ `attachment; filename="${path.basename(attachment.file_name)}"`
+ );
+ res.sendFile(absolutePath);
+});
+
+const removeAttachment = asyncHandler(async (req, res) => {
+ await attachmentService.deleteGrnAttachment(
+ req.params.grnId,
+ req.params.attachmentId,
+ req.user?.id,
+ req.id
+ );
+ res.json(new ApiResponse(200, null, 'GRN attachment deleted successfully'));
+});
+
+module.exports = {
+ create,
+ list,
+ getOne,
+ update,
+ cancel,
+ pdf,
+ listAttachments,
+ uploadAttachment,
+ getAttachment,
+ downloadAttachment,
+ removeAttachment,
+};
diff --git a/src/modules/grn/grn.routes.js b/src/modules/grn/grn.routes.js
index ce60b2b..7736025 100644
--- a/src/modules/grn/grn.routes.js
+++ b/src/modules/grn/grn.routes.js
@@ -3,6 +3,7 @@ const authenticate = require('../../middlewares/auth.middleware');
const authorize = require('../../middlewares/rbac.middleware');
const validate = require('../../middlewares/validate.middleware');
const controller = require('./grn.controller');
+const { grnAttachmentUpload } = require('./grn.upload.middleware');
const {
createGrnSchema,
updateGrnSchema,
@@ -20,6 +21,29 @@ router.post('/', authorize('GRN', 'create'), validate(createGrnSchema), controll
router.post('/:id/cancel', authorize('GRN', 'edit'), validate(cancelGrnSchema), controller.cancel);
router.get('/:id/pdf', authorize('GRN', 'view'), controller.pdf);
+router.get('/:grnId/attachments', authorize('GRN', 'view'), controller.listAttachments);
+router.post(
+ '/:grnId/attachments',
+ authorize('GRN', 'edit'),
+ grnAttachmentUpload.single('file'),
+ controller.uploadAttachment
+);
+router.get(
+ '/:grnId/attachments/:attachmentId',
+ authorize('GRN', 'view'),
+ controller.getAttachment
+);
+router.get(
+ '/:grnId/attachments/:attachmentId/download',
+ authorize('GRN', 'view'),
+ controller.downloadAttachment
+);
+router.delete(
+ '/:grnId/attachments/:attachmentId',
+ authorize('GRN', 'delete'),
+ controller.removeAttachment
+);
+
router.get('/:id', authorize('GRN', 'view'), controller.getOne);
router.put('/:id', authorize('GRN', 'edit'), validate(updateGrnSchema), controller.update);
diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js
index ea3aac7..3e634a0 100644
--- a/src/modules/grn/grn.service.js
+++ b/src/modules/grn/grn.service.js
@@ -6,6 +6,7 @@ const { nextDocumentNumber } = require('../../utils/generateCode');
const { buildSimplePdf } = require('../../utils/simplePdf');
const repository = require('./grn.repository');
const { assertWarehouse } = require('../../utils/locations');
+const { sanitizeAttachment } = require('./grn.attachments.service');
const grnListInclude = {
purchase_orders: { select: { id: true, po_number: true, status: true } },
@@ -30,6 +31,12 @@ const grnDetailInclude = {
},
},
},
+ grn_attachments: {
+ orderBy: { created_at: 'desc' },
+ include: {
+ users: { select: { id: true, full_name: true, employee_code: true } },
+ },
+ },
};
const toDateOnly = (value) => {
@@ -54,6 +61,7 @@ const sanitizeGrn = (row) => {
users_grn_updated_byTousers,
users_grn_cancelled_byTousers,
grn_items,
+ grn_attachments,
...rest
} = row;
@@ -75,7 +83,9 @@ const sanitizeGrn = (row) => {
items: undefined,
purchase_order_items: undefined,
})),
+ attachments: (grn_attachments || []).map(sanitizeAttachment),
grn_items: undefined,
+ grn_attachments: undefined,
purchase_orders: undefined,
vendors: undefined,
warehouses: undefined,
diff --git a/src/modules/grn/grn.upload.middleware.js b/src/modules/grn/grn.upload.middleware.js
new file mode 100644
index 0000000..ce2094d
--- /dev/null
+++ b/src/modules/grn/grn.upload.middleware.js
@@ -0,0 +1,31 @@
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const ApiError = require('../../utils/ApiError');
+const env = require('../../config/env');
+
+const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
+
+const grnAttachmentUpload = multer({
+ storage: multer.diskStorage({
+ destination: (req, _file, cb) => {
+ const dir = path.join(env.UPLOAD_DIR, 'grn', String(req.params.grnId));
+ fs.mkdirSync(dir, { recursive: true });
+ cb(null, dir);
+ },
+ filename: (_req, file, cb) => {
+ const unique = crypto.randomBytes(16).toString('hex');
+ cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
+ },
+ }),
+ fileFilter: (_req, file, cb) => {
+ if (!ALLOWED_MIME.includes(file.mimetype)) {
+ return cb(new ApiError(400, `Unsupported file type: ${file.mimetype}`), false);
+ }
+ return cb(null, true);
+ },
+ limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
+});
+
+module.exports = { grnAttachmentUpload };