Skip to content

Commit

Permalink
Adds pagination to extension request list (#1419)
Browse files Browse the repository at this point in the history
* feat : adds pagination for er list and also allows multiple search params

* test : adds test cases for ER pagination

* feat : updated query params

* feat : query parser

* refactor : change order of function parameters
  • Loading branch information
Ajeyakrishna-k committed Aug 28, 2023
1 parent 2aaaa1d commit fb4b2b4
Show file tree
Hide file tree
Showing 8 changed files with 300 additions and 5 deletions.
22 changes: 20 additions & 2 deletions controllers/extensionRequests.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const tasks = require("../models/tasks");
const { getUsername, getUsernameElseUndefined, getUserIdElseUndefined } = require("../utils/users");
const { EXTENSION_REQUEST_STATUS } = require("../constants/extensionRequests");
const { INTERNAL_SERVER_ERROR } = require("../constants/errorMessages");
const { transformQuery } = require("../utils/extensionRequests");
const { parseQueryParams } = require("../utils/queryParser");
/**
* Create ETA extension Request
*
Expand Down Expand Up @@ -89,8 +91,24 @@ const createTaskExtensionRequest = async (req, res) => {
*/
const fetchExtensionRequests = async (req, res) => {
try {
const { status, taskId, assignee } = req.query;
const allExtensionRequests = await extensionRequestsQuery.fetchExtensionRequests({ taskId, status, assignee });
const { status, taskId, assignee, dev, cursor, size, order } = parseQueryParams(req._parsedUrl.search);

const { transformedSize, transformedDev } = transformQuery(size, dev);

let allExtensionRequests;

if (transformedDev) {
allExtensionRequests = await extensionRequestsQuery.fetchPaginatedExtensionRequests(
{ taskId, status, assignee },
{ cursor, order, size: transformedSize, dev }
);
return res.json({
message: "Extension Requests returned successfully!",
...allExtensionRequests,
});
} else {
allExtensionRequests = await extensionRequestsQuery.fetchExtensionRequests({ taskId, status, assignee });
}

return res.json({
message: "Extension Requests returned successfully!",
Expand Down
33 changes: 33 additions & 0 deletions middlewares/validators/extensionRequests.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const joi = require("joi");
const { EXTENSION_REQUEST_STATUS } = require("../../constants/extensionRequests");
const { parseQueryParams } = require("../../utils/queryParser");
const { BAD_REQUEST } = require("../../constants/errorMessages");

const ER_STATUS_ENUM = Object.values(EXTENSION_REQUEST_STATUS);

const createExtensionRequest = async (req, res, next) => {
const schema = joi
Expand Down Expand Up @@ -60,8 +64,37 @@ const updateExtensionRequest = async (req, res, next) => {
}
};

const getExtensionRequestsValidator = async (req, res, next) => {
const schema = joi.object().keys({
dev: joi.bool().optional().sensitive(),
status: joi
.alternatives()
.try(joi.string().valid(...ER_STATUS_ENUM), joi.array().items(joi.string().valid(...ER_STATUS_ENUM)))
.optional(),
cursor: joi.string().optional(),
order: joi.string().valid("asc", "desc").optional(),
size: joi.number().integer().positive().min(1).max(100).optional(),
assignee: joi.alternatives().try(joi.string(), joi.array().items(joi.string())).optional(),
taskId: joi.alternatives().try(joi.string(), joi.array().items(joi.string())).optional(),
});

try {
const queries = parseQueryParams(req._parsedUrl.search);
if (!queries) {
res.boom.badRequest(BAD_REQUEST);
return;
}
await schema.validateAsync(queries);
next();
} catch (error) {
logger.error(`Error validating fetch extension requests query : ${error}`);
res.boom.badRequest(error.details[0].message);
}
};

module.exports = {
createExtensionRequest,
updateExtensionRequest,
updateExtensionRequestStatus,
getExtensionRequestsValidator,
};
66 changes: 65 additions & 1 deletion models/extensionRequests.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const firestore = require("../utils/firestore");
const extensionRequestsModel = firestore.collection("extensionRequests");
const { buildExtensionRequests, formatExtensionRequest } = require("../utils/extensionRequests");
const { buildExtensionRequests, formatExtensionRequest, generateNextLink } = require("../utils/extensionRequests");

/**
* Create Extension Request
Expand Down Expand Up @@ -69,6 +69,69 @@ const fetchExtensionRequests = async (extensionRequestQuery) => {
}
};

/**
* Fetch all Extension Requests
* @param extensionRequestQuery { Object }: Body of the extension request
* @param extensionRequestQuery.status {string} : STATUS of the extension request
* @param extensionRequestQuery.assignee {string} : assignee of the extension request
* @param extensionRequestQuery.taskId {string} : taskId of the extension request
* @param paginationQuery.cursor {string} : Id of the extension request
* @param paginationQuery.size {number} : maximum number of items in response
* @param paginationQuery.order {string} : order for timestamp/created time
* @return Array of Extension Requests {Promise<ExtensionRequestsArray|Array>}
*/
const fetchPaginatedExtensionRequests = async (extensionRequestQuery, paginationQuery) => {
try {
let extensionRequestsSnapshot = extensionRequestsModel;

Object.entries(extensionRequestQuery).forEach(([key, value]) => {
if (value) {
const opStr = Array.isArray(value) ? "in" : "==";
extensionRequestsSnapshot = extensionRequestsSnapshot.where(key, opStr, value);
}
});

const { cursor, size, order } = paginationQuery;

if (order) {
extensionRequestsSnapshot = extensionRequestsSnapshot.orderBy("timestamp", order);
}

if (cursor) {
const data = await extensionRequestsModel.doc(cursor).get();
extensionRequestsSnapshot = extensionRequestsSnapshot.startAfter(data).limit(size);
} else if (size) {
extensionRequestsSnapshot = extensionRequestsSnapshot.limit(size);
}

extensionRequestsSnapshot = await extensionRequestsSnapshot.get();

const requests = buildExtensionRequests(extensionRequestsSnapshot);
const promises = requests.map((request) => formatExtensionRequest(request));
const updatedRequests = await Promise.all(promises);

const resultDataLength = extensionRequestsSnapshot.docs.length;
const isNextLinkRequired = size && resultDataLength === size;
const lastVisible = isNextLinkRequired && extensionRequestsSnapshot.docs[resultDataLength - 1];

const nextPageParams = {
...extensionRequestQuery,
...paginationQuery,
cursor: lastVisible?.id,
};

let nextLink = "";
if (lastVisible) {
nextLink = generateNextLink(nextPageParams);
}

return { allExtensionRequests: updatedRequests, next: nextLink };
} catch (err) {
logger.error("error getting extension requests", err);
throw err;
}
};

const fetchExtensionRequest = async (extensionRequestId) => {
try {
const extensionRequest = await extensionRequestsModel.doc(extensionRequestId).get();
Expand All @@ -85,4 +148,5 @@ module.exports = {
fetchExtensionRequests,
fetchExtensionRequest,
updateExtensionRequest,
fetchPaginatedExtensionRequests,
};
9 changes: 8 additions & 1 deletion routes/extensionRequests.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ const {
createExtensionRequest,
updateExtensionRequest,
updateExtensionRequestStatus,
getExtensionRequestsValidator,
} = require("../middlewares/validators/extensionRequests");

router.post("/", authenticate, createExtensionRequest, extensionRequests.createTaskExtensionRequest);
router.get("/", authenticate, authorizeRoles([SUPERUSER, APPOWNER]), extensionRequests.fetchExtensionRequests);
router.get(
"/",
authenticate,
authorizeRoles([SUPERUSER, APPOWNER]),
getExtensionRequestsValidator,
extensionRequests.fetchExtensionRequests
);
router.get("/self", authenticate, extensionRequests.getSelfExtensionRequests);
router.get("/:id", authenticate, authorizeRoles([SUPERUSER, APPOWNER]), extensionRequests.getExtensionRequest);
router.patch(
Expand Down
60 changes: 60 additions & 0 deletions test/integration/extensionRequests.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,66 @@ describe("Extension Requests", function () {
return done();
});
});

it("Should return paginated response when dev flag and size is passed", function (done) {
const fetchPaginatedExtensionRequestStub = sinon.stub(extensionRequests, "fetchPaginatedExtensionRequests");
chai
.request(app)
.get("/extension-requests?q=dev:true,size:10")
.set("cookie", `${cookieName}=${superUserJwt}`)
.end((err, res) => {
if (err) {
return done(err);
}
expect(fetchPaginatedExtensionRequestStub.calledOnce).to.be.equal(true);

return done();
});
});

it("Should have the link to get next set of results", function (done) {
chai
.request(app)
.get(`/extension-requests?q=dev:true,size:10`)
.set("cookie", `${cookieName}=${superUserJwt}`)
.end((err, res) => {
if (err) {
return done(err);
}

expect(res).to.have.status(200);
expect(res.body).to.have.property("next");
return done();
});
});

it("Should get all extension requests filtered with status when multiple params are passed", function (done) {
chai
.request(app)
.get(
`/extension-requests?q=dev:true,status:${EXTENSION_REQUEST_STATUS.APPROVED}+${EXTENSION_REQUEST_STATUS.PENDING}`
)
.set("cookie", `${cookieName}=${superUserJwt}`)
.end((err, res) => {
if (err) {
return done(err);
}
expect(res).to.have.status(200);
expect(res.body).to.be.a("object");
expect(res.body.message).to.equal("Extension Requests returned successfully!");
expect(res.body.allExtensionRequests).to.be.a("array");
expect(res.body).to.have.property("next");

const extensionRequestsList = res.body.allExtensionRequests ?? [];
extensionRequestsList.forEach((extensionReq) => {
expect(extensionReq.status).to.be.oneOf([
EXTENSION_REQUEST_STATUS.APPROVED,
EXTENSION_REQUEST_STATUS.PENDING,
]);
});
return done();
});
});
});

describe("PATCH /extension-requests/:id/status", function () {
Expand Down
31 changes: 31 additions & 0 deletions test/unit/utils/queryParser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const { expect } = require("chai");
const { parseQueryParams } = require("../../../utils/queryParser");

describe("parseQueryParams", function () {
it("parses query parameters correctly", function () {
const queryString = "?q=status:APPROVED+DENIED,assignee:user1";
const parsedParams = parseQueryParams(queryString);

expect(parsedParams).to.deep.equal({
status: ["APPROVED", "DENIED"],
assignee: "user1",
});
});

it("handles empty or malformed query parameters", function () {
const queryString = "?q=;()";
const parsedParams = parseQueryParams(queryString);

expect(parsedParams).to.deep.equal({});
});

it('handles multiple values for non-"q" parameters', function () {
const queryString = "?status=APPROVED&status=DENIED&assignee=user1";
const parsedParams = parseQueryParams(queryString);

expect(parsedParams).to.deep.equal({
status: ["APPROVED", "DENIED"],
assignee: "user1",
});
});
});
33 changes: 32 additions & 1 deletion utils/extensionRequests.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const { getUsername } = require("./users");

const buildExtensionRequests = (extensionRequests, initialArray = []) => {
if (!extensionRequests.empty) {
extensionRequests.forEach((extensionRequests) => {
Expand All @@ -25,7 +24,39 @@ const formatExtensionRequest = async (extensionRequest) => {
return { ...body, id, timestamp, assignee };
};

const transformQuery = (size, dev = false) => {
const transformedDev = JSON.parse(dev);

let transformedSize;
if (size) {
transformedSize = parseInt(size);
}

return { transformedSize: transformedSize, transformedDev: transformedDev };
};

const generateNextLink = (nextPageParams) => {
const queryStringList = [];
for (const [key, value] of Object.entries(nextPageParams)) {
if (value) {
let queryString;
if (Array.isArray(value)) {
queryString = key + ":" + value.join("+");
} else {
queryString = key + ":" + value;
}
queryStringList.push(queryString);
}
}
const urlSearchParams = new URLSearchParams();
urlSearchParams.append("q", queryStringList.join(","));
const nextLink = `/extension-requests?${urlSearchParams.toString()}`;
return nextLink;
};

module.exports = {
buildExtensionRequests,
formatExtensionRequest,
transformQuery,
generateNextLink,
};
51 changes: 51 additions & 0 deletions utils/queryParser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Parses the query params and returns a key value object
*
* @param queryString {string} - string which is received on the http request
* @return resultParams {Object} - contains key value pairs of query params
*/
const parseQueryParams = (queryString) => {
try {
const urlParams = new URLSearchParams(queryString);

const resultParams = {};
for (const [key, value] of urlParams) {
if (!key || !value) continue;
if (key !== "q") {
if (!resultParams[key]) {
resultParams[key] = new Set();
}
resultParams[key].add(value);
continue;
}
const queries = value.trim().replace(" ", "+").split(",");
for (const query of queries) {
if (!query) continue;
const [searchTerm, searchValueString] = query.trim().split(":");
const searchValues = searchValueString.trim().split("+");
for (const searchValue of searchValues) {
if (!searchValue) continue;
if (!resultParams[searchTerm]) {
resultParams[searchTerm] = new Set();
}
resultParams[searchTerm].add(searchValue);
}
}
}

for (const [key, value] of Object.entries(resultParams)) {
if (value.size > 1) {
resultParams[key] = [...resultParams[key]];
} else {
const [first] = resultParams[key];
resultParams[key] = first;
}
}
return resultParams;
} catch (error) {
logger.error(`Error parsing the queries: ${error}`);
}
return {};
};

module.exports = { parseQueryParams };

0 comments on commit fb4b2b4

Please sign in to comment.