GWM : po,assets attachments api

This commit is contained in:
Gowtham M 2026-07-14 13:59:59 +05:30
parent 710b0b33c6
commit 9526870bd8
16 changed files with 802 additions and 0 deletions

View File

@ -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`)

View File

@ -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 } }

View File

@ -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 }

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

View File

@ -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,

View File

@ -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,
};

View File

@ -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',

View File

@ -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,
};
};

View File

@ -0,0 +1,7 @@
const { createAttachmentUpload } = require('../../utils/createAttachmentUpload.middleware');
const assetAttachmentUpload = createAttachmentUpload((req) =>
['assets', String(req.params.assetId)].join('/')
);
module.exports = { assetAttachmentUpload };

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

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

View File

@ -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,
};

View File

@ -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',

View File

@ -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,

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

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