diff --git a/app/package-lock.json b/app/package-lock.json index 68e13efc6..75b72b299 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,6 +19,7 @@ "express": "^4.18.2", "express-winston": "^4.2.0", "helmet": "^7.1.0", + "joi": "^17.12.1", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "pg": "^8.11.3", @@ -819,6 +820,19 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -1507,6 +1521,24 @@ "@prisma/debug": "5.7.1" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -8123,6 +8155,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.12.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.12.1.tgz", + "integrity": "sha512-vtxmq+Lsc5SlfqotnfVjlViWfOL9nt/avKNbKYizwf6gsCfq9NYY/ceYRMFD8XDdrjJ9abJyScWmhmIiy+XRtQ==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/app/package.json b/app/package.json index b3012fa59..512f17092 100644 --- a/app/package.json +++ b/app/package.json @@ -56,6 +56,7 @@ "express": "^4.18.2", "express-winston": "^4.2.0", "helmet": "^7.1.0", + "joi": "^17.12.1", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "pg": "^8.11.3", diff --git a/app/src/components/constants.ts b/app/src/components/constants.ts index f18d00ebe..1a9719135 100644 --- a/app/src/components/constants.ts +++ b/app/src/components/constants.ts @@ -41,3 +41,9 @@ export const APPLICATION_STATUS_LIST = Object.freeze({ DELAYED: 'Delayed', COMPLETED: 'Completed' }); + +/** Types of notes */ +export const NOTE_TYPE_LIST = Object.freeze({ + GENERAL: 'General', + BRING_FORWARD: 'Bring Forward' +}); diff --git a/app/src/controllers/note.ts b/app/src/controllers/note.ts index ce69dc4d9..d1de96d2c 100644 --- a/app/src/controllers/note.ts +++ b/app/src/controllers/note.ts @@ -9,7 +9,6 @@ const controller = { createNote: async (req: Request, res: Response, next: NextFunction) => { try { const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, NIL), NIL); - // TODO: define body type in request // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = req.body as any; diff --git a/app/src/middleware/validation.ts b/app/src/middleware/validation.ts new file mode 100644 index 000000000..1f8a78927 --- /dev/null +++ b/app/src/middleware/validation.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// @ts-expect-error api-problem lacks a defined interface; code still works fine +import Problem from 'api-problem'; +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; +// import type { NextFunction, Request, Response } from 'express'; + +/** + * @function validator + * Performs express request validation against a specified `schema` + * @param {object} schema An object containing Joi validation schema definitions + * @returns {function} Express middleware function + * @throws The error encountered upon failure + */ +export const validate = (schema: object) => { + return (req: Request, _res: Response, next: NextFunction) => { + const validationErrors = Object.entries(schema) + .map(([prop, def]) => { + const result = def.validate((req as any)[prop], { abortEarly: false })?.error; + return result ? [prop, result?.details] : undefined; + }) + .filter((error) => !!error) + .map((x) => x as any[]); + + if (Object.keys(validationErrors).length) { + throw new Problem(422, { + detail: validationErrors.flatMap((groups) => groups[1]?.map((error: any) => error?.message)).join('; '), + instance: req.originalUrl, + errors: Object.fromEntries(validationErrors) + }); + } else next(); + }; +}; diff --git a/app/src/routes/v1/document.ts b/app/src/routes/v1/document.ts index 10c5f0b07..f327595fc 100644 --- a/app/src/routes/v1/document.ts +++ b/app/src/routes/v1/document.ts @@ -1,22 +1,31 @@ import express from 'express'; import { documentController } from '../../controllers'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { documentValidator } from '../../validators'; import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; const router = express.Router(); router.use(requireSomeAuth); -router.put('/', (req: Request, res: Response, next: NextFunction): void => { +router.put('/', documentValidator.createDocument, (req: Request, res: Response, next: NextFunction): void => { documentController.createDocument(req, res, next); }); -router.delete('/:documentId', (req: Request, res: Response, next: NextFunction): void => { - documentController.deleteDocument(req, res, next); -}); +router.delete( + '/:documentId', + documentValidator.deleteDocument, + (req: Request, res: Response, next: NextFunction): void => { + documentController.deleteDocument(req, res, next); + } +); -router.get('/list/:activityId', (req: Request, res: Response, next: NextFunction): void => { - documentController.listDocuments(req, res, next); -}); +router.get( + '/list/:activityId', + documentValidator.listDocuments, + (req: Request, res: Response, next: NextFunction): void => { + documentController.listDocuments(req, res, next); + } +); export default router; diff --git a/app/src/routes/v1/note.ts b/app/src/routes/v1/note.ts index 29bd72abe..4747e44bf 100644 --- a/app/src/routes/v1/note.ts +++ b/app/src/routes/v1/note.ts @@ -1,6 +1,7 @@ import express from 'express'; import { noteController } from '../../controllers'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { noteValidator } from '../../validators'; import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; @@ -8,12 +9,12 @@ const router = express.Router(); router.use(requireSomeAuth); // Note create endpoint -router.put('/', (req: Request, res: Response, next: NextFunction): void => { +router.put('/', noteValidator.createNote, (req: Request, res: Response, next: NextFunction): void => { noteController.createNote(req, res, next); }); // Note list by activity endpoint -router.get('/list/:activityId', (req: Request, res: Response, next: NextFunction): void => { +router.get('/list/:activityId', noteValidator.listNotes, (req: Request, res: Response, next: NextFunction): void => { noteController.listNotes(req, res, next); }); diff --git a/app/src/routes/v1/permit.ts b/app/src/routes/v1/permit.ts index b42d0a4d6..0f1514db1 100644 --- a/app/src/routes/v1/permit.ts +++ b/app/src/routes/v1/permit.ts @@ -1,6 +1,7 @@ import express from 'express'; import { permitController } from '../../controllers'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { permitValidator } from '../../validators'; import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; @@ -8,24 +9,28 @@ const router = express.Router(); router.use(requireSomeAuth); // Permit create endpoint -router.put('/', (req: Request, res: Response, next: NextFunction): void => { +router.put('/', permitValidator.createPermit, (req: Request, res: Response, next: NextFunction): void => { permitController.createPermit(req, res, next); }); // Permit update endpoint -router.put('/:permitId', (req: Request, res: Response, next: NextFunction): void => { +router.put('/:permitId', permitValidator.updatePermit, (req: Request, res: Response, next: NextFunction): void => { permitController.updatePermit(req, res, next); }); // Permit delete endpoint -router.delete('/:permitId', (req: Request, res: Response, next: NextFunction): void => { +router.delete('/:permitId', permitValidator.deletePermit, (req: Request, res: Response, next: NextFunction): void => { permitController.deletePermit(req, res, next); }); // Permit list by activity endpoint -router.get('/list/:activityId', (req: Request, res: Response, next: NextFunction): void => { - permitController.listPermits(req, res, next); -}); +router.get( + '/list/:activityId', + permitValidator.listPermits, + (req: Request, res: Response, next: NextFunction): void => { + permitController.listPermits(req, res, next); + } +); // Permit types endpoint router.get('/types', (req: Request, res: Response, next: NextFunction): void => { diff --git a/app/src/routes/v1/submission.ts b/app/src/routes/v1/submission.ts index 1a7803abe..47f5a2ba1 100644 --- a/app/src/routes/v1/submission.ts +++ b/app/src/routes/v1/submission.ts @@ -1,6 +1,7 @@ import express from 'express'; import { submissionController } from '../../controllers'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { submissionValidator } from '../../validators'; import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; @@ -13,18 +14,30 @@ router.get('/', (req: Request, res: Response, next: NextFunction): void => { }); // Statistics endpoint -router.get('/statistics', (req: Request, res: Response, next: NextFunction): void => { - submissionController.getStatistics(req, res, next); -}); +router.get( + '/statistics', + submissionValidator.getStatistics, + (req: Request, res: Response, next: NextFunction): void => { + submissionController.getStatistics(req, res, next); + } +); // Submission endpoint -router.get('/:activityId', (req: Request, res: Response, next: NextFunction): void => { - submissionController.getSubmission(req, res, next); -}); +router.get( + '/:activityId', + submissionValidator.getSubmission, + (req: Request, res: Response, next: NextFunction): void => { + submissionController.getSubmission(req, res, next); + } +); // Submission update endpoint -router.put('/:submissionId', (req: Request, res: Response, next: NextFunction): void => { - submissionController.updateSubmission(req, res, next); -}); +router.put( + '/:submissionId', + submissionValidator.updateSubmission, + (req: Request, res: Response, next: NextFunction): void => { + submissionController.updateSubmission(req, res, next); + } +); export default router; diff --git a/app/src/routes/v1/user.ts b/app/src/routes/v1/user.ts index 8dff378db..b09b9c170 100644 --- a/app/src/routes/v1/user.ts +++ b/app/src/routes/v1/user.ts @@ -1,6 +1,7 @@ import express from 'express'; import { userController } from '../../controllers'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { userValidator } from '../../validators'; import type { NextFunction, Request, Response } from 'express'; @@ -8,7 +9,7 @@ const router = express.Router(); router.use(requireSomeAuth); // Submission endpoint -router.get('/', (req: Request, res: Response, next: NextFunction): void => { +router.get('/', userValidator.searchUsers, (req: Request, res: Response, next: NextFunction): void => { userController.searchUsers(req, res, next); }); diff --git a/app/src/services/permit.ts b/app/src/services/permit.ts index aaab9ccea..57e543f72 100644 --- a/app/src/services/permit.ts +++ b/app/src/services/permit.ts @@ -23,7 +23,6 @@ const service = { }, data: permit.toPrismaModel(newPermit) }); - return permit.fromPrismaModel(create); } catch (e: unknown) { throw e; diff --git a/app/src/validators/common.ts b/app/src/validators/common.ts new file mode 100644 index 000000000..1558cbdaa --- /dev/null +++ b/app/src/validators/common.ts @@ -0,0 +1,5 @@ +import Joi from 'joi'; + +export const activityId = Joi.string().min(8).max(8).required(); + +export const uuidv4 = Joi.string().guid({ version: 'uuidv4' }); diff --git a/app/src/validators/document.ts b/app/src/validators/document.ts new file mode 100644 index 000000000..c86d02f48 --- /dev/null +++ b/app/src/validators/document.ts @@ -0,0 +1,32 @@ +import Joi from 'joi'; + +import { activityId, uuidv4 } from './common'; +import { validate } from '../middleware/validation'; + +const schema = { + createDocument: { + body: Joi.object({ + activityId: activityId, + documentId: uuidv4.required(), + filename: Joi.string().max(255).required(), + mimeType: Joi.string().max(255).required(), + length: Joi.number().required() + }) + }, + deleteDocument: { + params: Joi.object({ + documentId: Joi.string().max(255).required() + }) + }, + listDocuments: { + params: Joi.object({ + activityId: activityId + }) + } +}; + +export default { + createDocument: validate(schema.createDocument), + deleteDocument: validate(schema.deleteDocument), + listDocuments: validate(schema.listDocuments) +}; diff --git a/app/src/validators/index.ts b/app/src/validators/index.ts new file mode 100644 index 000000000..d8445537c --- /dev/null +++ b/app/src/validators/index.ts @@ -0,0 +1,5 @@ +export { default as documentValidator } from './document'; +export { default as noteValidator } from './note'; +export { default as permitValidator } from './permit'; +export { default as submissionValidator } from './submission'; +export { default as userValidator } from './user'; diff --git a/app/src/validators/note.ts b/app/src/validators/note.ts new file mode 100644 index 000000000..31dee8035 --- /dev/null +++ b/app/src/validators/note.ts @@ -0,0 +1,26 @@ +import Joi from 'joi'; + +import { activityId } from './common'; +import { validate } from '../middleware/validation'; + +const schema = { + createNote: { + body: Joi.object({ + createdAt: Joi.date().required(), + activityId: activityId, + note: Joi.string(), + noteType: Joi.string().max(255).required(), + title: Joi.string().max(255) + }) + }, + listNotes: { + params: Joi.object({ + activityId: activityId + }) + } +}; + +export default { + createNote: validate(schema.createNote), + listNotes: validate(schema.listNotes) +}; diff --git a/app/src/validators/permit.ts b/app/src/validators/permit.ts new file mode 100644 index 000000000..14294eca3 --- /dev/null +++ b/app/src/validators/permit.ts @@ -0,0 +1,50 @@ +import Joi from 'joi'; + +import { activityId, uuidv4 } from './common'; +import { validate } from '../middleware/validation'; +import { permitTypeSchema } from './permitType'; + +const sharedPermitSchema = { + permitType: permitTypeSchema, + permitTypeId: Joi.number().max(255).required(), + activityId: activityId, + issuedPermitId: Joi.string().max(255), + trackingId: Joi.string().max(255), + authStatus: Joi.string().max(255), + needed: Joi.string().max(255).required(), + status: Joi.string().max(255).required(), + submittedDate: Joi.date().iso(), + adjudicationDate: Joi.date().iso() +}; + +const schema = { + createPermit: { + body: Joi.object(sharedPermitSchema) + }, + deletePermit: { + params: Joi.object({ + permitId: uuidv4.required() + }) + }, + listPermits: { + params: Joi.object({ + activityId: activityId + }) + }, + updatePermit: { + body: Joi.object({ + ...sharedPermitSchema, + permitId: uuidv4.required() + }), + params: Joi.object({ + permitId: uuidv4.required() + }) + } +}; + +export default { + createPermit: validate(schema.createPermit), + deletePermit: validate(schema.deletePermit), + listPermits: validate(schema.listPermits), + updatePermit: validate(schema.updatePermit) +}; diff --git a/app/src/validators/permitType.ts b/app/src/validators/permitType.ts new file mode 100644 index 000000000..ae9074767 --- /dev/null +++ b/app/src/validators/permitType.ts @@ -0,0 +1,17 @@ +import Joi from 'joi'; + +export const permitTypeSchema = Joi.object({ + permitTypeId: Joi.number().max(255).required(), + agency: Joi.string().max(255).required(), + division: Joi.string().max(255).allow(null), + branch: Joi.string().max(255).allow(null), + businessDomain: Joi.string().max(255).allow(null), + type: Joi.string().max(255).required(), + family: Joi.string().max(255).allow(null), + name: Joi.string().max(255).required(), + nameSubtype: Joi.string().max(255).allow(null), + acronym: Joi.string().max(255).allow(null), + trackedInATS: Joi.boolean().required(), + sourceSystem: Joi.string().max(255).allow(null), + sourceSystemAcronym: Joi.string().max(255).allow(null) +}); diff --git a/app/src/validators/submission.ts b/app/src/validators/submission.ts new file mode 100644 index 000000000..774c2c8ed --- /dev/null +++ b/app/src/validators/submission.ts @@ -0,0 +1,73 @@ +import Joi from 'joi'; + +import { activityId, uuidv4 } from './common'; +import { validate } from '../middleware/validation'; + +const schema = { + getStatistics: { + query: Joi.object({ + dateFrom: Joi.date().allow(null), + dateTo: Joi.date().allow(null), + monthYear: Joi.date().allow(null), + userId: uuidv4.allow(null) + }) + }, + getSubmission: { + params: Joi.object({ + activityId: activityId + }) + }, + updateSubmission: { + body: Joi.object({ + submissionId: uuidv4.required(), + activityId: activityId, + applicationStatus: Joi.string().max(255).required(), + assignedUserId: uuidv4.required(), + projectName: Joi.string().min(0).max(255).allow(null), + submittedAt: Joi.date().required(), + submittedBy: Joi.string().max(255).allow(null), + locationPIDs: Joi.string().min(0).max(255).allow(null), + contactName: Joi.string().min(0).max(255).allow(null), + contactPhoneNumber: Joi.string().min(0).max(255).allow(null), + contactEmail: Joi.string().min(0).max(255).allow(null), + companyNameRegistered: Joi.string().min(0).max(255).allow(null), + singleFamilyUnits: Joi.string().min(0).max(255).allow(null), + streetAddress: Joi.string().min(0).max(255).allow(null), + latitude: Joi.number().max(255).allow(null), + longitude: Joi.number().max(255).allow(null), + queuePriority: Joi.number().max(255).allow(null), + relatedPermits: Joi.string().max(255).allow(null), + astNotes: Joi.string().min(0).max(255).allow(null), + astUpdated: Joi.boolean().required(), + addedToATS: Joi.boolean().required(), + atsClientNumber: Joi.string().min(0).max(255).allow(null), + ltsaCompleted: Joi.boolean().required(), + bcOnlineCompleted: Joi.boolean().required(), + naturalDisaster: Joi.boolean().required(), + financiallySupported: Joi.boolean().required(), + financiallySupportedBC: Joi.boolean().required(), + financiallySupportedIndigenous: Joi.boolean().required(), + financiallySupportedNonProfit: Joi.boolean().required(), + financiallySupportedHousingCoop: Joi.boolean().required(), + aaiUpdated: Joi.boolean().required(), + waitingOn: Joi.string().min(0).max(255).allow(null), + bringForwardDate: Joi.date().allow(null), + notes: Joi.string().min(0).max(255).allow(null), + intakeStatus: Joi.string().max(255).required(), + guidance: Joi.boolean().required(), + statusRequest: Joi.boolean().required(), + inquiry: Joi.boolean().required(), + emergencyAssist: Joi.boolean().required(), + inapplicable: Joi.boolean().required() + }), + params: Joi.object({ + submissionId: uuidv4.required() + }) + } +}; + +export default { + getStatistics: validate(schema.getStatistics), + getSubmission: validate(schema.getSubmission), + updateSubmission: validate(schema.updateSubmission) +}; diff --git a/app/src/validators/user.ts b/app/src/validators/user.ts new file mode 100644 index 000000000..10c9e0c31 --- /dev/null +++ b/app/src/validators/user.ts @@ -0,0 +1,23 @@ +import Joi from 'joi'; +import { uuidv4 } from './common'; +import { validate } from '../middleware/validation'; + +const schema = { + searchUsers: { + query: Joi.object({ + userId: Joi.array().items(uuidv4), + identityId: Joi.array().items(uuidv4), + idp: Joi.array().items(Joi.string().max(255)), + username: Joi.string().max(255), + email: Joi.string().max(255), + firstName: Joi.string().max(255), + fullName: Joi.string().max(255), + lastName: Joi.string().max(255), + active: Joi.string().max(255) + }) + } +}; + +export default { + searchUsers: validate(schema.searchUsers) +}; diff --git a/frontend/src/components/note/NoteCard.vue b/frontend/src/components/note/NoteCard.vue index 41ecf2cc5..819cb1d4e 100644 --- a/frontend/src/components/note/NoteCard.vue +++ b/frontend/src/components/note/NoteCard.vue @@ -40,7 +40,7 @@ onMounted(() => {
Date: - {{ props.note.createdAt ? formatDateShort(props.note.createdAt ?? '') : undefined }} + {{ props.note.createdAt ? formatDateShort(props.note.createdAt) : undefined }}