GWM : Audit log api
This commit is contained in:
parent
382111f7b9
commit
0a52e1202c
@ -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** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
scripts/patch-audit-logs-module.sql
Normal file
20
scripts/patch-audit-logs-module.sql
Normal 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;
|
||||
179
src/docs/audit-logs-routes.yaml
Normal file
179
src/docs/audit-logs-routes.yaml
Normal 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" }
|
||||
30
src/modules/audit-logs/audit-logs.controller.js
Normal file
30
src/modules/audit-logs/audit-logs.controller.js
Normal 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 };
|
||||
30
src/modules/audit-logs/audit-logs.routes.js
Normal file
30
src/modules/audit-logs/audit-logs.routes.js
Normal 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;
|
||||
213
src/modules/audit-logs/audit-logs.service.js
Normal file
213
src/modules/audit-logs/audit-logs.service.js
Normal 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,
|
||||
};
|
||||
29
src/modules/audit-logs/audit-logs.validation.js
Normal file
29
src/modules/audit-logs/audit-logs.validation.js
Normal 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,
|
||||
};
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user