piper-utils 1.1.68 → 1.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,311 +1,311 @@
1
- import _ from 'lodash';
2
- import DB from 'sequelize';
3
- import dayjs from 'dayjs';
4
- import utc from 'dayjs/plugin/utc.js';
5
- import { errorList } from '../../../requestResponse/errorCodes.js';
6
- import { accessRightsUtils } from './accessRightsUtils.js';
7
-
8
- dayjs.extend(utc);
9
-
10
- /**
11
- * Create sequelize where clause from query string parameters and default sort object
12
- *
13
- * Translates URL query string parameters into a Sequelize-compatible WHERE object
14
- * by matching each param against a filter definition (objectFilters).
15
- *
16
- * Filter definitions come from defaultFilters() which maps schema data types to operators:
17
- * STRING / CHAR / TEXT -> Op.iLike (case-insensitive LIKE)
18
- * INTEGER / DECIMAL -> Op.or (positive or negative match)
19
- * BOOLEAN -> Op.eq (exact match with string-to-boolean coercion)
20
- * JSONB -> case-insensitive search via jsonb_extract_path_text
21
- * DATE -> Op.between
22
- *
23
- * Three convenience params are handled automatically:
24
- * - searchString: searches a single value across all iLike and eq fields using OR
25
- * - startDate + endDate: filters createdAt with a BETWEEN range
26
- *
27
- * @param {object} event - the Lambda event containing queryStringParameters
28
- * @param {object} objectFilters - filter definitions created by defaultFilters()
29
- * @returns {object} A Sequelize where clause object
30
- */
31
- export const createFilters = function (event, objectFilters) {
32
- let query = _.get(event, 'queryStringParameters', {}) || {};
33
-
34
- // Extract the three convenience params before iterating.
35
- // These are handled separately from the per-field filter logic below.
36
- const searchString = _.get(query, 'searchString');
37
- const startDate = _.get(query, 'startDate');
38
- const endDate = _.get(query, 'endDate');
39
-
40
- // ──────────────────────────────────────────────────────────────────────
41
- // STEP 1 — Build per-field WHERE conditions
42
- //
43
- // Each query param is looked up in objectFilters. If a matching filter
44
- // definition exists, we create the appropriate Sequelize condition.
45
- //
46
- // String-type fields (Op.iLike) are collected into a separate array
47
- // because they use DB.where(DB.fn('LOWER', DB.col(...))) which cannot
48
- // be expressed as a simple { column: { operator: value } } key.
49
- // Instead they are merged into WHERE via Op.and after the reduce.
50
- //
51
- // This approach replaces the Postgres-only Op.iLike with
52
- // LOWER(column) LIKE '%lowered_value%' which works across all
53
- // database dialects (Postgres, MySQL, MSSQL, SQLite).
54
- // ──────────────────────────────────────────────────────────────────────
55
- const caseInsensitiveConditions = [];
56
- const where = Object.keys(query).reduce((acc, item) => {
57
- const val = _.get(objectFilters, item, null);
58
-
59
- if (val) {
60
- // JSONB fields are handled entirely in Step 4 below.
61
- // Skip them here so they don't fall into the catch-all else branch.
62
- if (val.type === 'jsonb') {
63
- return acc;
64
- }
65
-
66
- // Dot-notation keys (e.g. "snowman.snowmanType") reference columns
67
- // on joined/included models. Sequelize requires these to be wrapped
68
- // in $...$ syntax in the WHERE object so it can resolve them.
69
- const itemName = item.includes('.')
70
- ? `$${item}$`
71
- : item;
72
-
73
- // STRING / CHAR / TEXT fields — case-insensitive partial match
74
- // Generates: WHERE LOWER("column") LIKE '%lowered_value%'
75
- if (val.filterType === DB.Op.iLike) {
76
- caseInsensitiveConditions.push(
77
- DB.where(
78
- DB.fn('LOWER', DB.col(item)),
79
- { [DB.Op.like]: `%${query[item]?.toLowerCase()}%` }
80
- )
81
- );
82
-
83
- // INTEGER / DECIMAL fields — match positive or negative value
84
- // Generates: WHERE "column" IN (-value, value)
85
- // Useful for fields like amounts where sign may vary.
86
- } else if (val.filterType === DB.Op.or) {
87
- acc[itemName] = {};
88
- acc[itemName][val.filterType] = [ -1 * +query[item], +query[item] ];
89
-
90
- // ENUM / multi-value fields — match any value in a comma-separated list
91
- // Query: ?status=open,closed -> WHERE "status" IN ('open','closed')
92
- } else if (val.filterType === DB.Op.in) {
93
- acc[itemName] = {};
94
- acc[itemName][val.filterType] = query[item].split(',');
95
-
96
- // Catch-all for other operators (Op.eq, Op.contains, Op.between, etc.)
97
- // Attempts JSON.parse first so structured values (objects, arrays, numbers)
98
- // are passed as their parsed type rather than raw strings.
99
- } else {
100
- acc[itemName] = {};
101
- let parsedJson;
102
- try {
103
- parsedJson = JSON.parse(query[item]);
104
- } catch (e) {
105
- parsedJson = query[item];
106
- }
107
- acc[itemName][val.filterType] = parsedJson;
108
- }
109
-
110
- // BOOLEAN override — runs after the block above so it can replace
111
- // the value set by the catch-all. Query strings are always strings
112
- // ("true", "false", "null"), so we coerce them to actual booleans/null.
113
- if (val.type === DB.BOOLEAN) {
114
- const queryValue = _.get(query, item, '').toLowerCase();
115
-
116
- const booleanMap = {
117
- 'false': false,
118
- 'null': null,
119
- 'true': true
120
- };
121
-
122
- acc[itemName][val.filterType] = booleanMap[queryValue];
123
- }
124
- }
125
-
126
- return acc;
127
- }, {});
128
-
129
- // Merge the case-insensitive string conditions into WHERE using Op.and.
130
- // This keeps them alongside the key-based conditions built above.
131
- // Example result: WHERE decimal = 100 AND LOWER("name") LIKE '%john%'
132
- if (caseInsensitiveConditions.length) {
133
- where[DB.Op.and] = [ ...(where[DB.Op.and] || []), ...caseInsensitiveConditions ];
134
- }
135
-
136
- // ──────────────────────────────────────────────────────────────────────
137
- // STEP 2 — Apply automatic filters (active flag, businessId, dates)
138
- //
139
- // These are appended to every query regardless of what the caller sent.
140
- // ──────────────────────────────────────────────────────────────────────
141
-
142
- // If the model has an "active" column and the caller did not explicitly
143
- // filter by it, default to only returning active records.
144
- if (objectFilters?.active && !where?.active) {
145
- where.active = true;
146
- }
147
-
148
- // Scope every query to the caller's authorized business IDs.
149
- // accessRightsUtils extracts them from the JWT claims on the event.
150
- const businessIds = accessRightsUtils(event);
151
- const filterAdds = {
152
- businessId: businessIds
153
- };
154
-
155
- // Date range filter — when both startDate and endDate are provided,
156
- // automatically filter createdAt with a BETWEEN clause.
157
- // Generates: WHERE "createdAt" BETWEEN '2024-01-01' AND '2024-12-31'
158
- if (startDate && endDate) {
159
- const s = dayjs.utc(startDate);
160
- const e = dayjs.utc(endDate);
161
- const dates = {
162
- [DB.Op.between]: [ s.toDate(), e.toDate() ]
163
- };
164
- if (!s.isValid()) {
165
- throw { ...errorList.invalidStartDate };
166
- }
167
-
168
- if (!e.isValid()) {
169
- throw { ...errorList.invalidEndDate };
170
- }
171
-
172
- filterAdds.createdAt = dates;
173
- }
174
-
175
- // ──────────────────────────────────────────────────────────────────────
176
- // STEP 3 — searchString: search one value across many fields with OR
177
- //
178
- // When ?searchString=john is provided, we build an OR array that checks
179
- // every filterable field for a match. This lets a single search box
180
- // query across name, title, email, etc. simultaneously.
181
- //
182
- // String fields (Op.iLike) get a case-insensitive LOWER + LIKE condition.
183
- // Numeric fields (Op.eq) get an exact match, but only when the search
184
- // value is a valid number (and the field is not a boolean).
185
- //
186
- // Generates: WHERE (LOWER("name") LIKE '%john%'
187
- // OR LOWER("title") LIKE '%john%'
188
- // OR "id" = 123)
189
- // ──────────────────────────────────────────────────────────────────────
190
- if (searchString) {
191
- const adds = Object.keys(objectFilters).reduce((acc, item) => {
192
- const type = objectFilters[item].filterType;
193
- const dataType = objectFilters[item].type;
194
-
195
- // String fields — case-insensitive partial match via LOWER + LIKE
196
- if (type === DB.Op.iLike) {
197
- acc.push(
198
- DB.where(
199
- DB.fn('LOWER', DB.col(item)),
200
- { [DB.Op.like]: `%${query['searchString'].toLowerCase()}%` }
201
- )
202
- );
203
- }
204
-
205
- // Numeric fields — exact match only when the search value is numeric.
206
- // Booleans are excluded because a search for "1" should not match
207
- // boolean true.
208
- if (!_.isNaN(Number(query['searchString'])) && !(dataType instanceof DB.BOOLEAN)) {
209
-
210
- if (type === DB.Op.eq) {
211
- const newOr = {};
212
- const itemName = item.includes('.') ? `$${item}$` : item;
213
- newOr[itemName] = {
214
- [DB.Op.eq]: `${query['searchString']}`
215
- };
216
- acc.push(newOr);
217
- }
218
- }
219
-
220
- return acc;
221
- }, []);
222
-
223
- filterAdds[DB.Op.or] = adds;
224
- }
225
-
226
- // Merge the automatic filters (businessId, createdAt, searchString OR)
227
- // into the where object built in Step 1.
228
- const withDate = Object.assign(where, filterAdds);
229
-
230
- // ──────────────────────────────────────────────────────────────────────
231
- // STEP 4 — JSONB field filters: case-insensitive search inside JSON columns
232
- //
233
- // JSONB columns store structured data (e.g. a "token" column containing
234
- // { cardType: "mastercard", cardHolderName: "GREGORY HERBERT" }).
235
- //
236
- // Query params use dot-notation to target nested keys:
237
- // ?token.cardHolderName=gregory
238
- // ?paymentProviderData.payment.result.message=approved
239
- //
240
- // For each matching param we:
241
- // 1. Split the path after the column prefix into individual key segments
242
- // 2. Use jsonb_extract_path_text(column, key1, key2, ...) to pull the
243
- // value out of the JSON as plain text
244
- // 3. Wrap it in LOWER() and compare with Op.like for case-insensitive match
245
- //
246
- // Generates: WHERE LOWER(jsonb_extract_path_text("token", 'cardHolderName'))
247
- // LIKE '%gregory%'
248
- //
249
- // jsonb_extract_path_text is used via DB.fn() so Sequelize parameterizes
250
- // the path arguments, keeping it safe from SQL injection.
251
- // ──────────────────────────────────────────────────────────────────────
252
- Object.keys(objectFilters || {}).forEach((k) => {
253
- const def = objectFilters[k];
254
- if (def && def.type === 'jsonb' && def.field) {
255
- const prefix = `${def.field}.`;
256
-
257
- Object.keys(query).forEach((qKey) => {
258
- if (qKey.startsWith(prefix)) {
259
- // Dot-notation: ?token.cardHolderName=gregory
260
- // e.g. "token.cardHolderName" -> pathParts = ["cardHolderName"]
261
- // e.g. "data.payment.result.message" -> pathParts = ["payment","result","message"]
262
- const pathParts = qKey.substring(prefix.length).split('.');
263
- const value = query[qKey];
264
-
265
- if (!withDate[DB.Op.and]) {
266
- withDate[DB.Op.and] = [];
267
- }
268
- withDate[DB.Op.and].push(
269
- DB.where(
270
- DB.fn('LOWER', DB.fn('jsonb_extract_path_text', DB.col(def.field), ...pathParts)),
271
- { [DB.Op.like]: `%${value?.toLowerCase()}%` }
272
- )
273
- );
274
- } else if (qKey === def.field) {
275
- // JSON-object-as-value: ?token={"cardHolderName":"Test"}
276
- // Parse the JSON and create a condition for each key-value pair.
277
- // Nested objects are flattened recursively into deeper path segments.
278
- let parsed;
279
- try {
280
- parsed = JSON.parse(query[qKey]);
281
- } catch (e) {
282
- return;
283
- }
284
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
285
- const addJsonbConditions = (obj, pathParts) => {
286
- Object.entries(obj).forEach(([key, value]) => {
287
- const currentPath = [...pathParts, key];
288
- if (value && typeof value === 'object' && !Array.isArray(value)) {
289
- addJsonbConditions(value, currentPath);
290
- } else if (value !== null && !Array.isArray(value)) {
291
- if (!withDate[DB.Op.and]) {
292
- withDate[DB.Op.and] = [];
293
- }
294
- withDate[DB.Op.and].push(
295
- DB.where(
296
- DB.fn('LOWER', DB.fn('jsonb_extract_path_text', DB.col(def.field), ...currentPath)),
297
- { [DB.Op.like]: `%${String(value)?.toLowerCase()}%` }
298
- )
299
- );
300
- }
301
- });
302
- };
303
- addJsonbConditions(parsed, []);
304
- }
305
- }
306
- });
307
- }
308
- });
309
-
310
- return withDate;
311
- };
1
+ import _ from 'lodash';
2
+ import DB from 'sequelize';
3
+ import dayjs from 'dayjs';
4
+ import utc from 'dayjs/plugin/utc.js';
5
+ import { errorList } from '../../../requestResponse/errorCodes.js';
6
+ import { accessRightsUtils } from './accessRightsUtils.js';
7
+
8
+ dayjs.extend(utc);
9
+
10
+ /**
11
+ * Create sequelize where clause from query string parameters and default sort object
12
+ *
13
+ * Translates URL query string parameters into a Sequelize-compatible WHERE object
14
+ * by matching each param against a filter definition (objectFilters).
15
+ *
16
+ * Filter definitions come from defaultFilters() which maps schema data types to operators:
17
+ * STRING / CHAR / TEXT -> Op.iLike (case-insensitive LIKE)
18
+ * INTEGER / DECIMAL -> Op.or (positive or negative match)
19
+ * BOOLEAN -> Op.eq (exact match with string-to-boolean coercion)
20
+ * JSONB -> case-insensitive search via jsonb_extract_path_text
21
+ * DATE -> Op.between
22
+ *
23
+ * Three convenience params are handled automatically:
24
+ * - searchString: searches a single value across all iLike and eq fields using OR
25
+ * - startDate + endDate: filters createdAt with a BETWEEN range
26
+ *
27
+ * @param {object} event - the Lambda event containing queryStringParameters
28
+ * @param {object} objectFilters - filter definitions created by defaultFilters()
29
+ * @returns {object} A Sequelize where clause object
30
+ */
31
+ export const createFilters = function (event, objectFilters) {
32
+ let query = _.get(event, 'queryStringParameters', {}) || {};
33
+
34
+ // Extract the three convenience params before iterating.
35
+ // These are handled separately from the per-field filter logic below.
36
+ const searchString = _.get(query, 'searchString');
37
+ const startDate = _.get(query, 'startDate');
38
+ const endDate = _.get(query, 'endDate');
39
+
40
+ // ──────────────────────────────────────────────────────────────────────
41
+ // STEP 1 — Build per-field WHERE conditions
42
+ //
43
+ // Each query param is looked up in objectFilters. If a matching filter
44
+ // definition exists, we create the appropriate Sequelize condition.
45
+ //
46
+ // String-type fields (Op.iLike) are collected into a separate array
47
+ // because they use DB.where(DB.fn('LOWER', DB.col(...))) which cannot
48
+ // be expressed as a simple { column: { operator: value } } key.
49
+ // Instead they are merged into WHERE via Op.and after the reduce.
50
+ //
51
+ // This approach replaces the Postgres-only Op.iLike with
52
+ // LOWER(column) LIKE '%lowered_value%' which works across all
53
+ // database dialects (Postgres, MySQL, MSSQL, SQLite).
54
+ // ──────────────────────────────────────────────────────────────────────
55
+ const caseInsensitiveConditions = [];
56
+ const where = Object.keys(query).reduce((acc, item) => {
57
+ const val = _.get(objectFilters, item, null);
58
+
59
+ if (val) {
60
+ // JSONB fields are handled entirely in Step 4 below.
61
+ // Skip them here so they don't fall into the catch-all else branch.
62
+ if (val.type === 'jsonb') {
63
+ return acc;
64
+ }
65
+
66
+ // Dot-notation keys (e.g. "snowman.snowmanType") reference columns
67
+ // on joined/included models. Sequelize requires these to be wrapped
68
+ // in $...$ syntax in the WHERE object so it can resolve them.
69
+ const itemName = item.includes('.')
70
+ ? `$${item}$`
71
+ : item;
72
+
73
+ // STRING / CHAR / TEXT fields — case-insensitive partial match
74
+ // Generates: WHERE LOWER("column") LIKE '%lowered_value%'
75
+ if (val.filterType === DB.Op.iLike) {
76
+ caseInsensitiveConditions.push(
77
+ DB.where(
78
+ DB.fn('LOWER', DB.col(item)),
79
+ { [DB.Op.like]: `%${query[item]?.toLowerCase()}%` }
80
+ )
81
+ );
82
+
83
+ // INTEGER / DECIMAL fields — match positive or negative value
84
+ // Generates: WHERE "column" IN (-value, value)
85
+ // Useful for fields like amounts where sign may vary.
86
+ } else if (val.filterType === DB.Op.or) {
87
+ acc[itemName] = {};
88
+ acc[itemName][val.filterType] = [ -1 * +query[item], +query[item] ];
89
+
90
+ // ENUM / multi-value fields — match any value in a comma-separated list
91
+ // Query: ?status=open,closed -> WHERE "status" IN ('open','closed')
92
+ } else if (val.filterType === DB.Op.in) {
93
+ acc[itemName] = {};
94
+ acc[itemName][val.filterType] = query[item].split(',');
95
+
96
+ // Catch-all for other operators (Op.eq, Op.contains, Op.between, etc.)
97
+ // Attempts JSON.parse first so structured values (objects, arrays, numbers)
98
+ // are passed as their parsed type rather than raw strings.
99
+ } else {
100
+ acc[itemName] = {};
101
+ let parsedJson;
102
+ try {
103
+ parsedJson = JSON.parse(query[item]);
104
+ } catch (e) {
105
+ parsedJson = query[item];
106
+ }
107
+ acc[itemName][val.filterType] = parsedJson;
108
+ }
109
+
110
+ // BOOLEAN override — runs after the block above so it can replace
111
+ // the value set by the catch-all. Query strings are always strings
112
+ // ("true", "false", "null"), so we coerce them to actual booleans/null.
113
+ if (val.type === DB.BOOLEAN) {
114
+ const queryValue = _.get(query, item, '').toLowerCase();
115
+
116
+ const booleanMap = {
117
+ 'false': false,
118
+ 'null': null,
119
+ 'true': true
120
+ };
121
+
122
+ acc[itemName][val.filterType] = booleanMap[queryValue];
123
+ }
124
+ }
125
+
126
+ return acc;
127
+ }, {});
128
+
129
+ // Merge the case-insensitive string conditions into WHERE using Op.and.
130
+ // This keeps them alongside the key-based conditions built above.
131
+ // Example result: WHERE decimal = 100 AND LOWER("name") LIKE '%john%'
132
+ if (caseInsensitiveConditions.length) {
133
+ where[DB.Op.and] = [ ...(where[DB.Op.and] || []), ...caseInsensitiveConditions ];
134
+ }
135
+
136
+ // ──────────────────────────────────────────────────────────────────────
137
+ // STEP 2 — Apply automatic filters (active flag, businessId, dates)
138
+ //
139
+ // These are appended to every query regardless of what the caller sent.
140
+ // ──────────────────────────────────────────────────────────────────────
141
+
142
+ // If the model has an "active" column and the caller did not explicitly
143
+ // filter by it, default to only returning active records.
144
+ if (objectFilters?.active && !where?.active) {
145
+ where.active = true;
146
+ }
147
+
148
+ // Scope every query to the caller's authorized business IDs.
149
+ // accessRightsUtils extracts them from the JWT claims on the event.
150
+ const businessIds = accessRightsUtils(event);
151
+ const filterAdds = {
152
+ businessId: businessIds
153
+ };
154
+
155
+ // Date range filter — when both startDate and endDate are provided,
156
+ // automatically filter createdAt with a BETWEEN clause.
157
+ // Generates: WHERE "createdAt" BETWEEN '2024-01-01' AND '2024-12-31'
158
+ if (startDate && endDate) {
159
+ const s = dayjs.utc(startDate);
160
+ const e = dayjs.utc(endDate);
161
+ const dates = {
162
+ [DB.Op.between]: [ s.toDate(), e.toDate() ]
163
+ };
164
+ if (!s.isValid()) {
165
+ throw { ...errorList.invalidStartDate };
166
+ }
167
+
168
+ if (!e.isValid()) {
169
+ throw { ...errorList.invalidEndDate };
170
+ }
171
+
172
+ filterAdds.createdAt = dates;
173
+ }
174
+
175
+ // ──────────────────────────────────────────────────────────────────────
176
+ // STEP 3 — searchString: search one value across many fields with OR
177
+ //
178
+ // When ?searchString=john is provided, we build an OR array that checks
179
+ // every filterable field for a match. This lets a single search box
180
+ // query across name, title, email, etc. simultaneously.
181
+ //
182
+ // String fields (Op.iLike) get a case-insensitive LOWER + LIKE condition.
183
+ // Numeric fields (Op.eq) get an exact match, but only when the search
184
+ // value is a valid number (and the field is not a boolean).
185
+ //
186
+ // Generates: WHERE (LOWER("name") LIKE '%john%'
187
+ // OR LOWER("title") LIKE '%john%'
188
+ // OR "id" = 123)
189
+ // ──────────────────────────────────────────────────────────────────────
190
+ if (searchString) {
191
+ const adds = Object.keys(objectFilters).reduce((acc, item) => {
192
+ const type = objectFilters[item].filterType;
193
+ const dataType = objectFilters[item].type;
194
+
195
+ // String fields — case-insensitive partial match via LOWER + LIKE
196
+ if (type === DB.Op.iLike) {
197
+ acc.push(
198
+ DB.where(
199
+ DB.fn('LOWER', DB.col(item)),
200
+ { [DB.Op.like]: `%${query['searchString'].toLowerCase()}%` }
201
+ )
202
+ );
203
+ }
204
+
205
+ // Numeric fields — exact match only when the search value is numeric.
206
+ // Booleans are excluded because a search for "1" should not match
207
+ // boolean true.
208
+ if (!_.isNaN(Number(query['searchString'])) && !(dataType instanceof DB.BOOLEAN)) {
209
+
210
+ if (type === DB.Op.eq) {
211
+ const newOr = {};
212
+ const itemName = item.includes('.') ? `$${item}$` : item;
213
+ newOr[itemName] = {
214
+ [DB.Op.eq]: `${query['searchString']}`
215
+ };
216
+ acc.push(newOr);
217
+ }
218
+ }
219
+
220
+ return acc;
221
+ }, []);
222
+
223
+ filterAdds[DB.Op.or] = adds;
224
+ }
225
+
226
+ // Merge the automatic filters (businessId, createdAt, searchString OR)
227
+ // into the where object built in Step 1.
228
+ const withDate = Object.assign(where, filterAdds);
229
+
230
+ // ──────────────────────────────────────────────────────────────────────
231
+ // STEP 4 — JSONB field filters: case-insensitive search inside JSON columns
232
+ //
233
+ // JSONB columns store structured data (e.g. a "token" column containing
234
+ // { cardType: "mastercard", cardHolderName: "GREGORY HERBERT" }).
235
+ //
236
+ // Query params use dot-notation to target nested keys:
237
+ // ?token.cardHolderName=gregory
238
+ // ?paymentProviderData.payment.result.message=approved
239
+ //
240
+ // For each matching param we:
241
+ // 1. Split the path after the column prefix into individual key segments
242
+ // 2. Use jsonb_extract_path_text(column, key1, key2, ...) to pull the
243
+ // value out of the JSON as plain text
244
+ // 3. Wrap it in LOWER() and compare with Op.like for case-insensitive match
245
+ //
246
+ // Generates: WHERE LOWER(jsonb_extract_path_text("token", 'cardHolderName'))
247
+ // LIKE '%gregory%'
248
+ //
249
+ // jsonb_extract_path_text is used via DB.fn() so Sequelize parameterizes
250
+ // the path arguments, keeping it safe from SQL injection.
251
+ // ──────────────────────────────────────────────────────────────────────
252
+ Object.keys(objectFilters || {}).forEach((k) => {
253
+ const def = objectFilters[k];
254
+ if (def && def.type === 'jsonb' && def.field) {
255
+ const prefix = `${def.field}.`;
256
+
257
+ Object.keys(query).forEach((qKey) => {
258
+ if (qKey.startsWith(prefix)) {
259
+ // Dot-notation: ?token.cardHolderName=gregory
260
+ // e.g. "token.cardHolderName" -> pathParts = ["cardHolderName"]
261
+ // e.g. "data.payment.result.message" -> pathParts = ["payment","result","message"]
262
+ const pathParts = qKey.substring(prefix.length).split('.');
263
+ const value = query[qKey];
264
+
265
+ if (!withDate[DB.Op.and]) {
266
+ withDate[DB.Op.and] = [];
267
+ }
268
+ withDate[DB.Op.and].push(
269
+ DB.where(
270
+ DB.fn('LOWER', DB.fn('jsonb_extract_path_text', DB.col(def.field), ...pathParts)),
271
+ { [DB.Op.like]: `%${value?.toLowerCase()}%` }
272
+ )
273
+ );
274
+ } else if (qKey === def.field) {
275
+ // JSON-object-as-value: ?token={"cardHolderName":"Test"}
276
+ // Parse the JSON and create a condition for each key-value pair.
277
+ // Nested objects are flattened recursively into deeper path segments.
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(query[qKey]);
281
+ } catch (e) {
282
+ return;
283
+ }
284
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
285
+ const addJsonbConditions = (obj, pathParts) => {
286
+ Object.entries(obj).forEach(([key, value]) => {
287
+ const currentPath = [...pathParts, key];
288
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
289
+ addJsonbConditions(value, currentPath);
290
+ } else if (value !== null && !Array.isArray(value)) {
291
+ if (!withDate[DB.Op.and]) {
292
+ withDate[DB.Op.and] = [];
293
+ }
294
+ withDate[DB.Op.and].push(
295
+ DB.where(
296
+ DB.fn('LOWER', DB.fn('jsonb_extract_path_text', DB.col(def.field), ...currentPath)),
297
+ { [DB.Op.like]: `%${String(value)?.toLowerCase()}%` }
298
+ )
299
+ );
300
+ }
301
+ });
302
+ };
303
+ addJsonbConditions(parsed, []);
304
+ }
305
+ }
306
+ });
307
+ }
308
+ });
309
+
310
+ return withDate;
311
+ };