diff --git a/scripts/patch-maintenance-checklist-drop-key.sql b/scripts/patch-maintenance-checklist-drop-key.sql new file mode 100644 index 0000000..e5c2671 --- /dev/null +++ b/scripts/patch-maintenance-checklist-drop-key.sql @@ -0,0 +1,58 @@ +-- Strip legacy `key` from maintenance checklist JSON (template + logs). +-- New shape: [{ "label": "...", "required": true }] +-- Idempotent: safe to re-run. + +BEGIN; + +-- Asset checklist templates +UPDATE assets +SET maintenance_checklist_json = sub.cleaned +FROM ( + SELECT + a.id, + jsonb_agg( + jsonb_strip_nulls( + jsonb_build_object( + 'label', COALESCE(NULLIF(trim(elem->>'label'), ''), NULLIF(trim(elem->>'key'), '')), + 'required', COALESCE((elem->>'required')::boolean, true) + ) + ) + ORDER BY ord + ) AS cleaned + FROM assets a + CROSS JOIN LATERAL jsonb_array_elements(a.maintenance_checklist_json) WITH ORDINALITY AS t(elem, ord) + WHERE a.maintenance_checklist_json IS NOT NULL + AND jsonb_typeof(a.maintenance_checklist_json) = 'array' + AND jsonb_array_length(a.maintenance_checklist_json) > 0 + GROUP BY a.id +) sub +WHERE assets.id = sub.id + AND sub.cleaned IS NOT NULL; + +-- Maintenance log results +UPDATE asset_maintenance_logs +SET checklist_json = sub.cleaned +FROM ( + SELECT + l.id, + jsonb_agg( + jsonb_strip_nulls( + jsonb_build_object( + 'label', COALESCE(NULLIF(trim(elem->>'label'), ''), NULLIF(trim(elem->>'key'), '')), + 'status', elem->>'status', + 'remarks', NULLIF(elem->>'remarks', '') + ) + ) + ORDER BY ord + ) AS cleaned + FROM asset_maintenance_logs l + CROSS JOIN LATERAL jsonb_array_elements(l.checklist_json) WITH ORDINALITY AS t(elem, ord) + WHERE l.checklist_json IS NOT NULL + AND jsonb_typeof(l.checklist_json) = 'array' + AND jsonb_array_length(l.checklist_json) > 0 + GROUP BY l.id +) sub +WHERE asset_maintenance_logs.id = sub.id + AND sub.cleaned IS NOT NULL; + +COMMIT; diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 85c5536..0a4ae31 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -23,13 +23,12 @@ components: maintenance_checklist_json: type: array nullable: true - description: 'Checklist TEMPLATE stored on the asset' + description: 'Checklist TEMPLATE stored on the asset. Unique by label (case-insensitive). No `key` field.' items: type: object - required: [key, label] + required: [label] properties: - key: { type: string, example: 'oil_level' } - label: { type: string, example: 'Check oil level' } + label: { type: string, example: 'Check CO2 level' } required: { type: boolean, example: true } vendor_id: { type: integer, nullable: true } po_id: { type: integer, nullable: true } @@ -73,9 +72,8 @@ components: nullable: true items: type: object - required: [key, label] + required: [label] properties: - key: { type: string } label: { type: string } required: { type: boolean } vendor_id: { type: integer, nullable: true } @@ -122,10 +120,9 @@ components: minItems: 1 items: type: object - required: [key, status] + required: [label, status] properties: - key: { type: string, example: 'oil_level' } - label: { type: string, example: 'Check oil level' } + label: { type: string, example: 'Check CO2 level' } status: { type: string, enum: [OK, NOT_OK, NA] } remarks: { type: string, nullable: true } TransferAssetBody: @@ -612,7 +609,7 @@ paths: summary: Submit a maintenance checklist (incharge only) description: | Only the asset's `maintenance_incharge_user_id` may submit. - Required checklist keys from `maintenance_checklist_json` must be present with status OK/NOT_OK/NA. + Required checklist labels from `maintenance_checklist_json` must be present with status OK/NOT_OK/NA. `next_due_date` defaults to performed_date + maintenance_frequency_in_days. parameters: - { name: id, in: path, required: true, schema: { type: string } } diff --git a/src/modules/assets/assets-maintenance.service.js b/src/modules/assets/assets-maintenance.service.js index 3ecdbcc..7177336 100644 --- a/src/modules/assets/assets-maintenance.service.js +++ b/src/modules/assets/assets-maintenance.service.js @@ -2,8 +2,8 @@ const prisma = require('../../config/prisma'); const ApiError = require('../../utils/ApiError'); const auditLog = require('../../utils/auditLog'); const { getPagination, isDropdownCall } = require('../../utils/pagination'); +const { toDateOnly, assertReference, getAssetOrThrow, normalizeChecklistTemplate, presentChecklistResults } = require('./assets.helpers'); const { MAINTENANCE_CHECKLIST_STATUSES } = require('./assets.constants'); -const { toDateOnly, getAssetOrThrow } = require('./assets.helpers'); const { sanitizeAsset } = require('./assets.service'); const logInclude = { @@ -43,6 +43,7 @@ const sanitizeLog = (row) => { } = row; return { ...rest, + checklist_json: presentChecklistResults(rest.checklist_json), performed_by_user: users_asset_maintenance_logs_performed_byTousers || null, created_by_user: users_asset_maintenance_logs_created_byTousers || null, }; @@ -61,59 +62,46 @@ const addDays = (value, 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 templateMap = new Map( + templateItems.map((item) => [String(item.label).trim().toLowerCase(), item]) + ); + const resultLabels = 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); + const label = String(row.label || '').trim(); + if (!label) throw new ApiError(422, 'Each checklist result requires a label'); + const labelKey = label.toLowerCase(); + if (resultLabels.has(labelKey)) { + throw new ApiError(422, `Duplicate checklist result label: ${label}`); + } + resultLabels.add(labelKey); if (!MAINTENANCE_CHECKLIST_STATUSES.includes(row.status)) { - throw new ApiError(422, `Invalid checklist status for ${key}`); + throw new ApiError(422, `Invalid checklist status for "${label}"`); + } + + const templateItem = templateMap.get(labelKey); + if (!templateItem) { + throw new ApiError(422, `Unknown checklist item: ${label}`); } - const templateItem = templateMap.get(key); normalized.push({ - key, - label: row.label || templateItem?.label || key, + label: templateItem.label, 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}`); + if (item.required !== false && !resultLabels.has(String(item.label).trim().toLowerCase())) { + throw new ApiError(422, `Required checklist item missing: ${item.label}`); } } diff --git a/src/modules/assets/assets.helpers.js b/src/modules/assets/assets.helpers.js index ec8d611..1adeb06 100644 --- a/src/modules/assets/assets.helpers.js +++ b/src/modules/assets/assets.helpers.js @@ -51,10 +51,61 @@ const deactivateOtherActive = async (tx, table, assetId, excludeId = null) => { }); }; +/** + * Checklist template on assets: [{ label, required }] — no `key`. + * Strips legacy `key` if present; uniqueness is by label (case-insensitive). + */ +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 labels = new Set(); + return template.map((item, index) => { + const label = String(item?.label || item?.key || '').trim(); + if (!label) throw new ApiError(422, `Checklist item ${index + 1}: label is required`); + const labelKey = label.toLowerCase(); + if (labels.has(labelKey)) { + throw new ApiError(422, `Duplicate checklist label: ${label}`); + } + labels.add(labelKey); + return { + label, + required: item.required !== false, + }; + }); +}; + +/** + * Soft-strip legacy `key` for API responses (no uniqueness validation). + */ +const presentChecklistTemplate = (template) => { + if (template === null || template === undefined) return null; + if (!Array.isArray(template)) return template; + return template + .map((item) => ({ + label: String(item?.label || item?.key || '').trim(), + required: item?.required !== false, + })) + .filter((item) => item.label); +}; + +const presentChecklistResults = (results) => { + if (!Array.isArray(results)) return results ?? null; + return results.map((item) => ({ + label: String(item?.label || item?.key || '').trim(), + status: item?.status, + remarks: item?.remarks ?? null, + })); +}; + module.exports = { toDateOnly, assertReference, getAssetOrThrow, deactivateOtherActive, + normalizeChecklistTemplate, + presentChecklistTemplate, + presentChecklistResults, SOFT_DELETE_TABLES, }; diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index b1633cd..b610278 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -12,7 +12,7 @@ const { resolveDepreciationRate, calculateDepreciation, } = require('./assets.depreciation'); -const { assertReference, toDateOnly } = require('./assets.helpers'); +const { assertReference, toDateOnly, normalizeChecklistTemplate, presentChecklistTemplate } = require('./assets.helpers'); const { sanitizeAttachment } = require('./assets.attachments.service'); const assetInclude = { @@ -108,7 +108,7 @@ const buildMaintenanceSummary = (rest, logs) => { return { frequency_in_days: frequency, - checklist: rest.maintenance_checklist_json ?? null, + checklist: presentChecklistTemplate(rest.maintenance_checklist_json), last_maintenance_date: lastMaintenanceDate, next_due_date: nextDueDate, is_due: isDue, @@ -150,6 +150,7 @@ const sanitizeAsset = (asset) => { return { ...rest, + maintenance_checklist_json: presentChecklistTemplate(rest.maintenance_checklist_json), purchase_cost: purchaseCost, salvage_value: salvageValue, depreciation_rate: depreciationRate, @@ -260,19 +261,8 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { if (payload.maintenance_checklist_json !== undefined) { if (payload.maintenance_checklist_json === null) { checklistJson = null; - } else if (!Array.isArray(payload.maintenance_checklist_json)) { - throw new ApiError(422, 'maintenance_checklist_json must be an array'); } else { - const keys = new Set(); - checklistJson = payload.maintenance_checklist_json.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 }; - }); + checklistJson = normalizeChecklistTemplate(payload.maintenance_checklist_json); } } diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index 722f087..026414c 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -16,19 +16,17 @@ const { } = require('./assets.constants'); const checklistTemplateItemSchema = Joi.object({ - key: Joi.string().trim().max(50).required(), label: Joi.string().trim().max(200).required(), required: Joi.boolean().default(true), -}).unknown(true); +}); const checklistResultItemSchema = Joi.object({ - key: Joi.string().trim().max(50).required(), - label: Joi.string().trim().max(200).optional(), + label: Joi.string().trim().max(200).required(), status: Joi.string() .valid(...MAINTENANCE_CHECKLIST_STATUSES) .required(), remarks: Joi.string().allow(null, '').max(500).optional(), -}).unknown(true); +}); const assetFields = { asset_name: Joi.string().max(200).required(),