diff --git a/src/modules/audit-logs/audit-logs.service.js b/src/modules/audit-logs/audit-logs.service.js index ba8cc6a..b584549 100644 --- a/src/modules/audit-logs/audit-logs.service.js +++ b/src/modules/audit-logs/audit-logs.service.js @@ -1,11 +1,23 @@ const prisma = require('../../config/prisma'); const ApiError = require('../../utils/ApiError'); const { getPagination } = require('../../utils/pagination'); +const { rowsToCsv, formatCsvDateTime } = require('../../utils/csv'); const auditInclude = { users: { select: { id: true, full_name: true, employee_code: true, email: true } }, }; +const SKIP_AUDIT_FIELDS = new Set([ + 'id', + 'created_by', + 'updated_by', + 'created_at', + 'updated_at', + 'deleted_at', + 'password_hash', + 'refresh_token_hash', +]); + const sanitizeAuditLog = (row) => { if (!row) return null; const { users, ...rest } = row; @@ -26,6 +38,99 @@ const sanitizeAuditLogListItem = (row) => { }; }; +const formatScalar = (value) => { + if (value === null || value === undefined) return ''; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) { + return formatCsvDateTime(value); + } + if (typeof value === 'object') { + if (value.name != null) return String(value.name); + if (value.code != null) return String(value.code); + if (value.full_name != null) return String(value.full_name); + if (value.label != null) return String(value.label); + return ''; + } + return String(value); +}; + +const flattenAuditObject = (value, prefix = '') => { + if (value === null || value === undefined) return {}; + if (typeof value !== 'object' || value instanceof Date || Array.isArray(value)) { + return prefix ? { [prefix]: formatScalar(value) } : {}; + } + + const out = {}; + for (const [key, raw] of Object.entries(value)) { + if (SKIP_AUDIT_FIELDS.has(key)) continue; + const path = prefix ? `${prefix}.${key}` : key; + + if (raw === null || raw === undefined) { + out[path] = ''; + continue; + } + + if (Array.isArray(raw)) { + out[path] = raw + .map((item) => formatScalar(item)) + .filter(Boolean) + .join('; '); + continue; + } + + if (typeof raw === 'object' && !(raw instanceof Date)) { + // Prefer a single readable label for nested relation objects + const label = formatScalar(raw); + if (label) { + out[path] = label; + } else { + Object.assign(out, flattenAuditObject(raw, path)); + } + continue; + } + + out[path] = formatScalar(raw); + } + return out; +}; + +const formatAuditValueForExport = (value) => { + if (value === null || value === undefined) return ''; + if (typeof value !== 'object' || value instanceof Date || Array.isArray(value)) { + return formatScalar(value); + } + + const flat = flattenAuditObject(value); + return Object.entries(flat) + .filter(([, v]) => v !== '') + .map(([key, v]) => `${key}: ${v}`) + .join(' | '); +}; + +const formatAuditDiffSide = (oldValue, newValue, side) => { + if (side === 'old' && (oldValue === null || oldValue === undefined)) return ''; + if (side === 'new' && (newValue === null || newValue === undefined)) return ''; + + const oldFlat = flattenAuditObject(oldValue); + const newFlat = flattenAuditObject(newValue); + const keys = new Set([...Object.keys(oldFlat), ...Object.keys(newFlat)]); + + // When both sides exist (UPDATE), show only changed fields + if (oldValue != null && newValue != null && typeof oldValue === 'object' && typeof newValue === 'object') { + const changed = []; + for (const key of keys) { + const before = oldFlat[key] ?? ''; + const after = newFlat[key] ?? ''; + if (before === after) continue; + changed.push(`${key}: ${side === 'old' ? before : after}`); + } + return changed.join(' | '); + } + + return formatAuditValueForExport(side === 'old' ? oldValue : newValue); +}; + const hasActiveFilters = (query) => Boolean( query.table_name?.trim() || @@ -153,12 +258,6 @@ const getFilterOptions = async () => { }; }; -const escapeCsv = (value) => { - const text = String(value ?? ''); - if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; - return text; -}; - const exportAuditLogs = async (query) => { if (!hasActiveFilters(query)) { throw new ApiError(422, 'At least one filter is required to export audit logs'); @@ -171,38 +270,26 @@ const exportAuditLogs = async (query) => { orderBy: [{ performed_at: 'desc' }, { id: 'desc' }], }); - const header = [ - 'ID', - 'Table Name', - 'Record ID', - 'Action', - 'Performed At', - 'Performed By', - 'Employee Code', - 'Request ID', - 'Old Value', - 'New Value', - ]; - - const lines = rows.map((row) => { - const item = sanitizeAuditLog(row); - return [ - item.id, - item.table_name, - item.record_id, - item.action, - item.performed_at, - item.performed_by_user?.full_name || '', - item.performed_by_user?.employee_code || '', - item.request_id || '', - item.old_value != null ? JSON.stringify(item.old_value) : '', - item.new_value != null ? JSON.stringify(item.new_value) : '', - ] - .map(escapeCsv) - .join(','); - }); - - return [header.join(','), ...lines].join('\n'); + return rowsToCsv( + [ + { key: 'table_name', header: 'Table Name' }, + { key: 'record_id', header: 'Record ID' }, + { key: 'action', header: 'Action' }, + { key: 'performed_at', header: 'Performed At', type: 'datetime' }, + { key: (row) => row.performed_by_user?.full_name || '', header: 'Performed By' }, + { key: (row) => row.performed_by_user?.employee_code || '', header: 'Employee Code' }, + { key: (row) => row.request_id || '', header: 'Request ID' }, + { + key: (row) => formatAuditDiffSide(row.old_value, row.new_value, 'old'), + header: 'Old Value', + }, + { + key: (row) => formatAuditDiffSide(row.old_value, row.new_value, 'new'), + header: 'New Value', + }, + ], + rows.map(sanitizeAuditLog) + ); }; module.exports = { diff --git a/src/modules/masters/_shared/master.factory.js b/src/modules/masters/_shared/master.factory.js index 713869c..5eb9c5d 100644 --- a/src/modules/masters/_shared/master.factory.js +++ b/src/modules/masters/_shared/master.factory.js @@ -29,11 +29,10 @@ const normalizePayload = (payload, fields) => { const defaultExportColumns = (fields, columns) => { if (columns && columns.length) return columns; return [ - { key: 'id', header: 'ID' }, ...fields .filter((f) => !AUDIT_FIELD_NAMES.has(f.name)) .map((f) => ({ key: f.name, header: f.name })), - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ]; }; diff --git a/src/modules/masters/item-subcategories/item-subcategories.service.js b/src/modules/masters/item-subcategories/item-subcategories.service.js index 0efdf47..cc185ad 100644 --- a/src/modules/masters/item-subcategories/item-subcategories.service.js +++ b/src/modules/masters/item-subcategories/item-subcategories.service.js @@ -21,13 +21,12 @@ const config = { include: subcategoryInclude, mapRow: sanitizeSubcategory, exportColumns: [ - { key: 'id', header: 'ID' }, { key: 'code', header: 'Code' }, { key: 'name', header: 'Name' }, { key: (row) => row.item_category?.code || '', header: 'Category Code' }, { key: (row) => row.item_category?.name || '', header: 'Category Name' }, { key: 'is_active', header: 'Is Active' }, - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ], fields: [ { diff --git a/src/modules/masters/items/items.service.js b/src/modules/masters/items/items.service.js index 3c8524f..cc24075 100644 --- a/src/modules/masters/items/items.service.js +++ b/src/modules/masters/items/items.service.js @@ -242,7 +242,6 @@ const exportItems = async (query) => { return rowsToCsv( [ - { key: 'id', header: 'ID' }, { key: 'item_code', header: 'Item Code' }, { key: 'item_name', header: 'Item Name' }, { key: (row) => row.item_category?.code || '', header: 'Category Code' }, @@ -254,7 +253,7 @@ const exportItems = async (query) => { { key: (row) => row.brand?.name || '', header: 'Brand' }, { key: 'is_asset_item', header: 'Is Asset Item' }, { key: 'is_active', header: 'Is Active' }, - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ], rows.map(sanitizeItem) ); diff --git a/src/modules/masters/locations/locations.service.js b/src/modules/masters/locations/locations.service.js index f7ba091..dc883d1 100644 --- a/src/modules/masters/locations/locations.service.js +++ b/src/modules/masters/locations/locations.service.js @@ -166,33 +166,30 @@ const exportLocations = async (query, type = null) => { const columns = effectiveType === LOCATION_TYPES.WAREHOUSE ? [ - { key: 'id', header: 'ID' }, { key: 'code', header: 'Code' }, { key: 'name', header: 'Name' }, { key: (row) => row.parent?.code || row.plant?.code || '', header: 'Plant Code' }, { key: (row) => row.parent?.name || row.plant?.name || '', header: 'Plant Name' }, { key: 'location', header: 'Location' }, { key: 'is_active', header: 'Is Active' }, - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ] : effectiveType === LOCATION_TYPES.PLANT ? [ - { key: 'id', header: 'ID' }, { key: 'code', header: 'Code' }, { key: 'name', header: 'Name' }, { key: 'gstin', header: 'GSTIN' }, { key: 'city', header: 'City' }, { key: 'state', header: 'State' }, { key: 'is_active', header: 'Is Active' }, - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ] : [ - { key: 'id', header: 'ID' }, { key: 'type', header: 'Type' }, { key: 'code', header: 'Code' }, { key: 'name', header: 'Name' }, { key: 'is_active', header: 'Is Active' }, - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ]; return rowsToCsv(columns, rows.map(mapRow)); diff --git a/src/modules/masters/uom/uom.service.js b/src/modules/masters/uom/uom.service.js index b369bec..36c7197 100644 --- a/src/modules/masters/uom/uom.service.js +++ b/src/modules/masters/uom/uom.service.js @@ -64,11 +64,10 @@ const exportUom = async (query) => { }); return rowsToCsv( [ - { key: 'id', header: 'ID' }, { key: 'code', header: 'Code' }, { key: 'name', header: 'Name' }, { key: 'is_active', header: 'Is Active' }, - { key: 'created_at', header: 'Created At' }, + { key: 'created_at', header: 'Created At', type: 'datetime' }, ], rows ); diff --git a/src/modules/reports/reports-assets-depreciation.service.js b/src/modules/reports/reports-assets-depreciation.service.js index 19ab2ea..5590cd7 100644 --- a/src/modules/reports/reports-assets-depreciation.service.js +++ b/src/modules/reports/reports-assets-depreciation.service.js @@ -1,5 +1,6 @@ const prisma = require('../../config/prisma'); const { getPagination } = require('../../utils/pagination'); +const { rowsToCsv } = require('../../utils/csv'); const { calculateDepreciation, round4 } = require('../assets/assets.depreciation'); const depreciationInclude = { @@ -183,12 +184,6 @@ const getDepreciationFilterOptions = async () => { }; }; -const escapeCsv = (value) => { - const text = String(value ?? ''); - if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; - return text; -}; - const exportAssetDepreciation = async (query) => { const asOfDate = query.as_of_date ? new Date(query.as_of_date) : new Date(); const where = buildDepreciationWhere(query); @@ -199,54 +194,29 @@ const exportAssetDepreciation = async (query) => { orderBy: [{ asset_code: 'asc' }], }); - const header = [ - 'Asset Code', - 'Asset Name', - 'Status', - 'Category', - 'Subcategory', - 'Plant', - 'Department', - 'Purchase Date', - 'Purchase Cost', - 'Salvage Value', - 'Useful Life (Years)', - 'Depreciation Method', - 'Depreciation Rate (%)', - 'Annual Depreciation', - 'Accumulated Depreciation', - 'Book Value', - 'Years Elapsed', - 'As Of Date', - ]; - - const lines = rows.map((row) => { - const item = sanitizeDepreciationRow(row, asOfDate); - return [ - item.asset_code, - item.asset_name, - item.status, - item.item_category?.name || '', - item.item_subcategory?.name || '', - item.plant?.name || '', - item.department?.name || '', - item.purchase_date ? new Date(item.purchase_date).toISOString().slice(0, 10) : '', - item.purchase_cost, - item.salvage_value, - item.useful_life_years ?? '', - item.depreciation_method || '', - item.depreciation_rate ?? '', - item.depreciation.annual_depreciation, - item.depreciation.accumulated_depreciation, - item.depreciation.book_value, - item.depreciation.years_elapsed, - asOfDate.toISOString().slice(0, 10), - ] - .map(escapeCsv) - .join(','); - }); - - return [header.join(','), ...lines].join('\n'); + return rowsToCsv( + [ + { key: 'asset_code', header: 'Asset Code' }, + { key: 'asset_name', header: 'Asset Name' }, + { key: 'status', header: 'Status' }, + { key: (row) => row.item_category?.name || '', header: 'Category' }, + { key: (row) => row.item_subcategory?.name || '', header: 'Subcategory' }, + { key: (row) => row.plant?.name || '', header: 'Plant' }, + { key: (row) => row.department?.name || '', header: 'Department' }, + { key: 'purchase_date', header: 'Purchase Date', type: 'date' }, + { key: 'purchase_cost', header: 'Purchase Cost' }, + { key: 'salvage_value', header: 'Salvage Value' }, + { key: (row) => row.useful_life_years ?? '', header: 'Useful Life (Years)' }, + { key: (row) => row.depreciation_method || '', header: 'Depreciation Method' }, + { key: (row) => row.depreciation_rate ?? '', header: 'Depreciation Rate (%)' }, + { key: (row) => row.depreciation.annual_depreciation, header: 'Annual Depreciation' }, + { key: (row) => row.depreciation.accumulated_depreciation, header: 'Accumulated Depreciation' }, + { key: (row) => row.depreciation.book_value, header: 'Book Value' }, + { key: (row) => row.depreciation.years_elapsed, header: 'Years Elapsed' }, + { key: () => asOfDate, header: 'As Of Date', type: 'date' }, + ], + rows.map((row) => sanitizeDepreciationRow(row, asOfDate)) + ); }; module.exports = { diff --git a/src/modules/users/users.service.js b/src/modules/users/users.service.js index 7485251..89c2699 100644 --- a/src/modules/users/users.service.js +++ b/src/modules/users/users.service.js @@ -293,11 +293,7 @@ const getFilterOptions = async () => { }; }; -const escapeCsv = (value) => { - const text = String(value ?? ''); - if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; - return text; -}; +const { rowsToCsv } = require('../../utils/csv'); const exportUsers = async (query) => { const where = buildUsersWhere(query); @@ -307,34 +303,19 @@ const exportUsers = async (query) => { orderBy: { full_name: 'asc' }, }); - const header = [ - 'Full Name', - 'Email', - 'Employee Code', - 'Roles', - 'Department', - 'Plant', - 'Last Login', - 'Status', - ]; - - const lines = rows.map((user) => { - const item = toUserListItem(user); - return [ - item.full_name, - item.email, - item.employee_code, - item.roles.map((role) => role.name).join('; '), - item.department?.name || '', - item.plant?.name || '', - item.last_login_at ? new Date(item.last_login_at).toISOString() : '', - item.status, - ] - .map(escapeCsv) - .join(','); - }); - - return [header.join(','), ...lines].join('\n'); + return rowsToCsv( + [ + { key: 'full_name', header: 'Full Name' }, + { key: 'email', header: 'Email' }, + { key: 'employee_code', header: 'Employee Code' }, + { key: (row) => row.roles.map((role) => role.name).join('; '), header: 'Roles' }, + { key: (row) => row.department?.name || '', header: 'Department' }, + { key: (row) => row.plant?.name || '', header: 'Plant' }, + { key: 'last_login_at', header: 'Last Login', type: 'datetime' }, + { key: 'status', header: 'Status' }, + ], + rows.map(toUserListItem) + ); }; const getUserById = async (id) => { diff --git a/src/utils/csv.js b/src/utils/csv.js index e90c1ec..9b31219 100644 --- a/src/utils/csv.js +++ b/src/utils/csv.js @@ -1,3 +1,5 @@ +const pad2 = (value) => String(value).padStart(2, '0'); + const escapeCsv = (value) => { if (value === null || value === undefined) return ''; const str = String(value); @@ -5,20 +7,60 @@ const escapeCsv = (value) => { return str; }; -const formatCsvCell = (value) => { +const isDateOnlyString = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value); + +const isIsoDateTimeString = (value) => + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test(value); + +/** + * Format date / datetime values for CSV/Excel as dd-mm-yyyy hh:mm. + * Date-only values (YYYY-MM-DD / Prisma @db.Date) use UTC calendar day at 00:00. + * @param {Date|string|number|null|undefined} value + * @returns {string} + */ +const formatCsvDateTime = (value) => { + if (value === null || value === undefined || value === '') return ''; + + if (isDateOnlyString(value)) { + const [year, month, day] = value.split('-'); + return `${day}-${month}-${year} 00:00`; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + + // Prisma @db.Date often arrives as Date at UTC midnight + const looksLikeDateOnly = + value instanceof Date && + date.getUTCHours() === 0 && + date.getUTCMinutes() === 0 && + date.getUTCSeconds() === 0 && + date.getUTCMilliseconds() === 0; + + if (looksLikeDateOnly && !isIsoDateTimeString(value)) { + return `${pad2(date.getUTCDate())}-${pad2(date.getUTCMonth() + 1)}-${date.getUTCFullYear()} 00:00`; + } + + return `${pad2(date.getDate())}-${pad2(date.getMonth() + 1)}-${date.getFullYear()} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +}; + +const formatCsvCell = (value, { type } = {}) => { if (value === null || value === undefined) return ''; + if (type === 'datetime' || type === 'date') return formatCsvDateTime(value); if (typeof value === 'bigint') return value.toString(); - if (value instanceof Date) return value.toISOString(); + if (value instanceof Date) return formatCsvDateTime(value); + if (isDateOnlyString(value) || isIsoDateTimeString(value)) return formatCsvDateTime(value); if (typeof value === 'boolean') return value ? 'true' : 'false'; if (typeof value === 'object') { - if (typeof value.toJSON === 'function') return formatCsvCell(value.toJSON()); + if (typeof value.toJSON === 'function') return formatCsvCell(value.toJSON(), { type }); return JSON.stringify(value); } return value; }; /** - * @param {Array<{ key: string|Function, header: string }>} columns + * @param {Array<{ key: string|Function, header: string, type?: 'date'|'datetime' }>} columns * @param {object[]} rows * @returns {string} */ @@ -28,11 +70,11 @@ const rowsToCsv = (columns, rows) => { columns .map((col) => { const raw = typeof col.key === 'function' ? col.key(row) : row[col.key]; - return escapeCsv(formatCsvCell(raw)); + return escapeCsv(formatCsvCell(raw, { type: col.type })); }) .join(',') ); return [header, ...lines].join('\n'); }; -module.exports = { escapeCsv, formatCsvCell, rowsToCsv }; +module.exports = { escapeCsv, formatCsvCell, formatCsvDateTime, rowsToCsv };