diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index d116276..43f4dc6 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -33,7 +33,7 @@ Use this checklist when building or extending the backend. Follow the build orde | Step | Content | |------|---------| -| Modules | `USERS`, `ROLES`, `MASTERS`, `VENDOR`, `PURCHASE_ORDER`, `GRN`, `ASSET` | +| Modules | `USERS`, `ROLES`, `MASTERS`, `VENDOR`, `PURCHASE_ORDER`, `GRN`, `ASSET`, `SETTINGS`, `AUDIT_LOGS` | | Permissions | Each module × `view`, `create`, `edit`, `delete`, `approve`, `export` (skip where N/A) | | Roles | Super Admin, Admin, Purchase Manager, Store Manager, Accounts, Asset Manager | | Bootstrap user | Super Admin, bcrypt-hashed password, `status = active` | @@ -319,6 +319,21 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL --- +### Audit Logs (`/audit-logs`) — module: `AUDIT_LOGS` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/audit-logs/filters` | view | Distinct `table_names`, `actions`, `performers` for FE dropdowns | +| [x] | GET | `/audit-logs` | view | Filtered list; **empty by default** until at least one filter is applied | +| [x] | GET | `/audit-logs/:id` | view | Full detail with `old_value` / `new_value` JSON | +| [x] | GET | `/audit-logs/export` | export | CSV export (filters required) | + +**List filters:** `table_name`, `record_id`, `action`, `performed_by`, `request_id`, `date_from`, `date_to`, `search` (+ `page`, `limit`). + +**DB patch:** `scripts/patch-audit-logs-module.sql` — `AUDIT_LOGS` module with `view` + `export` for Super Admin. + +--- + **DB patch:** run `scripts/patch-assets-amc-insurance.sql` then `scripts/patch-assets-views.sql` on deployed DB. --- @@ -337,7 +352,8 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | GRN | 11 | 11 | [x] Done | | Assets | 24 | 24 | [x] Done | | Settings | 5 | 5 | [x] Done | -| **Total** | **168** | **168** | **[x] Phase 1 APIs + Asset extensions** | +| Audit Logs | 4 | 4 | [x] Done | +| **Total** | **172** | **172** | **[x] Phase 1 APIs + Asset extensions** | --- diff --git a/docs/MODULE_DATA_FLOW.md b/docs/MODULE_DATA_FLOW.md index 30572bb..3cf33c8 100644 --- a/docs/MODULE_DATA_FLOW.md +++ b/docs/MODULE_DATA_FLOW.md @@ -126,7 +126,18 @@ sequenceDiagram SVC-->>FE: ApiResponse { success, message, data, meta } ``` -**RBAC modules:** `USERS`, `ROLES`, `MASTERS`, `VENDOR`, `PURCHASE_ORDER`, `GRN`, `ASSET`, `SETTINGS` +**RBAC modules:** `USERS`, `ROLES`, `MASTERS`, `VENDOR`, `PURCHASE_ORDER`, `GRN`, `ASSET`, `SETTINGS`, `AUDIT_LOGS` + +### Audit logs (read-only) + +| Endpoint | Purpose | +|---|---| +| `GET /audit-logs/filters` | Dropdown values: tables, actions, performers | +| `GET /audit-logs` | Filtered list — **returns empty until a filter is set** | +| `GET /audit-logs/:id` | Full row with `old_value` / `new_value` | +| `GET /audit-logs/export` | CSV of filtered rows | + +Typical FE flow: load filters → user picks table + record (or date range) → list → click row for detail drawer. --- diff --git a/scripts/patch-audit-logs-module.sql b/scripts/patch-audit-logs-module.sql new file mode 100644 index 0000000..bc58ccf --- /dev/null +++ b/scripts/patch-audit-logs-module.sql @@ -0,0 +1,20 @@ +-- Audit logs read API — AUDIT_LOGS RBAC module + +INSERT INTO modules (code, name, sort_order, is_active) +VALUES ('AUDIT_LOGS', 'Audit Logs', 95, true) +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, is_active = true; + +INSERT INTO permissions (module_id, action, is_active) +SELECT m.id, a.action, true +FROM modules m +CROSS JOIN (VALUES ('view'), ('export')) AS a(action) +WHERE m.code = 'AUDIT_LOGS' +ON CONFLICT (module_id, action) DO UPDATE SET is_active = true; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.action IN ('view', 'export') +JOIN modules m ON m.id = p.module_id AND m.code = 'AUDIT_LOGS' +WHERE r.name = 'Super Admin' AND r.deleted_at IS NULL +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/src/docs/audit-logs-routes.yaml b/src/docs/audit-logs-routes.yaml new file mode 100644 index 0000000..14a0681 --- /dev/null +++ b/src/docs/audit-logs-routes.yaml @@ -0,0 +1,179 @@ +tags: + - name: Audit Logs + +components: + schemas: + AuditLogListItem: + type: object + properties: + id: { type: string, example: "592" } + table_name: { type: string, example: items } + record_id: { type: string, example: "12" } + action: { type: string, example: CREATE } + performed_at: { type: string, format: date-time } + performed_by: { type: string, nullable: true } + request_id: { type: string, nullable: true } + performed_by_user: + type: object + nullable: true + properties: + id: { type: string } + full_name: { type: string } + employee_code: { type: string } + email: { type: string } + has_old_value: { type: boolean } + has_new_value: { type: boolean } + AuditLogDetail: + allOf: + - $ref: "#/components/schemas/AuditLogListItem" + - type: object + properties: + old_value: { type: object, nullable: true } + new_value: { type: object, nullable: true } + AuditLogFilterOptions: + type: object + properties: + table_names: + type: array + items: { type: string } + actions: + type: array + items: { type: string } + performers: + type: array + items: + type: object + properties: + id: { type: string } + full_name: { type: string } + employee_code: { type: string } + +paths: + /audit-logs/filters: + get: + tags: [Audit Logs] + summary: Distinct filter options for audit log screen + responses: + "200": + description: Filter options fetched + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: { $ref: "#/components/schemas/AuditLogFilterOptions" } + /audit-logs/export: + get: + tags: [Audit Logs] + summary: Export filtered audit logs as CSV + description: Requires at least one filter (same as list API). + parameters: + - in: query + name: table_name + schema: { type: string } + - in: query + name: record_id + schema: { type: integer } + - in: query + name: action + schema: { type: string } + - in: query + name: performed_by + schema: { type: integer } + - in: query + name: request_id + schema: { type: string } + - in: query + name: date_from + schema: { type: string, format: date-time } + - in: query + name: date_to + schema: { type: string, format: date-time } + - in: query + name: search + schema: { type: string } + responses: + "200": + description: CSV export + content: + text/csv: + schema: { type: string } + /audit-logs: + get: + tags: [Audit Logs] + summary: List audit logs (filter required) + description: | + Returns an empty list unless at least one filter is provided. + Use `meta.filters_required` to detect the unfiltered state. + parameters: + - in: query + name: page + schema: { type: integer, default: 1 } + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 100 } + - in: query + name: table_name + schema: { type: string } + description: Exact table name (case-insensitive) + - in: query + name: record_id + schema: { type: integer } + description: Target record primary key + - in: query + name: action + schema: { type: string } + description: Exact action (CREATE, UPDATE, DELETE, etc.) + - in: query + name: performed_by + schema: { type: integer } + description: User ID who performed the action + - in: query + name: request_id + schema: { type: string } + description: Partial match on request correlation ID + - in: query + name: date_from + schema: { type: string, format: date-time } + - in: query + name: date_to + schema: { type: string, format: date-time } + - in: query + name: search + schema: { type: string } + description: Search table_name, action, or request_id + responses: + "200": + description: Audit logs list (empty when no filters) + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/AuditLogListItem" } + /audit-logs/{id}: + get: + tags: [Audit Logs] + summary: Get audit log detail (includes old/new JSON) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: Audit log detail + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: { $ref: "#/components/schemas/AuditLogDetail" } diff --git a/src/modules/audit-logs/audit-logs.controller.js b/src/modules/audit-logs/audit-logs.controller.js new file mode 100644 index 0000000..26f1829 --- /dev/null +++ b/src/modules/audit-logs/audit-logs.controller.js @@ -0,0 +1,30 @@ +const asyncHandler = require('../../utils/asyncHandler'); +const ApiResponse = require('../../utils/ApiResponse'); +const service = require('./audit-logs.service'); + +const list = asyncHandler(async (req, res) => { + const result = await service.listAuditLogs(req.query); + const message = result.meta.filters_required + ? 'Apply at least one filter to view audit logs' + : 'Audit logs fetched'; + res.json(new ApiResponse(200, result.data, message, result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getAuditLogById(req.params.id); + res.json(new ApiResponse(200, data, 'Audit log fetched')); +}); + +const filters = asyncHandler(async (_req, res) => { + const data = await service.getFilterOptions(); + res.json(new ApiResponse(200, data, 'Audit log filter options fetched')); +}); + +const exportCsv = asyncHandler(async (req, res) => { + const csv = await service.exportAuditLogs(req.query); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="audit-logs-export.csv"'); + res.send(csv); +}); + +module.exports = { list, getOne, filters, exportCsv }; diff --git a/src/modules/audit-logs/audit-logs.routes.js b/src/modules/audit-logs/audit-logs.routes.js new file mode 100644 index 0000000..1a9a05d --- /dev/null +++ b/src/modules/audit-logs/audit-logs.routes.js @@ -0,0 +1,30 @@ +const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); +const authorize = require('../../middlewares/rbac.middleware'); +const validate = require('../../middlewares/validate.middleware'); +const controller = require('./audit-logs.controller'); +const { + listAuditLogsQuerySchema, + exportAuditLogsQuerySchema, +} = require('./audit-logs.validation'); + +const router = express.Router(); + +router.use(authenticate); + +router.get('/filters', authorize('AUDIT_LOGS', 'view'), controller.filters); +router.get( + '/export', + authorize('AUDIT_LOGS', 'export'), + validate(exportAuditLogsQuerySchema, 'query'), + controller.exportCsv +); +router.get( + '/', + authorize('AUDIT_LOGS', 'view'), + validate(listAuditLogsQuerySchema, 'query'), + controller.list +); +router.get('/:id', authorize('AUDIT_LOGS', 'view'), controller.getOne); + +module.exports = router; diff --git a/src/modules/audit-logs/audit-logs.service.js b/src/modules/audit-logs/audit-logs.service.js new file mode 100644 index 0000000..ba8cc6a --- /dev/null +++ b/src/modules/audit-logs/audit-logs.service.js @@ -0,0 +1,213 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const { getPagination } = require('../../utils/pagination'); + +const auditInclude = { + users: { select: { id: true, full_name: true, employee_code: true, email: true } }, +}; + +const sanitizeAuditLog = (row) => { + if (!row) return null; + const { users, ...rest } = row; + return { + ...rest, + performed_by_user: users || null, + }; +}; + +const sanitizeAuditLogListItem = (row) => { + const item = sanitizeAuditLog(row); + if (!item) return null; + const { old_value, new_value, ...summary } = item; + return { + ...summary, + has_old_value: old_value != null, + has_new_value: new_value != null, + }; +}; + +const hasActiveFilters = (query) => + Boolean( + query.table_name?.trim() || + query.record_id || + query.action?.trim() || + query.performed_by || + query.request_id?.trim() || + query.date_from || + query.date_to || + query.search?.trim() + ); + +const buildAuditLogsWhere = (query) => { + const where = {}; + + if (query.table_name?.trim()) { + where.table_name = { equals: query.table_name.trim(), mode: 'insensitive' }; + } + + if (query.record_id) { + where.record_id = BigInt(query.record_id); + } + + if (query.action?.trim()) { + where.action = { equals: query.action.trim(), mode: 'insensitive' }; + } + + if (query.performed_by) { + where.performed_by = BigInt(query.performed_by); + } + + if (query.request_id?.trim()) { + where.request_id = { contains: query.request_id.trim(), mode: 'insensitive' }; + } + + if (query.date_from || query.date_to) { + where.performed_at = {}; + if (query.date_from) where.performed_at.gte = new Date(query.date_from); + if (query.date_to) where.performed_at.lte = new Date(query.date_to); + } + + if (query.search?.trim()) { + const term = query.search.trim(); + where.OR = [ + { table_name: { contains: term, mode: 'insensitive' } }, + { action: { contains: term, mode: 'insensitive' } }, + { request_id: { contains: term, mode: 'insensitive' } }, + ]; + } + + return where; +}; + +const listAuditLogs = async (query) => { + const { page, limit, skip } = getPagination(query); + + if (!hasActiveFilters(query)) { + return { + data: [], + meta: { page, limit, total: 0, filters_required: true }, + }; + } + + const where = buildAuditLogsWhere(query); + const [total, rows] = await Promise.all([ + prisma.audit_logs.count({ where }), + prisma.audit_logs.findMany({ + where, + include: auditInclude, + orderBy: [{ performed_at: 'desc' }, { id: 'desc' }], + skip, + take: limit, + }), + ]); + + return { + data: rows.map(sanitizeAuditLogListItem), + meta: { page, limit, total }, + }; +}; + +const getAuditLogById = async (id) => { + const row = await prisma.audit_logs.findUnique({ + where: { id: BigInt(id) }, + include: auditInclude, + }); + + if (!row) throw new ApiError(404, 'Audit log not found'); + return sanitizeAuditLog(row); +}; + +const getFilterOptions = async () => { + const [tableNames, actions, performerRows] = await Promise.all([ + prisma.audit_logs.findMany({ + distinct: ['table_name'], + select: { table_name: true }, + orderBy: { table_name: 'asc' }, + }), + prisma.audit_logs.findMany({ + distinct: ['action'], + select: { action: true }, + orderBy: { action: 'asc' }, + }), + prisma.audit_logs.findMany({ + where: { performed_by: { not: null } }, + distinct: ['performed_by'], + select: { performed_by: true }, + }), + ]); + + const performerIds = performerRows.map((row) => row.performed_by); + const performers = + performerIds.length === 0 + ? [] + : await prisma.users.findMany({ + where: { id: { in: performerIds }, deleted_at: null }, + select: { id: true, full_name: true, employee_code: true }, + orderBy: { full_name: 'asc' }, + }); + + return { + table_names: tableNames.map((row) => row.table_name), + actions: actions.map((row) => row.action), + performers, + }; +}; + +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'); + } + + const where = buildAuditLogsWhere(query); + const rows = await prisma.audit_logs.findMany({ + where, + include: auditInclude, + 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'); +}; + +module.exports = { + listAuditLogs, + getAuditLogById, + getFilterOptions, + exportAuditLogs, +}; diff --git a/src/modules/audit-logs/audit-logs.validation.js b/src/modules/audit-logs/audit-logs.validation.js new file mode 100644 index 0000000..2a57cdb --- /dev/null +++ b/src/modules/audit-logs/audit-logs.validation.js @@ -0,0 +1,29 @@ +const Joi = require('joi'); + +const listAuditLogsQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + table_name: Joi.string().max(100).trim().optional(), + record_id: Joi.number().integer().positive().optional(), + action: Joi.string().max(50).trim().optional(), + performed_by: Joi.number().integer().positive().optional(), + request_id: Joi.string().max(50).trim().optional(), + date_from: Joi.date().iso().optional(), + date_to: Joi.date().iso().optional(), + search: Joi.string().max(100).trim().optional(), +}).custom((value, helpers) => { + if (value.date_from && value.date_to && new Date(value.date_from) > new Date(value.date_to)) { + return helpers.message('date_from must be before or equal to date_to'); + } + return value; +}); + +const exportAuditLogsQuerySchema = listAuditLogsQuerySchema.keys({ + page: Joi.strip(), + limit: Joi.strip(), +}); + +module.exports = { + listAuditLogsQuerySchema, + exportAuditLogsQuerySchema, +}; diff --git a/src/routes/v1/index.js b/src/routes/v1/index.js index 9258b64..4f6947d 100644 --- a/src/routes/v1/index.js +++ b/src/routes/v1/index.js @@ -10,6 +10,7 @@ router.use('/purchase-orders', require('../../modules/purchase-orders/purchase-o router.use('/grn', require('../../modules/grn/grn.routes')); router.use('/assets', require('../../modules/assets/assets.routes')); router.use('/settings', require('../../modules/settings/settings.routes')); +router.use('/audit-logs', require('../../modules/audit-logs/audit-logs.routes')); router.use('/masters', require('../../modules/masters')); router.get('/healthz', (req, res) => {