From 605d845ba32d9cb5ecbda24c932fb538330c14a4 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Fri, 4 Sep 2026 00:36:22 +0100 Subject: [PATCH] Enhance date handling and filtering capabilities in utility functions and tests This commit introduces new functions for handling various date operations, including start and end of day, week, month, hour, and minute. It also adds relative date parsing capabilities, allowing for expressions like 'TODAY', '-7D..', and '-1M..-CM'. Additionally, the `parseFilter` function is updated to support these new date functionalities, with corresponding tests added to ensure accurate date filtering and validation. The changes improve the overall date management and querying capabilities within the application. --- src/__tests__/utils.parseFilter.test.js | 173 ++++++++- .../__tests__/utils.convertObjectId.test.js | 31 ++ src/database/utils.js | 1 + src/routes/management/auditlogs.js | 34 +- src/routes/management/permissionsetting.js | 176 ++++++---- src/routes/management/users.js | 67 ++-- src/services/management/auditlogs.js | 112 +++--- src/utils.js | 329 +++++++++++++++++- 8 files changed, 756 insertions(+), 167 deletions(-) create mode 100644 src/database/__tests__/utils.convertObjectId.test.js diff --git a/src/__tests__/utils.parseFilter.test.js b/src/__tests__/utils.parseFilter.test.js index c4ac2ad..df7e562 100644 --- a/src/__tests__/utils.parseFilter.test.js +++ b/src/__tests__/utils.parseFilter.test.js @@ -1,7 +1,67 @@ import { describe, expect, it } from '@jest/globals'; -import { parseFilter } from '../utils.js'; +import { getFilter, parseFilter } from '../utils.js'; import { jobModel } from '../database/schemas/production/job.schema.js'; +const startOfDay = (date) => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +const endOfDay = (date) => { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +}; + +const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); + +const endOfMonth = (date) => + new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999); + +const startOfWeek = (date) => { + const d = startOfDay(date); + const day = d.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + d.setDate(d.getDate() + mondayOffset); + return d; +}; + +const endOfWeek = (date) => { + const start = startOfWeek(date); + const end = new Date(start); + end.setDate(end.getDate() + 6); + return endOfDay(end); +}; + +const startOfYear = (date) => new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0); + +const endOfYear = (date) => new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999); + +const startOfHour = (date) => { + const d = new Date(date); + d.setMinutes(0, 0, 0); + return d; +}; + +const endOfHour = (date) => { + const d = new Date(date); + d.setMinutes(59, 59, 999); + return d; +}; + +const startOfMinute = (date) => { + const d = new Date(date); + d.setSeconds(0, 0); + return d; +}; + +const endOfMinute = (date) => { + const d = new Date(date); + d.setSeconds(59, 999); + return d; +}; + describe('parseFilter number fields', () => { it('matches exact numeric quantity without regex', async () => { await expect(parseFilter('quantity', '12', jobModel)).resolves.toEqual({ quantity: 12 }); @@ -51,3 +111,114 @@ describe('parseFilter number fields', () => { }); }); }); + +describe('parseFilter date fields', () => { + it('matches TODAY as the current calendar day', async () => { + const now = new Date(); + await expect(parseFilter('createdAt', 'TODAY', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) }, + }); + }); + + it('matches TODAY.. as from today onwards', async () => { + const now = new Date(); + await expect(parseFilter('createdAt', 'TODAY..', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfDay(now) }, + }); + }); + + it('matches -7D.. as the last 7 days', async () => { + const from = new Date(); + from.setDate(from.getDate() - 7); + await expect(parseFilter('createdAt', '-7D..', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfDay(from) }, + }); + }); + + it('matches -30D.. as the last 30 days', async () => { + const from = new Date(); + from.setDate(from.getDate() - 30); + await expect(parseFilter('createdAt', '-30D..', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfDay(from) }, + }); + }); + + it('matches -1M..-CM as the previous calendar month', async () => { + const now = new Date(); + const previous = new Date(now.getFullYear(), now.getMonth() - 1, 1); + await expect(parseFilter('createdAt', '-1M..-CM', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfMonth(previous), $lte: endOfMonth(previous) }, + }); + }); + + it('matches -1W as the previous calendar week', async () => { + const previous = new Date(); + previous.setDate(previous.getDate() - 7); + await expect(parseFilter('createdAt', '-1W', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfWeek(previous), $lte: endOfWeek(previous) }, + }); + }); + + it('matches -1Y..-CY as the previous calendar year', async () => { + const previous = new Date(new Date().getFullYear() - 1, 0, 1); + await expect(parseFilter('createdAt', '-1Y..-CY', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfYear(previous), $lte: endOfYear(previous) }, + }); + }); + + it('matches -2H as that hour two hours ago', async () => { + const hour = new Date(); + hour.setHours(hour.getHours() - 2); + await expect(parseFilter('createdAt', '-2H', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfHour(hour), $lte: endOfHour(hour) }, + }); + }); + + it('matches -15m.. as the last 15 minutes', async () => { + const from = new Date(); + from.setMinutes(from.getMinutes() - 15); + await expect(parseFilter('createdAt', '-15m..', jobModel)).resolves.toEqual({ + createdAt: { $gte: startOfMinute(from) }, + }); + }); + + it('does not treat lowercase m as a month', async () => { + const month = await parseFilter('createdAt', '-1M', jobModel); + const minute = await parseFilter('createdAt', '-1m', jobModel); + expect(month).not.toEqual(minute); + const from = new Date(); + from.setMinutes(from.getMinutes() - 1); + expect(minute).toEqual({ + createdAt: { $gte: startOfMinute(from), $lte: endOfMinute(from) }, + }); + }); + + it('omits invalid date text instead of sending it to Mongo', async () => { + await expect(parseFilter('createdAt', 'not-a-date', jobModel)).resolves.toEqual({}); + }); + + it('omits expressions that mix dates with non-date operands', async () => { + await expect(parseFilter('createdAt', 'TODAY|foo', jobModel)).resolves.toEqual({}); + }); + + it('rejects impossible calendar days', async () => { + await expect(parseFilter('createdAt', '32', jobModel)).resolves.toEqual({}); + }); + + it('getFilter drops invalid date fields and keeps valid ones', async () => { + const now = new Date(); + const filter = await getFilter( + { createdAt: 'yesterday', quantity: '12' }, + ['createdAt', 'quantity'], + true, + jobModel + ); + expect(filter).toEqual({ quantity: 12 }); + expect(filter.createdAt).toBeUndefined(); + + const today = await getFilter({ createdAt: 'TODAY' }, ['createdAt'], true, jobModel); + expect(today).toEqual({ + createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) }, + }); + }); +}); diff --git a/src/database/__tests__/utils.convertObjectId.test.js b/src/database/__tests__/utils.convertObjectId.test.js new file mode 100644 index 0000000..358975d --- /dev/null +++ b/src/database/__tests__/utils.convertObjectId.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from '@jest/globals'; +import mongoose from 'mongoose'; +import { convertObjectIdStringsInFilter } from '../utils.js'; + +describe('convertObjectIdStringsInFilter', () => { + it('preserves Date bounds on comparison operators', () => { + const start = new Date('2026-08-04T23:00:00.000Z'); + const end = new Date('2026-09-04T22:59:59.999Z'); + + expect( + convertObjectIdStringsInFilter({ + createdAt: { $gte: start, $lte: end }, + }) + ).toEqual({ + createdAt: { $gte: start, $lte: end }, + }); + }); + + it('converts ObjectId strings while leaving Date values intact', () => { + const start = new Date('2026-08-01T00:00:00.000Z'); + const id = '691a537b1e8acd0b40114206'; + + const result = convertObjectIdStringsInFilter({ + owner: id, + createdAt: { $gte: start }, + }); + + expect(result.owner).toEqual(new mongoose.Types.ObjectId(id)); + expect(result.createdAt.$gte).toBe(start); + }); +}); diff --git a/src/database/utils.js b/src/database/utils.js index 920c084..8e56def 100644 --- a/src/database/utils.js +++ b/src/database/utils.js @@ -49,6 +49,7 @@ function extractReferenceId(value) { /** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */ export function convertObjectIdStringsInFilter(filter) { + if (filter instanceof Date || filter instanceof mongoose.Types.ObjectId) return filter; if (!filter || typeof filter !== 'object') return filter; const result = {}; diff --git a/src/routes/management/auditlogs.js b/src/routes/management/auditlogs.js index e4ee9ea..add538f 100644 --- a/src/routes/management/auditlogs.js +++ b/src/routes/management/auditlogs.js @@ -7,6 +7,7 @@ import { getAuditLogStatsRouteHandler, getAuditLogHistoryRouteHandler, searchAuditLogsRouteHandler, + getAuditLogPropertyValuesRouteHandler, } from '../../services/management/auditlogs.js'; import { getFilter, getSort } from '../../utils.js'; @@ -15,22 +16,26 @@ const router = express.Router(); const listAllowedFilters = [ 'parent._id', 'owner._id', + 'owner', + 'parent', 'operation', 'createdAt', 'updatedAt', '_reference', ]; -const listAllowedSorters = ['createdAt', 'updatedAt']; +const listAllowedSorters = ['createdAt', 'updatedAt', 'owner', 'parent', 'operation']; router.get('/', isAuthenticated, checkPermissions('auditLog', 'list'), async (req, res) => { - const { page, limit, sortProperty, sortOrder } = req.query; + const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listAuditLogsRouteHandler( req, res, page, limit, + property, filter, + search, getSort(sortProperty, listAllowedSorters), sortOrder ); @@ -45,11 +50,32 @@ router.get('/stats', isAuthenticated, async (req, res) => { getAuditLogStatsRouteHandler(req, res); }); -router.get('/history', isAuthenticated, async (req, res) => { +router.get('/values', checkPermissions('auditLog', 'list'), isAuthenticated, async (req, res) => { + const { property } = req.query; + const filter = await getFilter(req.query, listAllowedFilters, true); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true); + } + getAuditLogPropertyValuesRouteHandler(req, res, property, filter, masterFilter); +}); + +router.get( + '/:id/properties', + checkPermissions('auditLog', 'list'), + isAuthenticated, + async (req, res) => { + const { id } = req.params; + const { property } = req.query; + getAuditLogPropertyValuesRouteHandler(req, res, property); + } +); + +router.get('/history', checkPermissions('auditLog', 'list'), isAuthenticated, async (req, res) => { getAuditLogHistoryRouteHandler(req, res); }); -router.get('/:id', async (req, res) => { +router.get('/:id', checkPermissions('auditLog', 'list'), isAuthenticated, async (req, res) => { await getAuditLogRouteHandler(req, res); }); diff --git a/src/routes/management/permissionsetting.js b/src/routes/management/permissionsetting.js index 704ad07..1423f16 100644 --- a/src/routes/management/permissionsetting.js +++ b/src/routes/management/permissionsetting.js @@ -22,31 +22,41 @@ import { getPermissionSettingsNeighborsRouteHandler, } from '../../services/management/permissionsetting.js'; -router.get('/', isAuthenticated, checkPermissions('permissionSetting', 'list'), async (req, res) => { - const { page, limit, property, search, sortProperty, sortOrder } = req.query; - const filter = await getFilter(req.query, listAllowedFilters); - listPermissionSettingsRouteHandler( - req, - res, - page, - limit, - property, - filter, - search, - getSort(sortProperty, listAllowedSorters), - sortOrder - ); -}); - -router.get('/properties', checkPermissions('permissionSetting', 'list'), isAuthenticated, async (req, res) => { - let properties = convertPropertiesString(req.query.properties); - const filter = await getFilter(req.query, propertiesAllowedFilters, false); - var masterFilter = {}; - if (req.query.masterFilter) { - masterFilter = JSON.parse(req.query.masterFilter); +router.get( + '/', + isAuthenticated, + checkPermissions('permissionSetting', 'list'), + async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listPermissionSettingsRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); } - listPermissionSettingsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); -}); +); + +router.get( + '/properties', + checkPermissions('permissionSetting', 'list'), + isAuthenticated, + async (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listPermissionSettingsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); + } +); router.get( '/values', @@ -57,58 +67,94 @@ router.get( const filter = await getFilter(req.query, listAllowedFilters, true); var masterFilter = {}; if (req.query.masterFilter) { - masterFilter = await getFilter( - JSON.parse(req.query.masterFilter), - listAllowedFilters, - true - ); + masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true); } getPermissionSettingsPropertyValuesRouteHandler(req, res, property, filter, masterFilter); } ); -router.get('/search', checkPermissions('permissionSetting', 'list'), isAuthenticated, async (req, res) => { - const { search } = req.query; - searchPermissionSettingsRouteHandler(req, res, search); -}); +router.get( + '/search', + checkPermissions('permissionSetting', 'list'), + isAuthenticated, + async (req, res) => { + const { search } = req.query; + searchPermissionSettingsRouteHandler(req, res, search); + } +); -router.post('/', isAuthenticated, checkPermissions('permissionSetting', 'new'), async (req, res) => { - newPermissionSettingsRouteHandler(req, res); -}); +router.post( + '/', + isAuthenticated, + checkPermissions('permissionSetting', 'new'), + async (req, res) => { + newPermissionSettingsRouteHandler(req, res); + } +); -router.get('/stats', isAuthenticated, async (req, res) => { - getPermissionSettingsStatsRouteHandler(req, res); -}); +router.get( + '/stats', + isAuthenticated, + checkPermissions('permissionSetting', 'list'), + async (req, res) => { + getPermissionSettingsStatsRouteHandler(req, res); + } +); -router.get('/history', isAuthenticated, async (req, res) => { - getPermissionSettingsHistoryRouteHandler(req, res); -}); +router.get( + '/history', + isAuthenticated, + checkPermissions('permissionSetting', 'list'), + async (req, res) => { + getPermissionSettingsHistoryRouteHandler(req, res); + } +); -router.get('/neighbors', isAuthenticated, async (req, res) => { - const { property, search, sortProperty, sortOrder, id } = req.query; - const filter = await getFilter(req.query, listAllowedFilters); - getPermissionSettingsNeighborsRouteHandler( - req, - res, - property, - filter, - search, - getSort(sortProperty, listAllowedSorters), - sortOrder, - id - ); -}); +router.get( + '/neighbors', + isAuthenticated, + checkPermissions('permissionSetting', 'list'), + async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getPermissionSettingsNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); + } +); -router.get('/:id', isAuthenticated, async (req, res) => { - getPermissionSettingsRouteHandler(req, res); -}); +router.get( + '/:id', + isAuthenticated, + checkPermissions('permissionSetting', 'info'), + async (req, res) => { + getPermissionSettingsRouteHandler(req, res); + } +); -router.put('/:id', isAuthenticated, checkPermissions('permissionSetting', 'edit'), async (req, res) => { - editPermissionSettingsRouteHandler(req, res); -}); +router.put( + '/:id', + isAuthenticated, + checkPermissions('permissionSetting', 'edit'), + async (req, res) => { + editPermissionSettingsRouteHandler(req, res); + } +); -router.delete('/:id', isAuthenticated, async (req, res) => { - deletePermissionSettingsRouteHandler(req, res); -}); +router.delete( + '/:id', + isAuthenticated, + checkPermissions('permissionSetting', 'delete'), + async (req, res) => { + deletePermissionSettingsRouteHandler(req, res); + } +); export default router; diff --git a/src/routes/management/users.js b/src/routes/management/users.js index 0d99a76..1e7b51a 100644 --- a/src/routes/management/users.js +++ b/src/routes/management/users.js @@ -29,15 +29,24 @@ import { setAppPasswordRouteHandler, searchUsersRouteHandler, getUserPropertyValuesRouteHandler, - - getUserNeighborsRouteHandler + getUserNeighborsRouteHandler, } from '../../services/management/users.js'; // list of document templates router.get('/', isAuthenticated, checkPermissions('user', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; - const filter = await getFilter(req.query, listAllowedFilters); - listUsersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); + const filter = await getFilter(req.query, listAllowedFilters); + listUsersRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); }); router.get('/properties', checkPermissions('user', 'list'), isAuthenticated, async (req, res) => { @@ -50,30 +59,20 @@ router.get('/properties', checkPermissions('user', 'list'), isAuthenticated, asy listUsersByPropertiesRouteHandler(req, res, properties, filter, masterFilter); }); -router.get( - '/values', - checkPermissions('user', 'list'), - isAuthenticated, - async (req, res) => { - const { property } = req.query; - const filter = await getFilter(req.query, listAllowedFilters, true); - var masterFilter = {}; - if (req.query.masterFilter) { - masterFilter = await getFilter( - JSON.parse(req.query.masterFilter), - listAllowedFilters, - true - ); - } - getUserPropertyValuesRouteHandler(req, res, property, filter, masterFilter); +router.get('/values', checkPermissions('user', 'list'), isAuthenticated, async (req, res) => { + const { property } = req.query; + const filter = await getFilter(req.query, listAllowedFilters, true); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true); } -); + getUserPropertyValuesRouteHandler(req, res, property, filter, masterFilter); +}); router.get('/search', checkPermissions('user', 'list'), isAuthenticated, async (req, res) => { const { search } = req.query; searchUsersRouteHandler(req, res, search); }); - // get user stats router.get('/stats', isAuthenticated, async (req, res) => { getUserStatsRouteHandler(req, res); @@ -87,10 +86,19 @@ router.get('/history', isAuthenticated, async (req, res) => { router.get('/neighbors', isAuthenticated, async (req, res) => { const { property, search, sortProperty, sortOrder, id } = req.query; const filter = await getFilter(req.query, listAllowedFilters); - getUserNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id); + getUserNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); }); -router.get('/:id', isAuthenticated, async (req, res) => { +router.get('/:id', isAuthenticated, checkPermissions('user', 'info'), async (req, res) => { getUserRouteHandler(req, res); }); @@ -99,8 +107,13 @@ router.put('/:id', isAuthenticated, checkPermissions('user', 'edit'), async (req editUserRouteHandler(req, res); }); -router.post('/:id/setAppPassword', isAuthenticated, checkPermissions('user', 'newAppPassword'), async (req, res) => { - setAppPasswordRouteHandler(req, res); -}); +router.post( + '/:id/setAppPassword', + isAuthenticated, + checkPermissions('user', 'newAppPassword'), + async (req, res) => { + setAppPasswordRouteHandler(req, res); + } +); export default router; diff --git a/src/services/management/auditlogs.js b/src/services/management/auditlogs.js index 52eb185..085620e 100644 --- a/src/services/management/auditlogs.js +++ b/src/services/management/auditlogs.js @@ -2,55 +2,51 @@ import config from '../../config.js'; import { auditLogModel } from '../../database/schemas/management/auditlog.schema.js'; import log4js from 'log4js'; import mongoose from 'mongoose'; -import { getModelStats, getModelHistory, searchObjects } from '../../database/database.js'; +import { + getModelStats, + getModelHistory, + searchObjects, + getObject, + getPropertyValues, + listObjects, +} from '../../database/database.js'; const logger = log4js.getLogger('AuditLogs'); logger.level = config.server.logLevel; +const AUDIT_LOG_POPULATE = ['owner', 'parent']; + export const listAuditLogsRouteHandler = async ( req, res, page = 1, limit = 25, + property = '', filter = {}, + search = '', sort = '', order = 'ascend' ) => { - try { - // Calculate the skip value based on the page number and limit - const skip = (page - 1) * limit; - const sortOrder = order === 'descend' ? 1 : -1; + const result = await listObjects({ + model: auditLogModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: AUDIT_LOG_POPULATE, + }); - if (!sort || sort != '') { - sort = 'createdAt'; - } - - // Use find with population and filter - let query = auditLogModel - .find(filter) - .sort({ [sort]: sortOrder }) - .skip(skip) - .limit(Number(limit)) - .populate([ - { path: 'owner', select: 'name _id color' }, - { path: 'parent', select: '_id name' }, - ]); - - const auditLogs = await query; - logger.trace( - `List of audit logs (Page ${page}, Limit ${limit}, Sort ${sort}, Order ${order}):`, - auditLogs - ); - - const expandedIdAuditLogs = auditLogs.map((auditLog) => { - const expendedAuditLog = { ...auditLog._doc }; - return expendedAuditLog; - }); - res.send(expandedIdAuditLogs); - } catch (error) { - logger.error('Error listing audit logs:', error); - res.status(500).send({ error: error }); + if (result?.error) { + logger.error('Error listing audit logs.'); + res.status(result.code).send(result); + return; } + + logger.debug(`List of audit logs (Page ${page}, Limit ${limit}). Count: ${result.length}.`); + res.send(result); }; export const searchAuditLogsRouteHandler = async (req, res, search) => { @@ -61,30 +57,34 @@ export const searchAuditLogsRouteHandler = async (req, res, search) => { res.send(result); }; +export const getAuditLogPropertyValuesRouteHandler = async ( + req, + res, + property, + filter, + masterFilter +) => { + const result = await getPropertyValues({ + model: auditLogModel, + property, + filter: { ...filter, ...masterFilter }, + }); + res.send(result); +}; + export const getAuditLogRouteHandler = async (req, res) => { - try { - // Get ID from params - const id = new mongoose.Types.ObjectId(req.params.id); - // Fetch the audit log with the given ID - const auditLog = await auditLogModel - .findOne({ - _id: id, - }) - .populate('printer') - .populate('owner') - .populate('parent'); - - if (!auditLog) { - logger.warn(`Audit log not found with supplied id.`); - return res.status(404).send({ error: 'Audit log not found.' }); - } - - logger.trace(`Audit log with ID: ${id}:`, auditLog); - res.send(auditLog); - } catch (error) { - logger.error('Error fetching audit log:', error); - res.status(500).send({ error: error.message }); + const id = req.params.id; + const result = await getObject({ + model: auditLogModel, + id, + populate: AUDIT_LOG_POPULATE, + }); + if (result?.error) { + logger.warn(`Audit log not found with supplied id.`); + return res.status(result.code).send(result); } + logger.debug(`Retreived audit log with ID: ${id}`); + res.send(result); }; export const getAuditLogStatsRouteHandler = async (req, res) => { diff --git a/src/utils.js b/src/utils.js index 3b1a9f0..43dffe0 100644 --- a/src/utils.js +++ b/src/utils.js @@ -42,9 +42,35 @@ logger.level = config.server.logLevel; // ? a single character Hans?n // @ ignore case @location (text matching is case-insensitive) // -// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare -// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the -// current month/year, "22..23" => start of 22 through end of 23. +// Date-typed fields (schema Date) interpret bare values as calendar +// days/datetimes, e.g. "22" => the whole of day 22 of the current month/year, +// "22..23" => start of 22 through end of 23. +// +// Relative date operands: +// TODAY current calendar day +// TODAY.. from start of today onwards +// -7D.. from 7 days ago onwards (last 7 days) +// -30D.. from 30 days ago onwards +// -1W previous calendar week (Monday–Sunday) +// -1M previous calendar month (as a whole day-range) +// -1M..-CM previous calendar month (from its start up to current month) +// -1Y previous calendar year +// -nD / +nD n days before / after today +// -nW / +nW n calendar weeks before / after the current week +// -nM / +nM n calendar months before / after the current month +// -nY / +nY n calendar years before / after the current year +// -nH / +nH n hours before / after the current hour +// -nm / +nm n minutes before / after the current minute (lowercase m) +// CW / CM / CY / CH / Cm current week / month / year / hour / minute +// -CW / -CM / -CY / -CH / -Cm +// current-unit boundary (start of this unit / end of last unit) +// +// Unit letters: D day, W week, M month, Y year, H hour, m minute. +// M (month) and m (minute) are case-sensitive; other units are not. +// +// An invalid date operand (anything other than a date, relative date, or +// filter operator) omits that field from the query instead of sending it to +// MongoDB. // // Note: BC's space-delimited date shorthand is locale dependent; here numeric // date operands are read day-first ("D M Y H Min S") with missing components @@ -60,11 +86,6 @@ function buildWildcardRegexPattern(input) { return `^${withWildcards}$`; } -function looksLikeDateField(property) { - const last = String(property).split('.').pop(); - return /[a-z](?:At|Date|Time)$/.test(last) || /^(?:date|time|datetime)$/i.test(last); -} - const NO_MATCH_CONDITION = { op: { $in: [] } }; function isBooleanField(property, model = null) { @@ -104,6 +125,30 @@ function isNumberField(property, model = null) { return false; } +function isDateTimeSchemaPath(path) { + if (!path) return false; + + if (path.instance === 'Date') return true; + + if (path.instance === 'Array') { + return getEmbeddedSchemaType(path)?.instance === 'Date'; + } + + return false; +} + +function isDateTimeField(property, model = null) { + if (model?.schema) { + return isDateTimeSchemaPath(model.schema.path(property)); + } + + for (const entry of Object.values(models)) { + if (isDateTimeSchemaPath(entry.model?.schema?.path(property))) return true; + } + + return false; +} + function buildNumberRegexCondition(property, pattern, negated = false) { const expr = { $regexMatch: { @@ -329,11 +374,207 @@ function endOfDay(date) { return d; } +function startOfMonth(date) { + return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); +} + +function endOfMonth(date) { + return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999); +} + +function shiftCalendarMonths(date, n) { + return new Date(date.getFullYear(), date.getMonth() + n, 1); +} + +function startOfWeek(date) { + const d = startOfDay(date); + const day = d.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + d.setDate(d.getDate() + mondayOffset); + return d; +} + +function endOfWeek(date) { + const start = startOfWeek(date); + const end = new Date(start); + end.setDate(end.getDate() + 6); + return endOfDay(end); +} + +function shiftWeeks(date, n) { + const d = new Date(date); + d.setDate(d.getDate() + n * 7); + return d; +} + +function startOfYear(date) { + return new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0); +} + +function endOfYear(date) { + return new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999); +} + +function shiftYears(date, n) { + return new Date(date.getFullYear() + n, 0, 1); +} + +function startOfHour(date) { + const d = new Date(date); + d.setMinutes(0, 0, 0); + return d; +} + +function endOfHour(date) { + const d = new Date(date); + d.setMinutes(59, 59, 999); + return d; +} + +function startOfMinute(date) { + const d = new Date(date); + d.setSeconds(0, 0); + return d; +} + +function endOfMinute(date) { + const d = new Date(date); + d.setSeconds(59, 999); + return d; +} + function normalizeYear(year) { if (year >= 100) return year; return year < 70 ? 2000 + year : 1900 + year; } +const RELATIVE_OFFSET_RE = /^([+-])?(\d+)([DdWwYyHhMm])$/; +const RELATIVE_CURRENT_RE = /^([+-])?C([DdWwYyHhMm])$/; + +function normalizeRelativeUnit(letter) { + if (letter === 'M' || letter === 'm') return letter; + return letter.toUpperCase(); +} + +function resolveRelativeUnit(now, unit, amount, boundary, isCurrent, minusCurrent) { + const end = boundary === 'end'; + + if (unit === 'D') { + if (isCurrent) return end ? endOfDay(now) : startOfDay(now); + const date = new Date(now); + date.setDate(date.getDate() + amount); + return end ? endOfDay(date) : startOfDay(date); + } + + if (unit === 'W') { + if (isCurrent && minusCurrent) { + return end ? endOfWeek(shiftWeeks(now, -1)) : startOfWeek(now); + } + const target = isCurrent ? now : shiftWeeks(now, amount); + return end ? endOfWeek(target) : startOfWeek(target); + } + + if (unit === 'M') { + if (isCurrent) { + if (minusCurrent) { + return end ? endOfMonth(shiftCalendarMonths(now, -1)) : startOfMonth(now); + } + return end ? endOfMonth(now) : startOfMonth(now); + } + const month = shiftCalendarMonths(now, amount); + return end ? endOfMonth(month) : startOfMonth(month); + } + + if (unit === 'Y') { + if (isCurrent) { + if (minusCurrent) { + return end ? endOfYear(shiftYears(now, -1)) : startOfYear(now); + } + return end ? endOfYear(now) : startOfYear(now); + } + const year = shiftYears(now, amount); + return end ? endOfYear(year) : startOfYear(year); + } + + if (unit === 'H') { + if (isCurrent) { + if (minusCurrent) { + const prev = new Date(now); + prev.setHours(prev.getHours() - 1); + return end ? endOfHour(prev) : startOfHour(now); + } + return end ? endOfHour(now) : startOfHour(now); + } + const date = new Date(now); + date.setHours(date.getHours() + amount); + return end ? endOfHour(date) : startOfHour(date); + } + + if (unit === 'm') { + if (isCurrent) { + if (minusCurrent) { + const prev = new Date(now); + prev.setMinutes(prev.getMinutes() - 1); + return end ? endOfMinute(prev) : startOfMinute(now); + } + return end ? endOfMinute(now) : startOfMinute(now); + } + const date = new Date(now); + date.setMinutes(date.getMinutes() + amount); + return end ? endOfMinute(date) : startOfMinute(date); + } + + return null; +} + +function parseRelativeDateOperand(text, boundary = 'start') { + const raw = String(text).trim(); + if (!raw) return null; + + if (/^TODAY$/i.test(raw)) { + const now = new Date(); + return boundary === 'end' ? endOfDay(now) : startOfDay(now); + } + + const offset = raw.match(RELATIVE_OFFSET_RE); + if (offset) { + const amount = (offset[1] === '-' ? -1 : 1) * Number(offset[2]); + return resolveRelativeUnit( + new Date(), + normalizeRelativeUnit(offset[3]), + amount, + boundary, + false, + false + ); + } + + const current = raw.match(RELATIVE_CURRENT_RE); + if (current) { + return resolveRelativeUnit( + new Date(), + normalizeRelativeUnit(current[2]), + 0, + boundary, + true, + current[1] === '-' + ); + } + + return null; +} + +function dateComponentsMatch(date, year, month, day, hour, minute, second) { + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day && + date.getHours() === hour && + date.getMinutes() === minute && + date.getSeconds() === second + ); +} + // Parses a single date operand to a Date, filling missing parts from the // current date and the supplied interval boundary ('start' | 'end'). function parseDateOperand(value, boundary = 'start') { @@ -341,6 +582,9 @@ function parseDateOperand(value, boundary = 'start') { if (!text) return null; const end = boundary === 'end'; + const relative = parseRelativeDateOperand(text, boundary); + if (relative) return relative; + // Values with explicit separators (ISO and similar) are parsed directly. if (/[-/T]/.test(text) || /\d:\d/.test(text)) { const parsed = new Date(text); @@ -364,7 +608,46 @@ function parseDateOperand(value, boundary = 'start') { const second = nums.length >= 6 ? nums[5] : end ? 59 : 0; const ms = end ? 999 : 0; const date = new Date(year, month - 1, day, hour, minute, second, ms); - return isNaN(date.getTime()) ? null : date; + if (isNaN(date.getTime())) return null; + if (!dateComponentsMatch(date, year, month, day, hour, minute, second)) return null; + return date; +} + +function isValidDateOperand(value) { + return parseDateOperand(value, 'start') != null; +} + +function isValidDateFilterLeaf(rawToken) { + const token = rawToken.trim(); + if (token === '') return true; + + const rangeIdx = token.indexOf('..'); + if (rangeIdx !== -1) { + const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim()); + const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim()); + if (lo !== '' && !isValidDateOperand(lo)) return false; + if (hi !== '' && !isValidDateOperand(hi)) return false; + return true; + } + + const operators = ['<>', '>=', '<=', '>', '<', '=']; + for (const symbol of operators) { + if (token.startsWith(symbol)) { + const operand = stripIgnoreCase(token.slice(symbol.length).trim()); + return operand !== '' && isValidDateOperand(operand); + } + } + + return isValidDateOperand(stripIgnoreCase(token)); +} + +function isValidDateFilterNode(node) { + if (node.type === 'leaf') return isValidDateFilterLeaf(node.token); + return node.items.every(isValidDateFilterNode); +} + +function isValidDateFilterExpression(expression) { + return isValidDateFilterNode(parseExpression(String(expression))); } // Coerces a literal to its strongest matching scalar type. @@ -380,8 +663,7 @@ function coerceScalar(value) { // Coerces an interval/comparison boundary, preferring a date for date fields. function coerceBoundary(value, isDateField, boundary) { if (isDateField) { - const date = parseDateOperand(value, boundary); - if (date) return date; + return parseDateOperand(value, boundary); } return coerceScalar(value); } @@ -462,6 +744,7 @@ function buildEquality( const start = parseDateOperand(value, 'start'); const end = parseDateOperand(value, 'end'); if (start && end) return { op: { $gte: start, $lte: end } }; + return NO_MATCH_CONDITION; } const lower = value.toLowerCase(); if (lower === 'true') return { value: true }; @@ -506,6 +789,7 @@ function buildComparison( const start = parseDateOperand(value, 'start'); const end = parseDateOperand(value, 'end'); if (start && end) return { op: { $not: { $gte: start, $lte: end } } }; + return NO_MATCH_CONDITION; } if (/[*?]/.test(value)) { if (isNumberField) { @@ -521,7 +805,9 @@ function buildComparison( // For dates, < and >= align to the start of the day, > and <= to the end. const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start'; - return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } }; + const bound = coerceBoundary(value, isDateField, boundary); + if (isDateField && !bound) return NO_MATCH_CONDITION; + return { op: { [`$${name}`]: bound } }; } function parseLeafComparisonOperators( @@ -582,6 +868,10 @@ function parseLeafCondition( return NO_MATCH_CONDITION; } } + if (isDateField) { + if (lo !== '' && !parseDateOperand(lo, 'start')) return NO_MATCH_CONDITION; + if (hi !== '' && !parseDateOperand(hi, 'end')) return NO_MATCH_CONDITION; + } const op = {}; if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start'); if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end'); @@ -882,10 +1172,13 @@ async function parseFilter(property, value, model = null) { expression = stripModelPrefixFromExpression(expression); } - const isDateField = looksLikeDateField(property); + const isDateField = isDateTimeField(property, model); const isObjectIdField = fieldKind.kind === 'objectId'; const isNumField = isNumberField(filterProperty, model); const tree = parseExpression(expression); + if (isDateField && !isValidDateFilterNode(tree)) { + return {}; + } const condition = buildCondition( tree, filterProperty, @@ -1803,7 +2096,15 @@ async function getFilter(query, allowedFilters, parse = true, model = null) { } for (const [key, value] of Object.entries(queryClean)) { if (allowedFilters.includes(key)) { - clauses.push(parse ? await parseFilter(key, value, model) : { [key]: value }); + if (parse) { + const clause = await parseFilter(key, value, model); + // Drop date filters that contain anything other than operators and dates. + if (clause && Object.keys(clause).length > 0) { + clauses.push(clause); + } + } else { + clauses.push({ [key]: value }); + } } } return mergeFilterClauses(clauses);