Skip to content

Commit 216aa77

Browse files
authored
Merge pull request #5329 from 3drepo/ISSUE_5244
Issue 5244 - Custom Tickets Query Filters
2 parents 5beeb9e + f3487f7 commit 216aa77

File tree

22 files changed

+991
-39
lines changed

22 files changed

+991
-39
lines changed

backend/src/v5/handler/db.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,10 @@ DBHandler.findOne = async (database, colName, query, projection, sort) => {
190190
return collection.findOne(query, options);
191191
};
192192

193-
DBHandler.find = async (database, colName, query, projection, sort, limit) => {
193+
DBHandler.find = async (database, colName, query, projection, sort, limit, skip = 0) => {
194194
const collection = await getCollection(database, colName);
195195
const options = deleteIfUndefined({ projection, sort });
196-
const cmd = collection.find(query, options);
196+
const cmd = collection.find(query, options).skip(skip);
197197
return limit ? cmd.limit(limit).toArray() : cmd.toArray();
198198
};
199199

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Copyright (C) 2024 3D Repo Ltd
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as
6+
* published by the Free Software Foundation, either version 3 of the
7+
* License, or (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
const { createResponseCode, templates } = require('../../../../../../../utils/responseCodes');
19+
const { queryParamSchema, querySchema } = require('../../../../../../../schemas/tickets/tickets.filters');
20+
const { respond } = require('../../../../../../../utils/responder');
21+
22+
const TicketQueryFilters = {};
23+
24+
TicketQueryFilters.validateQueryString = async (req, res, next) => {
25+
if (req.query.query) {
26+
try {
27+
const queryString = await querySchema.validate(decodeURIComponent(req.query.query));
28+
const queryParams = queryString.slice(1, -1).split('&&');
29+
const queryFilters = [];
30+
31+
await Promise.all(queryParams.map(async (param) => {
32+
const [propertyName, operator, value] = param.split('::');
33+
try {
34+
const validatedQuery = await queryParamSchema.validate({ propertyName, operator, value });
35+
queryFilters.push(validatedQuery);
36+
} catch (err) {
37+
throw createResponseCode(templates.invalidArguments, `Error at '${propertyName}' query filter: ${err.message}`);
38+
}
39+
}));
40+
41+
req.listOptions.queryFilters = queryFilters;
42+
} catch (err) {
43+
respond(req, res, createResponseCode(templates.invalidArguments, err.message));
44+
return;
45+
}
46+
}
47+
48+
await next();
49+
};
50+
51+
module.exports = TicketQueryFilters;

backend/src/v5/middleware/dataConverter/inputs/teamspaces/projects/models/commons/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Utils.validateListSortAndFilter = async (req, res, next) => {
3535
otherwise: (s) => s.default(true),
3636
}),
3737
updatedSince: types.date,
38+
limit: Yup.number().integer().min(1),
39+
skip: Yup.number().integer().min(0).default(0),
3840
});
3941
try {
4042
req.listOptions = await schema.validate(req.query, { stripUnknown: true });

backend/src/v5/middleware/dataConverter/inputs/teamspaces/projects/models/drawings/calibrations.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ const validateNewCalibrationData = async (req, res, next) => {
6363
model: positionArray(YupHelper.types.position).required(),
6464
drawing: positionArray(YupHelper.types.position2d).required(),
6565
}).required(),
66-
verticalRange: Yup.array().of(Yup.number()).length(2).required()
67-
.test('valid-verticalRange', 'The second number of the range must be larger than the first', (value) => value[0] <= value[1]),
66+
verticalRange: YupHelper.types.range.required(),
6867
units: YupHelper.types.strings.unit.required(),
6968
}).required().noUnknown();
7069

backend/src/v5/models/tickets.constants.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,69 @@
1515
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
*/
1717

18+
const { isNumberString } = require('../utils/helper/typeCheck');
19+
const { queryOperators } = require('../schemas/tickets/tickets.filters');
20+
const { toBoolean } = require('../utils/helper/strings');
21+
1822
const Tickets = {};
1923

2024
Tickets.TICKETS_RESOURCES_COL = 'tickets.resources';
2125

26+
Tickets.operatorToQuery = {
27+
[queryOperators.EXISTS]: (propertyName) => ({
28+
[propertyName]: { $exists: true },
29+
}),
30+
[queryOperators.NOT_EXISTS]: (propertyName) => ({
31+
[propertyName]: { $not: { $exists: true } },
32+
}),
33+
[queryOperators.IS]: (propertyName, value) => ({
34+
[propertyName]: { $in: value },
35+
}),
36+
[queryOperators.NOT_IS]: (propertyName, value) => ({
37+
[propertyName]: { $not: { $in: value } },
38+
}),
39+
[queryOperators.EQUALS]: (propertyName, value) => ({
40+
[propertyName]: { $in: value.flatMap((v) => {
41+
if (isNumberString(v)) return [Number(v), new Date(Number(v))];
42+
return toBoolean(v);
43+
}) },
44+
}),
45+
[queryOperators.NOT_EQUALS]: (propertyName, value) => ({
46+
[propertyName]: { $not: { $in: value.flatMap((v) => {
47+
if (isNumberString(v)) return [Number(v), new Date(Number(v))];
48+
return toBoolean(v);
49+
}) } },
50+
}),
51+
[queryOperators.CONTAINS]: (propertyName, value) => ({
52+
$or: value.map((val) => ({ [propertyName]: { $regex: val, $options: 'i' } })),
53+
}),
54+
[queryOperators.NOT_CONTAINS]: (propertyName, value) => ({
55+
$nor: value.map((val) => ({ [propertyName]: { $regex: val, $options: 'i' } })),
56+
}),
57+
[queryOperators.RANGE]: (propertyName, value) => ({
58+
$or: value.flatMap((range) => [
59+
{ [propertyName]: { $gte: range[0], $lte: range[1] } },
60+
{ [propertyName]: { $gte: new Date(range[0]), $lte: new Date(range[1]) } },
61+
]),
62+
}),
63+
[queryOperators.NOT_IN_RANGE]: (propertyName, value) => ({
64+
$nor: value.flatMap((range) => [
65+
{ [propertyName]: { $gte: range[0], $lte: range[1] } },
66+
{ [propertyName]: { $gte: new Date(range[0]), $lte: new Date(range[1]) } },
67+
]),
68+
}),
69+
[queryOperators.GREATER_OR_EQUAL_TO]: (propertyName, value) => ({
70+
$or: [
71+
{ [propertyName]: { $gte: value } },
72+
{ [propertyName]: { $gte: new Date(value) } },
73+
],
74+
}),
75+
[queryOperators.LESSER_OR_EQUAL_TO]: (propertyName, value) => ({
76+
$or: [
77+
{ [propertyName]: { $lte: value } },
78+
{ [propertyName]: { $lte: new Date(value) } },
79+
],
80+
}),
81+
};
82+
2283
module.exports = Tickets;

backend/src/v5/models/tickets.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const { Long } = DbHandler.dataTypes;
2727

2828
const Tickets = {};
2929
const TICKETS_COL = 'tickets';
30+
const TEMPLATES_COL = 'templates';
3031
const TICKETS_COUNTER_COL = 'tickets.counters';
3132

3233
const reserveTicketNumbers = async (teamspace, project, model, type, nToReserve) => {
@@ -161,6 +162,48 @@ Tickets.getTicketById = async (
161162
Tickets.getTicketsByQuery = (teamspace, project, model, query, projection) => DbHandler.find(teamspace,
162163
TICKETS_COL, { teamspace, project, model, ...query }, projection);
163164

165+
Tickets.getTicketsByFilter = (
166+
teamspace,
167+
project,
168+
model,
169+
{
170+
query,
171+
ticketCodeQuery,
172+
projection = { teamspace: 0, project: 0, model: 0 },
173+
updatedSince,
174+
sort = { [`properties.${basePropertyLabels.CREATED_AT}`]: -1 },
175+
limit,
176+
skip = 0,
177+
} = {},
178+
) => {
179+
const formattedQuery = { teamspace, project, model, ...query };
180+
181+
if (updatedSince) {
182+
formattedQuery[`properties.${basePropertyLabels.UPDATED_AT}`] = { $gt: updatedSince };
183+
}
184+
185+
if (ticketCodeQuery) {
186+
const pipelines = [
187+
{ $match: formattedQuery },
188+
{ $lookup: { from: TEMPLATES_COL, localField: 'type', foreignField: '_id', as: 'templateDetails' } },
189+
{ $unwind: '$templateDetails' },
190+
{ $addFields: { ticketCode: { $concat: ['$templateDetails.code', ':', { $toString: '$number' }] } } },
191+
{ $match: ticketCodeQuery },
192+
{ $project: projection },
193+
{ $sort: sort },
194+
{ $skip: skip },
195+
];
196+
197+
if (limit) {
198+
pipelines.push({ $limit: limit });
199+
}
200+
201+
return DbHandler.aggregate(teamspace, TICKETS_COL, pipelines);
202+
}
203+
204+
return DbHandler.find(teamspace, TICKETS_COL, formattedQuery, projection, sort, limit, skip);
205+
};
206+
164207
Tickets.getAllTickets = (
165208
teamspace,
166209
project,
@@ -169,16 +212,17 @@ Tickets.getAllTickets = (
169212
projection = { teamspace: 0, project: 0, model: 0 },
170213
updatedSince,
171214
sort = { [`properties.${basePropertyLabels.Created_AT}`]: -1 },
215+
limit,
216+
skip = 0,
172217
} = {},
173-
174218
) => {
175219
const query = { teamspace, project, model };
176220

177221
if (updatedSince) {
178222
query[`properties.${basePropertyLabels.UPDATED_AT}`] = { $gt: updatedSince };
179223
}
180224

181-
return DbHandler.find(teamspace, TICKETS_COL, query, projection, sort);
225+
return DbHandler.find(teamspace, TICKETS_COL, query, projection, sort, limit, skip);
182226
};
183227

184228
module.exports = Tickets;

backend/src/v5/processors/teamspaces/projects/models/commons/tickets.js

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
*/
1717

18+
const { TICKETS_RESOURCES_COL, operatorToQuery } = require('../../../../../models/tickets.constants');
1819
const { UUIDToString, generateUUID, stringToUUID } = require('../../../../../utils/helper/uuids');
1920
const { addGroups, deleteGroups, getGroupsByIds } = require('./tickets.groups');
20-
const { addTicketsWithTemplate, getAllTickets, getTicketById, updateTickets } = require('../../../../../models/tickets');
21+
const { addTicketsWithTemplate, getAllTickets, getTicketById, getTicketsByFilter, updateTickets } = require('../../../../../models/tickets');
2122
const {
2223
basePropertyLabels,
2324
modulePropertyLabels,
@@ -28,15 +29,15 @@ const {
2829
const { createResponseCode, templates } = require('../../../../../utils/responseCodes');
2930
const { deleteIfUndefined, isEmpty } = require('../../../../../utils/helper/objects');
3031
const { generateFullSchema, getClosedStatuses } = require('../../../../../schemas/tickets/templates');
32+
const { getAllTemplates, getTemplatesByQuery } = require('../../../../../models/tickets.templates');
3133
const { getFileWithMetaAsStream, removeFiles, storeFiles } = require('../../../../../services/filesManager');
3234
const { getNestedProperty, setNestedProperty } = require('../../../../../utils/helper/objects');
3335
const { isBuffer, isUUID } = require('../../../../../utils/helper/typeCheck');
34-
const { TICKETS_RESOURCES_COL } = require('../../../../../models/tickets.constants');
3536
const { events } = require('../../../../../services/eventsManager/eventsManager.constants');
36-
const { getAllTemplates } = require('../../../../../models/tickets.templates');
3737
const { getArrayDifference } = require('../../../../../utils/helper/arrays');
3838
const { importComments } = require('./tickets.comments');
3939
const { publish } = require('../../../../../services/eventsManager/eventsManager');
40+
const { specialQueryFields } = require('../../../../../schemas/tickets/tickets.filters');
4041

4142
const Tickets = {};
4243

@@ -326,8 +327,34 @@ const filtersToProjection = (filters) => {
326327
return projectionObject;
327328
};
328329

329-
Tickets.getTicketList = (teamspace, project, model,
330-
{ filters = [], updatedSince, sortBy, sortDesc }) => {
330+
const getQueryInfoFromQueryFilters = async (teamspace, queryFilters) => {
331+
const queries = [];
332+
let templateQuery;
333+
let ticketCodeQuery;
334+
335+
queryFilters.forEach(({ propertyName, operator, value }) => {
336+
if (propertyName === specialQueryFields.TEMPLATE) {
337+
templateQuery = operatorToQuery[operator]('code', value);
338+
} else if (propertyName === specialQueryFields.TICKET_CODE) {
339+
ticketCodeQuery = operatorToQuery[operator](propertyName, value);
340+
} else {
341+
queries.push({ ...operatorToQuery[operator](propertyName, value) });
342+
}
343+
});
344+
345+
if (templateQuery) {
346+
const temps = await getTemplatesByQuery(teamspace, templateQuery, { _id: 1 });
347+
queries.push({ type: { $in: temps.map(({ _id }) => _id) } });
348+
}
349+
350+
return {
351+
query: queries.length ? { $and: queries } : {},
352+
ticketCodeQuery,
353+
};
354+
};
355+
356+
Tickets.getTicketList = async (teamspace, project, model,
357+
{ filters = [], queryFilters = [], updatedSince, sortBy, sortDesc, limit, skip }) => {
331358
const { SAFETIBASE, SEQUENCING } = presetModules;
332359
const {
333360
[SAFETIBASE]: safetibaseProps,
@@ -361,7 +388,14 @@ Tickets.getTicketList = (teamspace, project, model,
361388
sort = { [propertyToFilterName(sortBy)]: sortDesc ? -1 : 1 };
362389
}
363390

364-
return getAllTickets(teamspace, project, model, deleteIfUndefined({ projection, updatedSince, sort }));
391+
if (queryFilters.length) {
392+
const queryInfo = await getQueryInfoFromQueryFilters(teamspace, queryFilters);
393+
return getTicketsByFilter(teamspace, project, model,
394+
deleteIfUndefined({ projection, updatedSince, sort, limit, skip, ...queryInfo }));
395+
}
396+
397+
return getAllTickets(teamspace, project, model,
398+
deleteIfUndefined({ projection, updatedSince, sort, limit, skip }));
365399
};
366400

367401
Tickets.getOpenTicketsCount = async (teamspace, project, model) => {

backend/src/v5/routes/teamspaces/projects/models/common/tickets.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const { getAllTemplates: getAllTemplatesInProject } = require('../../../../../pr
4848
const { getUserFromSession } = require('../../../../../utils/sessions');
4949
const { templates } = require('../../../../../utils/responseCodes');
5050
const { validateListSortAndFilter } = require('../../../../../middleware/dataConverter/inputs/teamspaces/projects/models/commons/utils');
51+
const { validateQueryString } = require('../../../../../middleware/dataConverter/inputs/teamspaces/projects/models/commons/ticketQueryFilters');
5152

5253
const createTicket = (isFed) => async (req, res) => {
5354
const { teamspace, project, model } = req.params;
@@ -576,19 +577,19 @@ const establishRoutes = (isFed) => {
576577
* schema:
577578
* type: string
578579
* - name: updatedSince
579-
* description: only return tickets that have been updated since a certain time (in epoch timestamp)
580+
* description: Only return tickets that have been updated since a certain time (in epoch timestamp)
580581
* in: query
581582
* required: false
582583
* schema:
583584
* type: number
584585
* - name: sortBy
585-
* description: specify what property the tickets should be sorted by (default is created at)
586+
* description: Specify what property the tickets should be sorted by (default is created at)
586587
* in: query
587588
* required: false
588589
* schema:
589590
* type: string
590591
* - name: sortDesc
591-
* description: specify whether the tickets should be sorted in descending order (default is true)
592+
* description: Specify whether the tickets should be sorted in descending order (default is true)
592593
* in: query
593594
* required: false
594595
* schema:
@@ -599,6 +600,24 @@ const establishRoutes = (isFed) => {
599600
* required: false
600601
* schema:
601602
* type: string
603+
* - name: query
604+
* description: Query string that defies tickets to be included in the response. More information here https://github.com/3drepo/3drepo.io/wiki/Custom-Ticket-Query-Filters
605+
* in: query
606+
* required: false
607+
* schema:
608+
* type: string
609+
* - name: skip
610+
* description: Skip the first x tickets to be returned
611+
* in: query
612+
* required: false
613+
* schema:
614+
* type: number
615+
* - name: limit
616+
* description: Limit the amount of tickets to be returned
617+
* in: query
618+
* required: false
619+
* schema:
620+
* type: number
602621
* responses:
603622
* 401:
604623
* $ref: "#/components/responses/notLoggedIn"
@@ -643,7 +662,7 @@ const establishRoutes = (isFed) => {
643662
* description: ticket modules and their properties
644663
*
645664
*/
646-
router.get('/', hasReadAccess, validateListSortAndFilter, getTicketsInModel(isFed), serialiseTicketList);
665+
router.get('/', hasReadAccess, validateListSortAndFilter, validateQueryString, getTicketsInModel(isFed), serialiseTicketList);
647666

648667
/**
649668
* @openapi

backend/src/v5/schemas/tickets/templates.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const pinColSchema = Yup.lazy((val) => {
5656
});
5757
});
5858

59-
const blackListedChrsRegex = /^[^.,[\]]*$/;
59+
const blackListedChrsRegex = /^(?!\$)(?!.*&&)[^.,[\]":]*$/;
6060

6161
const uniqueTypeBlackList = [
6262
propTypes.LONG_TEXT,

0 commit comments

Comments
 (0)