GWM : Audit log api

This commit is contained in:
Gowtham M 2026-07-10 17:45:36 +05:30
parent 382111f7b9
commit 0a52e1202c
9 changed files with 532 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {