diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index 61865d2..6762d12 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -233,6 +233,11 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | [x] | POST | `/purchase-orders/:id/amend` | edit | Amend PO | | [x] | POST | `/purchase-orders/:id/cancel` | edit | Cancel PO | | [x] | GET | `/purchase-orders/:id/pdf` | view | PDF export | +| [x] | GET | `/purchase-orders/:poId/attachments` | view | List PO attachments | +| [x] | POST | `/purchase-orders/:poId/attachments` | edit | Upload file (multipart `file`) | +| [x] | GET | `/purchase-orders/:poId/attachments/:attachmentId` | view | Attachment metadata | +| [x] | GET | `/purchase-orders/:poId/attachments/:attachmentId/download` | view | Download file (authenticated) | +| [x] | DELETE | `/purchase-orders/:poId/attachments/:attachmentId` | delete | Delete attachment + file | --- @@ -265,6 +270,11 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | [x] | DELETE | `/assets/:id` | delete | Soft delete | | [x] | POST | `/assets/:id/transfer` | edit | Transfer asset | | [x] | GET | `/assets/:id/transfer-history` | view | Transfer history | +| [x] | GET | `/assets/:assetId/attachments` | view | List asset attachments | +| [x] | POST | `/assets/:assetId/attachments` | edit | Upload file (multipart `file`; optional `attachment_type`, AMC/visit/insurance link) | +| [x] | GET | `/assets/:assetId/attachments/:attachmentId` | view | Attachment metadata | +| [x] | GET | `/assets/:assetId/attachments/:attachmentId/download` | view | Download file (authenticated) | +| [x] | DELETE | `/assets/:assetId/attachments/:attachmentId` | delete | Delete attachment + file | **AMC contracts** (`/assets/:id/amc`) diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 97cee28..8a385be 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -271,6 +271,29 @@ components: premium_paid: { type: boolean } premium_paid_date: { type: string, format: date, nullable: true } remarks: { type: string } + AssetAttachmentResponse: + type: object + properties: + id: { type: string, example: '1' } + asset_id: { type: string, example: '1' } + attachment_type: + type: string + enum: [DOCUMENT, PHOTO, WARRANTY, INVOICE, AMC_CONTRACT, SERVICE_REPORT, INSURANCE_POLICY, OTHER] + example: DOCUMENT + amc_contract_id: { type: string, nullable: true, example: '1' } + service_visit_id: { type: string, nullable: true, example: '1' } + insurance_id: { type: string, nullable: true, example: '1' } + file_name: { type: string, example: 'warranty-card.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: /assets/options: @@ -485,6 +508,99 @@ paths: '200': description: Transfer history fetched content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } + /assets/{assetId}/attachments: + get: + tags: [Assets] + summary: List asset attachments + parameters: + - { name: assetId, 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/AssetAttachmentResponse' } + '404': { description: Asset not found } + post: + tags: [Assets] + summary: Upload asset attachment + description: Multipart upload. Optional fields link file to AMC, service visit, or insurance policy. + parameters: + - { name: assetId, 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 + attachment_type: + type: string + enum: [DOCUMENT, PHOTO, WARRANTY, INVOICE, AMC_CONTRACT, SERVICE_REPORT, INSURANCE_POLICY, OTHER] + default: DOCUMENT + amc_contract_id: { type: integer, nullable: true } + service_visit_id: { type: integer, nullable: true } + insurance_id: { type: integer, nullable: true } + responses: + '201': + description: Attachment uploaded + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + '400': { description: Invalid attachment_type or missing file } + '404': { description: Asset or linked record not found } + /assets/{assetId}/attachments/{attachmentId}: + get: + tags: [Assets] + summary: Get asset attachment metadata + parameters: + - { name: assetId, 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: [Assets] + summary: Delete asset attachment + parameters: + - { name: assetId, 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 } + /assets/{assetId}/attachments/{attachmentId}/download: + get: + tags: [Assets] + summary: Download asset attachment file + parameters: + - { name: assetId, in: path, required: true, schema: { type: string, example: '1' } } + - { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: File download + content: + application/octet-stream: + schema: { type: string, format: binary } + '404': { description: Not found } /assets/{id}/amc: parameters: - { name: id, in: path, required: true, schema: { type: string } } diff --git a/src/docs/purchase-orders-routes.yaml b/src/docs/purchase-orders-routes.yaml index e8ac2ca..10753c9 100644 --- a/src/docs/purchase-orders-routes.yaml +++ b/src/docs/purchase-orders-routes.yaml @@ -79,6 +79,22 @@ components: required: [remarks] properties: remarks: { type: string, example: 'Rates not competitive' } + PoAttachmentResponse: + type: object + properties: + id: { type: string, example: '1' } + po_id: { type: string, example: '1' } + file_name: { type: string, example: 'vendor-quote.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: /purchase-orders: @@ -247,3 +263,88 @@ paths: content: application/pdf: schema: { type: string, format: binary } + /purchase-orders/{poId}/attachments: + get: + tags: [Purchase Orders] + summary: List purchase order attachments + parameters: + - { name: poId, 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/PoAttachmentResponse' } + '404': { description: Purchase order not found } + post: + tags: [Purchase Orders] + summary: Upload purchase order attachment + description: Multipart upload. Allowed types PDF, JPEG, PNG, WebP. Not allowed on cancelled PO. + parameters: + - { name: poId, 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 + responses: + '201': + description: Attachment uploaded + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + '409': { description: Purchase order is cancelled } + /purchase-orders/{poId}/attachments/{attachmentId}: + get: + tags: [Purchase Orders] + summary: Get purchase order attachment metadata + parameters: + - { name: poId, 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: [Purchase Orders] + summary: Delete purchase order attachment + parameters: + - { name: poId, 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' } + '409': { description: Purchase order is cancelled } + /purchase-orders/{poId}/attachments/{attachmentId}/download: + get: + tags: [Purchase Orders] + summary: Download purchase order attachment file + parameters: + - { name: poId, in: path, required: true, schema: { type: string, example: '1' } } + - { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: File download + content: + application/octet-stream: + schema: { type: string, format: binary } + '404': { description: Not found } diff --git a/src/modules/assets/assets.attachments.service.js b/src/modules/assets/assets.attachments.service.js new file mode 100644 index 0000000..a95e275 --- /dev/null +++ b/src/modules/assets/assets.attachments.service.js @@ -0,0 +1,164 @@ +const path = require('path'); +const fs = require('fs'); +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const env = require('../../config/env'); +const { ASSET_ATTACHMENT_TYPES } = require('./assets.constants'); +const { + attachmentInclude, + sanitizeAttachment, + resolveFilePath, + unlinkIfExists, +} = require('../../utils/attachment.helpers'); + +const getAssetOrThrow = async (assetId) => { + const asset = await prisma.assets.findFirst({ + where: { id: BigInt(assetId), deleted_at: null }, + }); + if (!asset) throw new ApiError(404, 'Asset not found'); + return asset; +}; + +const getAttachmentOrThrow = async (assetId, attachmentId) => { + const row = await prisma.asset_attachments.findFirst({ + where: { + id: BigInt(attachmentId), + asset_id: BigInt(assetId), + }, + include: attachmentInclude, + }); + if (!row) throw new ApiError(404, 'Asset attachment not found'); + return row; +}; + +const normalizeUploadMeta = async (assetId, body = {}) => { + const attachmentType = body.attachment_type || 'DOCUMENT'; + if (!ASSET_ATTACHMENT_TYPES.includes(attachmentType)) { + throw new ApiError(400, `Invalid attachment_type: ${attachmentType}`); + } + + const amcContractId = body.amc_contract_id ? BigInt(body.amc_contract_id) : null; + const serviceVisitId = body.service_visit_id ? BigInt(body.service_visit_id) : null; + const insuranceId = body.insurance_id ? BigInt(body.insurance_id) : null; + + if (amcContractId) { + const row = await prisma.asset_amc_contracts.findFirst({ + where: { id: amcContractId, asset_id: BigInt(assetId) }, + }); + if (!row) throw new ApiError(404, 'AMC contract not found for this asset'); + } + + if (serviceVisitId) { + const row = await prisma.asset_service_visits.findFirst({ + where: { id: serviceVisitId, asset_id: BigInt(assetId) }, + }); + if (!row) throw new ApiError(404, 'Service visit not found for this asset'); + } + + if (insuranceId) { + const row = await prisma.asset_insurance_policies.findFirst({ + where: { id: insuranceId, asset_id: BigInt(assetId) }, + }); + if (!row) throw new ApiError(404, 'Insurance policy not found for this asset'); + } + + return { + attachment_type: attachmentType, + amc_contract_id: amcContractId, + service_visit_id: serviceVisitId, + insurance_id: insuranceId, + }; +}; + +const listAssetAttachments = async (assetId) => { + await getAssetOrThrow(assetId); + const rows = await prisma.asset_attachments.findMany({ + where: { asset_id: BigInt(assetId) }, + include: attachmentInclude, + orderBy: { created_at: 'desc' }, + }); + return rows.map(sanitizeAttachment); +}; + +const uploadAssetAttachment = async (assetId, file, meta, userId, requestId) => { + await getAssetOrThrow(assetId); + if (!file) throw new ApiError(400, 'file is required'); + + const uploadMeta = await normalizeUploadMeta(assetId, meta); + const relativePath = path.relative(path.resolve(env.UPLOAD_DIR), file.path); + const created = await prisma.asset_attachments.create({ + data: { + asset_id: BigInt(assetId), + attachment_type: uploadMeta.attachment_type, + amc_contract_id: uploadMeta.amc_contract_id, + service_visit_id: uploadMeta.service_visit_id, + insurance_id: uploadMeta.insurance_id, + 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: 'asset_attachments', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeAttachment(created), + userId, + requestId, + }); + + return sanitizeAttachment(created); +}; + +const getAssetAttachmentById = async (assetId, attachmentId) => + sanitizeAttachment(await getAttachmentOrThrow(assetId, attachmentId)); + +const downloadAssetAttachment = async (assetId, attachmentId) => { + await getAssetOrThrow(assetId); + const attachment = await getAttachmentOrThrow(assetId, 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 deleteAssetAttachment = async (assetId, attachmentId, userId, requestId) => { + await getAssetOrThrow(assetId); + + const existing = await getAttachmentOrThrow(assetId, attachmentId); + const absolutePath = resolveFilePath(existing.file_path); + + await prisma.asset_attachments.delete({ where: { id: existing.id } }); + unlinkIfExists(absolutePath); + + await auditLog({ + tableName: 'asset_attachments', + recordId: attachmentId, + action: 'DELETE', + oldValue: sanitizeAttachment(existing), + newValue: null, + userId, + requestId, + }); +}; + +module.exports = { + listAssetAttachments, + uploadAssetAttachment, + getAssetAttachmentById, + downloadAssetAttachment, + deleteAssetAttachment, + sanitizeAttachment, +}; diff --git a/src/modules/assets/assets.constants.js b/src/modules/assets/assets.constants.js index f81edbb..7853c39 100644 --- a/src/modules/assets/assets.constants.js +++ b/src/modules/assets/assets.constants.js @@ -72,6 +72,17 @@ const INSURANCE_POLICY_TYPE_OPTIONS = toOptions([ ['OTHER', 'Other'], ]); +const ASSET_ATTACHMENT_TYPES = [ + 'DOCUMENT', + 'PHOTO', + 'WARRANTY', + 'INVOICE', + 'AMC_CONTRACT', + 'SERVICE_REPORT', + 'INSURANCE_POLICY', + 'OTHER', +]; + const ASSET_CONDITIONS = ASSET_CONDITION_OPTIONS.map((o) => o.value); const ASSET_STATUSES = ASSET_STATUS_OPTIONS.map((o) => o.value); const DEPRECIATION_METHODS = DEPRECIATION_METHOD_VALUES; @@ -103,6 +114,7 @@ const getAssetDropdownOptions = () => ({ module.exports = { ASSET_CONDITIONS, ASSET_STATUSES, + ASSET_ATTACHMENT_TYPES, DEPRECIATION_METHODS, DISPOSAL_STATUSES, AMC_CONTRACT_TYPES, diff --git a/src/modules/assets/assets.controller.js b/src/modules/assets/assets.controller.js index e8b9880..cd865b5 100644 --- a/src/modules/assets/assets.controller.js +++ b/src/modules/assets/assets.controller.js @@ -5,6 +5,8 @@ const amcService = require('./assets-amc.service'); const visitsService = require('./assets-service-visits.service'); const insuranceService = require('./assets-insurance.service'); const alertsService = require('./assets-alerts.service'); +const attachmentService = require('./assets.attachments.service'); +const path = require('path'); const create = asyncHandler(async (req, res) => { const data = await service.createAsset(req.body, req.user?.id, req.id); @@ -224,6 +226,53 @@ const calculateDepreciation = asyncHandler(async (req, res) => { res.json(new ApiResponse(200, data, 'Depreciation calculated')); }); +const listAttachments = asyncHandler(async (req, res) => { + const data = await attachmentService.listAssetAttachments(req.params.assetId); + res.json(new ApiResponse(200, data, 'Asset attachments fetched')); +}); + +const uploadAttachment = asyncHandler(async (req, res) => { + const data = await attachmentService.uploadAssetAttachment( + req.params.assetId, + req.file, + req.body, + req.user?.id, + req.id + ); + res.status(201).json(new ApiResponse(201, data, 'Asset attachment uploaded successfully')); +}); + +const getAttachment = asyncHandler(async (req, res) => { + const data = await attachmentService.getAssetAttachmentById( + req.params.assetId, + req.params.attachmentId + ); + res.json(new ApiResponse(200, data, 'Asset attachment fetched')); +}); + +const downloadAttachment = asyncHandler(async (req, res) => { + const { attachment, absolutePath } = await attachmentService.downloadAssetAttachment( + req.params.assetId, + 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.deleteAssetAttachment( + req.params.assetId, + req.params.attachmentId, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, null, 'Asset attachment deleted successfully')); +}); + module.exports = { create, list, @@ -261,4 +310,9 @@ module.exports = { renewInsurance, expiryAlerts, serviceAlerts, + listAttachments, + uploadAttachment, + getAttachment, + downloadAttachment, + removeAttachment, }; diff --git a/src/modules/assets/assets.routes.js b/src/modules/assets/assets.routes.js index 1225be9..e6c6b81 100644 --- a/src/modules/assets/assets.routes.js +++ b/src/modules/assets/assets.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('./assets.controller'); +const { assetAttachmentUpload } = require('./assets.upload.middleware'); const { createAssetSchema, updateAssetSchema, @@ -71,6 +72,33 @@ router.get( controller.listVisitConditionsAfter ); +router.get( + '/:assetId/attachments', + authorize('ASSET', 'view'), + controller.listAttachments +); +router.post( + '/:assetId/attachments', + authorize('ASSET', 'edit'), + assetAttachmentUpload.single('file'), + controller.uploadAttachment +); +router.get( + '/:assetId/attachments/:attachmentId', + authorize('ASSET', 'view'), + controller.getAttachment +); +router.get( + '/:assetId/attachments/:attachmentId/download', + authorize('ASSET', 'view'), + controller.downloadAttachment +); +router.delete( + '/:assetId/attachments/:attachmentId', + authorize('ASSET', 'delete'), + controller.removeAttachment +); + router.get('/:id/amc', authorize('ASSET', 'view'), controller.listAmc); router.post( '/:id/amc', diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index 6778b45..f6dbe42 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -12,6 +12,7 @@ const { calculateDepreciation, } = require('./assets.depreciation'); const { assertReference, toDateOnly } = require('./assets.helpers'); +const { sanitizeAttachment } = require('./assets.attachments.service'); const assetInclude = { item_categories: { @@ -40,6 +41,12 @@ const assetInclude = { const assetDetailInclude = { ...assetInclude, users_assets_updated_byTousers: { select: { id: true, full_name: true } }, + asset_attachments: { + orderBy: { created_at: 'desc' }, + include: { + users: { select: { id: true, full_name: true, employee_code: true } }, + }, + }, }; const transferInclude = { @@ -70,6 +77,7 @@ const sanitizeAsset = (asset) => { grn, users_assets_created_byTousers, users_assets_updated_byTousers, + asset_attachments, ...rest } = asset; @@ -102,6 +110,7 @@ const sanitizeAsset = (asset) => { grn: grn || null, created_by_user: users_assets_created_byTousers || null, updated_by_user: users_assets_updated_byTousers || null, + attachments: (asset_attachments || []).map(sanitizeAttachment), depreciation: calculateDepreciation({ depreciation_method: rest.depreciation_method, depreciation_rate: depreciationRate, @@ -118,6 +127,7 @@ const sanitizeAsset = (asset) => { purchase_orders: undefined, users_assets_created_byTousers: undefined, users_assets_updated_byTousers: undefined, + asset_attachments: undefined, }; }; diff --git a/src/modules/assets/assets.upload.middleware.js b/src/modules/assets/assets.upload.middleware.js new file mode 100644 index 0000000..3fe9834 --- /dev/null +++ b/src/modules/assets/assets.upload.middleware.js @@ -0,0 +1,7 @@ +const { createAttachmentUpload } = require('../../utils/createAttachmentUpload.middleware'); + +const assetAttachmentUpload = createAttachmentUpload((req) => + ['assets', String(req.params.assetId)].join('/') +); + +module.exports = { assetAttachmentUpload }; diff --git a/src/modules/purchase-orders/po.attachments.service.js b/src/modules/purchase-orders/po.attachments.service.js new file mode 100644 index 0000000..f83ea4c --- /dev/null +++ b/src/modules/purchase-orders/po.attachments.service.js @@ -0,0 +1,128 @@ +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, + sanitizeAttachment, + resolveFilePath, + unlinkIfExists, +} = require('../../utils/attachment.helpers'); + +const getPoOrThrow = async (poId) => { + const po = await prisma.purchase_orders.findFirst({ + where: { id: BigInt(poId), deleted_at: null }, + }); + if (!po) throw new ApiError(404, 'Purchase order not found'); + return po; +}; + +const assertPoAllowsAttachments = (po) => { + if (po.status === 'CANCELLED') { + throw new ApiError(409, 'Attachments cannot be managed on cancelled purchase order'); + } +}; + +const getAttachmentOrThrow = async (poId, attachmentId) => { + const row = await prisma.po_attachments.findFirst({ + where: { + id: BigInt(attachmentId), + po_id: BigInt(poId), + }, + include: attachmentInclude, + }); + if (!row) throw new ApiError(404, 'Purchase order attachment not found'); + return row; +}; + +const listPoAttachments = async (poId) => { + await getPoOrThrow(poId); + const rows = await prisma.po_attachments.findMany({ + where: { po_id: BigInt(poId) }, + include: attachmentInclude, + orderBy: { created_at: 'desc' }, + }); + return rows.map(sanitizeAttachment); +}; + +const uploadPoAttachment = async (poId, file, userId, requestId) => { + const po = await getPoOrThrow(poId); + assertPoAllowsAttachments(po); + + if (!file) throw new ApiError(400, 'file is required'); + + const relativePath = path.relative(path.resolve(env.UPLOAD_DIR), file.path); + const created = await prisma.po_attachments.create({ + data: { + po_id: BigInt(poId), + 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: 'po_attachments', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeAttachment(created), + userId, + requestId, + }); + + return sanitizeAttachment(created); +}; + +const getPoAttachmentById = async (poId, attachmentId) => + sanitizeAttachment(await getAttachmentOrThrow(poId, attachmentId)); + +const downloadPoAttachment = async (poId, attachmentId) => { + await getPoOrThrow(poId); + const attachment = await getAttachmentOrThrow(poId, 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 deletePoAttachment = async (poId, attachmentId, userId, requestId) => { + const po = await getPoOrThrow(poId); + assertPoAllowsAttachments(po); + + const existing = await getAttachmentOrThrow(poId, attachmentId); + const absolutePath = resolveFilePath(existing.file_path); + + await prisma.po_attachments.delete({ where: { id: existing.id } }); + unlinkIfExists(absolutePath); + + await auditLog({ + tableName: 'po_attachments', + recordId: attachmentId, + action: 'DELETE', + oldValue: sanitizeAttachment(existing), + newValue: null, + userId, + requestId, + }); +}; + +module.exports = { + listPoAttachments, + uploadPoAttachment, + getPoAttachmentById, + downloadPoAttachment, + deletePoAttachment, + sanitizeAttachment, +}; diff --git a/src/modules/purchase-orders/po.upload.middleware.js b/src/modules/purchase-orders/po.upload.middleware.js new file mode 100644 index 0000000..4e5a971 --- /dev/null +++ b/src/modules/purchase-orders/po.upload.middleware.js @@ -0,0 +1,7 @@ +const { createAttachmentUpload } = require('../../utils/createAttachmentUpload.middleware'); + +const poAttachmentUpload = createAttachmentUpload((req) => + ['purchase-orders', String(req.params.poId)].join('/') +); + +module.exports = { poAttachmentUpload }; diff --git a/src/modules/purchase-orders/purchase-orders.controller.js b/src/modules/purchase-orders/purchase-orders.controller.js index cc786a0..5b15a78 100644 --- a/src/modules/purchase-orders/purchase-orders.controller.js +++ b/src/modules/purchase-orders/purchase-orders.controller.js @@ -1,6 +1,8 @@ const asyncHandler = require('../../utils/asyncHandler'); const ApiResponse = require('../../utils/ApiResponse'); const service = require('./purchase-orders.service'); +const attachmentService = require('./po.attachments.service'); +const path = require('path'); const create = asyncHandler(async (req, res) => { const data = await service.createPurchaseOrder(req.body, req.user?.id, req.id); @@ -59,6 +61,52 @@ const pdf = asyncHandler(async (req, res) => { res.send(buffer); }); +const listAttachments = asyncHandler(async (req, res) => { + const data = await attachmentService.listPoAttachments(req.params.poId); + res.json(new ApiResponse(200, data, 'Purchase order attachments fetched')); +}); + +const uploadAttachment = asyncHandler(async (req, res) => { + const data = await attachmentService.uploadPoAttachment( + req.params.poId, + req.file, + req.user?.id, + req.id + ); + res.status(201).json(new ApiResponse(201, data, 'Purchase order attachment uploaded successfully')); +}); + +const getAttachment = asyncHandler(async (req, res) => { + const data = await attachmentService.getPoAttachmentById( + req.params.poId, + req.params.attachmentId + ); + res.json(new ApiResponse(200, data, 'Purchase order attachment fetched')); +}); + +const downloadAttachment = asyncHandler(async (req, res) => { + const { attachment, absolutePath } = await attachmentService.downloadPoAttachment( + req.params.poId, + 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.deletePoAttachment( + req.params.poId, + req.params.attachmentId, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, null, 'Purchase order attachment deleted successfully')); +}); + module.exports = { create, list, @@ -71,4 +119,9 @@ module.exports = { amend, cancel, pdf, + listAttachments, + uploadAttachment, + getAttachment, + downloadAttachment, + removeAttachment, }; diff --git a/src/modules/purchase-orders/purchase-orders.routes.js b/src/modules/purchase-orders/purchase-orders.routes.js index a0ccea4..a43e3d5 100644 --- a/src/modules/purchase-orders/purchase-orders.routes.js +++ b/src/modules/purchase-orders/purchase-orders.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('./purchase-orders.controller'); +const { poAttachmentUpload } = require('./po.upload.middleware'); const { createPurchaseOrderSchema, updatePurchaseOrderSchema, @@ -61,6 +62,33 @@ router.post( ); router.get('/:id/pdf', authorize('PURCHASE_ORDER', 'view'), controller.pdf); +router.get( + '/:poId/attachments', + authorize('PURCHASE_ORDER', 'view'), + controller.listAttachments +); +router.post( + '/:poId/attachments', + authorize('PURCHASE_ORDER', 'edit'), + poAttachmentUpload.single('file'), + controller.uploadAttachment +); +router.get( + '/:poId/attachments/:attachmentId', + authorize('PURCHASE_ORDER', 'view'), + controller.getAttachment +); +router.get( + '/:poId/attachments/:attachmentId/download', + authorize('PURCHASE_ORDER', 'view'), + controller.downloadAttachment +); +router.delete( + '/:poId/attachments/:attachmentId', + authorize('PURCHASE_ORDER', 'delete'), + controller.removeAttachment +); + router.get('/:id', authorize('PURCHASE_ORDER', 'view'), controller.getOne); router.put( '/:id', diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js index 720133a..73d0ec6 100644 --- a/src/modules/purchase-orders/purchase-orders.service.js +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -18,6 +18,7 @@ const { const { computeLineAmounts, computeHeaderTotals, toNum } = require('./purchase-orders.calculations'); const repository = require('./purchase-orders.repository'); const { assertPlant, assertWarehouse } = require('../../utils/locations'); +const { sanitizeAttachment } = require('./po.attachments.service'); const poListInclude = { vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, @@ -49,6 +50,12 @@ const poDetailInclude = { users: { select: { id: true, full_name: true } }, }, }, + po_attachments: { + orderBy: { created_at: 'desc' }, + include: { + users: { select: { id: true, full_name: true, employee_code: true } }, + }, + }, }; const poPdfInclude = { @@ -108,6 +115,7 @@ const sanitizePo = (po) => { purchase_orders, purchase_order_items, po_approvals, + po_attachments, ...rest } = po; @@ -139,8 +147,10 @@ const sanitizePo = (po) => { roles: undefined, users: undefined, })), + attachments: (po_attachments || []).map(sanitizeAttachment), purchase_order_items: undefined, po_approvals: undefined, + po_attachments: undefined, vendors: undefined, plants: undefined, warehouses: undefined, diff --git a/src/utils/attachment.helpers.js b/src/utils/attachment.helpers.js new file mode 100644 index 0000000..6b83b67 --- /dev/null +++ b/src/utils/attachment.helpers.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const ApiError = require('./ApiError'); +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 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 unlinkIfExists = (absolutePath) => { + if (fs.existsSync(absolutePath)) { + fs.unlinkSync(absolutePath); + } +}; + +module.exports = { + attachmentInclude, + sanitizeAttachment, + resolveFilePath, + unlinkIfExists, +}; diff --git a/src/utils/createAttachmentUpload.middleware.js b/src/utils/createAttachmentUpload.middleware.js new file mode 100644 index 0000000..4e87a65 --- /dev/null +++ b/src/utils/createAttachmentUpload.middleware.js @@ -0,0 +1,32 @@ +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const ApiError = require('./ApiError'); +const env = require('../config/env'); + +const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']; + +const createAttachmentUpload = (getRelativeDir) => + multer({ + storage: multer.diskStorage({ + destination: (req, _file, cb) => { + const dir = path.join(env.UPLOAD_DIR, getRelativeDir(req)); + 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 = { createAttachmentUpload, ALLOWED_MIME };