GWM : po,assets attachments api
This commit is contained in:
parent
710b0b33c6
commit
9526870bd8
@ -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`)
|
||||
|
||||
|
||||
@ -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 } }
|
||||
|
||||
@ -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 }
|
||||
|
||||
164
src/modules/assets/assets.attachments.service.js
Normal file
164
src/modules/assets/assets.attachments.service.js
Normal file
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
7
src/modules/assets/assets.upload.middleware.js
Normal file
7
src/modules/assets/assets.upload.middleware.js
Normal file
@ -0,0 +1,7 @@
|
||||
const { createAttachmentUpload } = require('../../utils/createAttachmentUpload.middleware');
|
||||
|
||||
const assetAttachmentUpload = createAttachmentUpload((req) =>
|
||||
['assets', String(req.params.assetId)].join('/')
|
||||
);
|
||||
|
||||
module.exports = { assetAttachmentUpload };
|
||||
128
src/modules/purchase-orders/po.attachments.service.js
Normal file
128
src/modules/purchase-orders/po.attachments.service.js
Normal file
@ -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,
|
||||
};
|
||||
7
src/modules/purchase-orders/po.upload.middleware.js
Normal file
7
src/modules/purchase-orders/po.upload.middleware.js
Normal file
@ -0,0 +1,7 @@
|
||||
const { createAttachmentUpload } = require('../../utils/createAttachmentUpload.middleware');
|
||||
|
||||
const poAttachmentUpload = createAttachmentUpload((req) =>
|
||||
['purchase-orders', String(req.params.poId)].join('/')
|
||||
);
|
||||
|
||||
module.exports = { poAttachmentUpload };
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
42
src/utils/attachment.helpers.js
Normal file
42
src/utils/attachment.helpers.js
Normal file
@ -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,
|
||||
};
|
||||
32
src/utils/createAttachmentUpload.middleware.js
Normal file
32
src/utils/createAttachmentUpload.middleware.js
Normal file
@ -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 };
|
||||
Loading…
Reference in New Issue
Block a user