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