Skip to content

Commit

Permalink
Merge pull request #70 from bcgov/SPR-156-File-Loading-Optimization
Browse files Browse the repository at this point in the history
SPR-156 File Loading Optimization
  • Loading branch information
dbarkowsky authored Jul 4, 2023
2 parents 5a3ecfc + 5ce4bc5 commit da38b3d
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 44 deletions.
134 changes: 126 additions & 8 deletions api/controllers/requests-api-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { z } from 'zod';
import { checkForCompleteRequest } from '../helpers/checkForCompleteRequest';
import { getUserInfo } from '../keycloak/utils';
import Constants from '../constants/Constants';
import { Purchase } from '../interfaces/Purchase';
import { Approval } from '../interfaces/Approval';

// All functions use requests collection
const collection: Collection<RequestRecord> = db.collection<RequestRecord>('requests');
Expand Down Expand Up @@ -35,8 +37,8 @@ export interface RequestRecord {
lastName: string;
employeeId: number;
idir: string;
purchases: object[];
approvals: object[];
purchases: Purchase[];
approvals: Approval[];
additionalComments: string;
submit: boolean;
submissionDate: string;
Expand Down Expand Up @@ -149,6 +151,12 @@ export const getRequestsByIDIR = async (req: Request, res: Response) => {
export const getRequestByID = async (req: Request, res: Response) => {
const { id } = req.params;
const { TESTING } = Constants;

// Projection to get everything except a file's data
const noFileProjection = {
'purchases.fileObj.file': 0,
};

// If ID doesn't match schema, return a 400
try {
idSchema.parse(id);
Expand All @@ -157,9 +165,14 @@ export const getRequestByID = async (req: Request, res: Response) => {
}

try {
const record: WithId<RequestRecord> = await collection.findOne({
_id: { $eq: new ObjectId(id) },
});
const record: WithId<RequestRecord> = await collection.findOne(
{
_id: { $eq: new ObjectId(id) },
},
{
projection: noFileProjection,
},
);
if (!record) {
return res.status(404).send('No record with that ID found.');
}
Expand All @@ -172,7 +185,6 @@ export const getRequestByID = async (req: Request, res: Response) => {
if (!idirMatches && !isAdmin)
return res.status(403).send('Forbidden: User does not match requested record.');
}

return res.status(200).json(record);
}
} catch (e) {
Expand Down Expand Up @@ -234,11 +246,40 @@ export const updateRequestState = async (req: Request, res: Response) => {
}
}

// If new files weren't uploaded, incoming patch won't have base64 file data, only metadata.
// Check each incoming file in purchases and approvals. If there's no base64 file, use the file from the existing record
const refinedPurchases: Purchase[] = purchases
? purchases.map((purchase: Purchase, index: number) => {
if (purchase.fileObj && purchase.fileObj.file) {
return purchase;
} else {
return existingRequest.purchases[index];
}
})
: [];

const refinedApprovals: Approval[] = approvals
? approvals.map((approval: Approval, index: number) => {
if (approval.fileObj && approval.fileObj.file) {
return approval;
} else if (existingRequest.approvals[index].fileObj) {
return {
...approval,
fileObj: existingRequest.approvals[index].fileObj,
};
} else {
return existingRequest.approvals[index];
}
})
: [];

console.log(refinedApprovals);

// Create setting object
const newProperties = {
approvals: approvals || existingRequest?.approvals || [],
approvals: refinedApprovals,
additionalComments: additionalComments || existingRequest?.additionalComments || '',
purchases: purchases || existingRequest?.purchases || [],
purchases: refinedPurchases,
employeeId: employeeId || existingRequest?.employeeId || 999999,
state:
refinedState === undefined || refinedState === null ? existingRequest.state : refinedState,
Expand All @@ -260,3 +301,80 @@ export const updateRequestState = async (req: Request, res: Response) => {
return res.status(400).send('Request could not be processed.');
}
};

/**
* @description Gets all files associated with a record or a single requested file based on record ID and file upload timestamp
* @param {Request} req The incoming request.
* @param {Response} res The returned response.
* @returns A response with the attached base64 files in the body.
*/
export const getFile = async (req: Request, res: Response) => {
const { id } = req.params;
const { date } = req.query;
const { TESTING } = Constants;

interface FilesRecord {
idir: string;
purchases: Purchase[];
approvals: Approval[];
}

// Projection to get only the file's data
const onlyFileProjection = {
idir: 1,
'purchases.fileObj': 1,
'approvals.fileObj': 1,
};

// If ID doesn't match schema, return a 400
try {
idSchema.parse(id);
} catch (e) {
return res.status(400).send('ID was malformed. Cannot complete request.');
}

try {
// Get all files from that record
const record: WithId<FilesRecord> = await collection.findOne(
{
_id: { $eq: new ObjectId(id) },
},
{
projection: onlyFileProjection,
},
);

// Return 404 if that record didn't exist
if (!record) {
return res.status(404).send('No record with that ID found.');
} else {
if (!TESTING) {
// If neither the IDIR matches nor the user is admin, return the 403
const userInfo = getUserInfo(req.headers.authorization.split(' ')[1]); // Excludes the 'Bearer' part of token.
const idirMatches = userInfo.idir_user_guid === record.idir;
const isAdmin = userInfo.client_roles?.includes('admin');
if (!idirMatches && !isAdmin)
return res.status(403).send('Forbidden: User does not match requested record.');
}

// Flat map files
const allFiles = [...record.purchases, ...record.approvals].map((el) => el.fileObj);
if (date) {
// Select only the file with the matching date
const desiredFile = allFiles.find((fileObj) => fileObj && fileObj.date === date);
// Return 404 if no files were returned
if (!desiredFile) {
return res.status(404).send('No file matches that request.');
}
return res.status(200).json({ file: desiredFile.file });
} else {
// No date? Return all files
return res.status(200).json({ files: allFiles });
}
}
} catch (e) {
console.warn(e);
// Error response
return res.status(400).send('Request could not be processed.');
}
};
98 changes: 77 additions & 21 deletions api/docs/requests.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
components:
### SCHEMAS ###
### SCHEMAS ###
schemas:
ReimbursementRequestObject:
type: object
description: The base reimbursement request. All requests should have these properties.
properties:
properties:
firstName:
type: string
example: Joe
Expand Down Expand Up @@ -40,7 +40,7 @@ components:
size:
type: number
example: 94702
file:
file:
type: string
example: base64EncodedFile
additionalComments:
Expand All @@ -60,8 +60,8 @@ components:
properties:
data:
allOf:
- $ref: '#/components/schemas/ReimbursementRequestObject'
- type: object
- $ref: '#/components/schemas/ReimbursementRequestObject'
- type: object
properties:
lateEntry:
type: boolean
Expand Down Expand Up @@ -90,22 +90,22 @@ components:
items:
type: object
properties:
approvalDate:
approvalDate:
type: string
example: '2023-05-24T20:08:34.000Z'
fileObj:
type: object
properties:
name:
name:
type: string
example: my_approval.pdf
size:
size:
type: number
example: 94702
date:
date:
type: string
example: '2023-06-05T20:08:38.709Z'
file:
file:
type: string
example: base64EncodedFile
responses:
Expand Down Expand Up @@ -147,7 +147,7 @@ paths:
responses:
'201':
description: OK
content:
content:
application/json:
schema:
$ref: '#/components/schemas/ReimbursementRequest'
Expand All @@ -159,13 +159,13 @@ paths:
tags:
- Requests
summary: Returns all request documents from the database.
description: Returns all request documents from the database.
description: Returns all request documents from the database.
parameters:
- in: query
name: minimal
schema:
type: boolean
example: true
example: true
description: Boolean to determine if a minimal version of each document is returned.
- in: query
name: before
Expand Down Expand Up @@ -208,7 +208,7 @@ paths:
name: minimal
schema:
type: boolean
example: true
example: true
description: Boolean to determine if a minimal version of each document is returned.
- in: query
name: idir
Expand Down Expand Up @@ -294,22 +294,22 @@ paths:
items:
type: object
properties:
approvalDate:
approvalDate:
type: string
example: '2023-05-24T20:08:34.000Z'
fileObj:
type: object
properties:
name:
name:
type: string
example: my_approval.pdf
size:
size:
type: number
example: 94702
date:
date:
type: string
example: '2023-06-05T20:08:38.709Z'
file:
file:
type: string
example: base64EncodedFile
purchases:
Expand All @@ -335,7 +335,7 @@ paths:
size:
type: number
example: 94702
file:
file:
type: string
example: base64EncodedFile
additionalComments:
Expand Down Expand Up @@ -363,6 +363,62 @@ paths:
text/plain:
schema:
type: string
example: 'An invalid state was requested. || Forbidden: User does not match requested record.'
example: 'An invalid state was requested. || Forbidden: User does not match requested record.'
'429':
$ref: '#/components/responses/429TooManyRequests'

/requests/{id}/files:
get:
security:
- bearerAuth: []
tags:
- Requests
summary: Returns a single file from a specific request record.
description: Returns a single file from a specific request record.
parameters:
- in: path
name: id
schema:
type: string
example: 64480513a30c8be7b83d9593
description: The document ID of the request record.
- in: query
name: date
schema:
type: string
example: 2023-07-03T19:50:22.310Z
description: The upload date of the desired file.
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
file:
type: string
example: base64encodedFile
'400':
description: Bad Request
content:
text/plain:
schema:
type: string
example: 'ID was malformed. Cannot complete request. || Request could not be processed.'
'403':
description: Forbidden
content:
text/plain:
schema:
type: string
example: 'Forbidden: User does not match requested record.'
'404':
description: Not Found
content:
text/plain:
schema:
type: string
example: 'No file matching that request. || No record with that ID found.'
'429':
$ref: '#/components/responses/429TooManyRequests'
4 changes: 4 additions & 0 deletions api/routes/protected/requests-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getRequestsByIDIR,
getRequestByID,
updateRequestState,
getFile,
} from '../../controllers/requests-api-controller';

const router = express.Router();
Expand All @@ -17,4 +18,7 @@ router.route('/requests/idir').get(getRequestsByIDIR);
// Request by a specific ID
router.route('/requests/:id').get(getRequestByID).patch(updateRequestState);

// File retrievals
router.route('/requests/:id/files').get(getFile);

export default router;
Loading

0 comments on commit da38b3d

Please sign in to comment.