const prisma = require('../../config/prisma'); const ApiError = require('../../utils/ApiError'); const auditLog = require('../../utils/auditLog'); const { getPagination, isDropdownCall } = require('../../utils/pagination'); const { MAINTENANCE_CHECKLIST_STATUSES } = require('./assets.constants'); const { toDateOnly, getAssetOrThrow } = require('./assets.helpers'); const { sanitizeAsset } = require('./assets.service'); const logInclude = { users_asset_maintenance_logs_performed_byTousers: { select: { id: true, full_name: true, employee_code: true }, }, users_asset_maintenance_logs_created_byTousers: { select: { id: true, full_name: true }, }, }; const assetListInclude = { item_categories: { select: { id: true, code: true, name: true } }, item_subcategories: { select: { id: true, code: true, name: true } }, location: { select: { id: true, code: true, name: true, type: true } }, departments: { select: { id: true, name: true } }, users_assets_assigned_to_user_idTousers: { select: { id: true, full_name: true, employee_code: true }, }, users_assets_maintenance_incharge_idTousers: { select: { id: true, full_name: true, employee_code: true }, }, asset_maintenance_logs: { where: { deleted_at: null }, orderBy: [{ performed_date: 'desc' }, { created_at: 'desc' }], take: 1, select: { id: true, performed_date: true, next_due_date: true }, }, }; const sanitizeLog = (row) => { if (!row) return null; const { users_asset_maintenance_logs_performed_byTousers, users_asset_maintenance_logs_created_byTousers, ...rest } = row; return { ...rest, performed_by_user: users_asset_maintenance_logs_performed_byTousers || null, created_by_user: users_asset_maintenance_logs_created_byTousers || null, }; }; const startOfUtcDay = (value) => { const d = new Date(value); if (Number.isNaN(d.getTime())) return null; return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); }; const addDays = (value, days) => { const base = startOfUtcDay(value); if (!base || days === null || days === undefined) return null; base.setUTCDate(base.getUTCDate() + Number(days)); return base; }; const normalizeChecklistTemplate = (template) => { if (template === null || template === undefined) return null; if (!Array.isArray(template)) { throw new ApiError(422, 'maintenance_checklist_json must be an array'); } const keys = new Set(); return template.map((item, index) => { const key = String(item.key || '').trim(); const label = String(item.label || '').trim(); if (!key) throw new ApiError(422, `Checklist item ${index + 1}: key is required`); if (!label) throw new ApiError(422, `Checklist item ${index + 1}: label is required`); if (keys.has(key)) throw new ApiError(422, `Duplicate checklist key: ${key}`); keys.add(key); return { key, label, required: item.required !== false, }; }); }; const validateChecklistResults = (template, results) => { if (!Array.isArray(results) || !results.length) { throw new ApiError(422, 'checklist_json is required and must contain at least one item'); } const templateItems = Array.isArray(template) ? template : []; const templateMap = new Map(templateItems.map((item) => [item.key, item])); const resultKeys = new Set(); const normalized = []; for (const row of results) { const key = String(row.key || '').trim(); if (!key) throw new ApiError(422, 'Each checklist result requires a key'); if (resultKeys.has(key)) throw new ApiError(422, `Duplicate checklist result key: ${key}`); resultKeys.add(key); if (!MAINTENANCE_CHECKLIST_STATUSES.includes(row.status)) { throw new ApiError(422, `Invalid checklist status for ${key}`); } const templateItem = templateMap.get(key); normalized.push({ key, label: row.label || templateItem?.label || key, status: row.status, remarks: row.remarks || null, }); } for (const item of templateItems) { if (item.required !== false && !resultKeys.has(item.key)) { throw new ApiError(422, `Required checklist item missing: ${item.key}`); } } return normalized; }; const assertInchargeOrThrow = (asset, userId) => { if (!userId) throw new ApiError(403, 'Authentication required'); if ( !asset.maintenance_incharge_user_id || asset.maintenance_incharge_user_id.toString() !== BigInt(userId).toString() ) { throw new ApiError(403, 'Only the maintenance incharge can perform this action'); } }; /** * Assets assigned to the logged-in user as maintenance incharge. * Optional due_only=true returns only assets whose next maintenance is due/overdue. */ const listMyMaintenanceAssets = async (query, userId) => { if (!userId) throw new ApiError(403, 'Authentication required'); const where = { deleted_at: null, maintenance_incharge_user_id: BigInt(userId), ...(query.status ? { status: query.status } : {}), ...(query.location_id ? { location_id: BigInt(query.location_id) } : {}), ...(query.is_active !== undefined ? { is_active: query.is_active } : { is_active: true }), ...(query.search ? { OR: [ { asset_code: { contains: query.search, mode: 'insensitive' } }, { asset_name: { contains: query.search, mode: 'insensitive' } }, { serial_number: { contains: query.search, mode: 'insensitive' } }, ], } : {}), }; const rows = await prisma.assets.findMany({ where, include: assetListInclude, orderBy: { asset_code: 'asc' }, }); let data = rows.map(sanitizeAsset); if (query.due_only === true) { data = data.filter((row) => row.maintenance?.is_due === true); } if (isDropdownCall(query)) { return { data, meta: { page: 1, limit: data.length, total: data.length, dropdown: true } }; } const { page, limit, skip } = getPagination(query); const total = data.length; const paged = data.slice(skip, skip + limit); return { data: paged, meta: { page, limit, total } }; }; const listMaintenanceLogs = async (assetId, query) => { await getAssetOrThrow(assetId); const { page, limit, skip } = getPagination(query); const where = { asset_id: BigInt(assetId), deleted_at: null }; const [rows, total] = await Promise.all([ prisma.asset_maintenance_logs.findMany({ where, include: logInclude, orderBy: [{ performed_date: 'desc' }, { created_at: 'desc' }], skip, take: limit, }), prisma.asset_maintenance_logs.count({ where }), ]); return { data: rows.map(sanitizeLog), meta: { page, limit, total } }; }; const getMaintenanceLog = async (assetId, logId) => { await getAssetOrThrow(assetId); const row = await prisma.asset_maintenance_logs.findFirst({ where: { id: BigInt(logId), asset_id: BigInt(assetId), deleted_at: null, }, include: logInclude, }); if (!row) throw new ApiError(404, 'Maintenance log not found'); return sanitizeLog(row); }; const createMaintenanceLog = async (assetId, payload, userId, requestId) => { const asset = await getAssetOrThrow(assetId); assertInchargeOrThrow(asset, userId); if (!asset.maintenance_frequency_in_days) { throw new ApiError(422, 'Asset has no maintenance_frequency_in_days configured'); } const template = normalizeChecklistTemplate(asset.maintenance_checklist_json); if (!template || !template.length) { throw new ApiError(422, 'Asset has no maintenance_checklist_json configured'); } const checklist = validateChecklistResults(template, payload.checklist_json); const performedDate = toDateOnly(payload.performed_date); const nextDueDate = payload.next_due_date ? toDateOnly(payload.next_due_date) : addDays(performedDate, asset.maintenance_frequency_in_days); const created = await prisma.asset_maintenance_logs.create({ data: { asset_id: BigInt(assetId), performed_by: userId ? BigInt(userId) : null, performed_date: performedDate, next_due_date: nextDueDate, checklist_json: checklist, remarks: payload.remarks || null, created_by: userId ? BigInt(userId) : null, updated_by: userId ? BigInt(userId) : null, }, include: logInclude, }); const sanitized = sanitizeLog(created); await auditLog({ tableName: 'asset_maintenance_logs', recordId: created.id, action: 'CREATE', oldValue: null, newValue: sanitized, userId, requestId, }); return sanitized; }; const deleteMaintenanceLog = async (assetId, logId, userId, requestId) => { const asset = await getAssetOrThrow(assetId); assertInchargeOrThrow(asset, userId); const existing = await prisma.asset_maintenance_logs.findFirst({ where: { id: BigInt(logId), asset_id: BigInt(assetId), deleted_at: null, }, include: logInclude, }); if (!existing) throw new ApiError(404, 'Maintenance log not found'); const deleted = await prisma.asset_maintenance_logs.update({ where: { id: existing.id }, data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null, }, }); await auditLog({ tableName: 'asset_maintenance_logs', recordId: logId, action: 'DELETE', oldValue: sanitizeLog(existing), newValue: deleted, userId, requestId, }); }; module.exports = { listMyMaintenanceAssets, listMaintenanceLogs, getMaintenanceLog, createMaintenanceLog, deleteMaintenanceLog, normalizeChecklistTemplate, };