diff --git a/.github/environments/values.dev.yaml b/.github/environments/values.dev.yaml index bff79400..4c513e90 100644 --- a/.github/environments/values.dev.yaml +++ b/.github/environments/values.dev.yaml @@ -13,6 +13,8 @@ config: FRONTEND_OPENSTREETMAP_APIPATH: https://tile.openstreetmap.org FRONTEND_ORGBOOK_APIPATH: https://orgbook.gov.bc.ca/api/v4 SERVER_APIPATH: /api/v1 + SERVER_ATS_APIPATH: https://i1api.nrs.gov.bc.ca/ats-api/v1 + SERVER_ATS_TOKENURL: https://i1api.nrs.gov.bc.ca/oauth2/v1/oauth/token?grant_type=client_credentials&disableDeveloperFilter=true&scope=ATS_API.* SERVER_BODYLIMIT: 30mb SERVER_CHEFS_APIPATH: https://submit.digital.gov.bc.ca/app/api/v1 SERVER_CHES_APIPATH: https://ches-dev.api.gov.bc.ca/api/v1 diff --git a/.github/environments/values.prod.yaml b/.github/environments/values.prod.yaml index 13232f32..5378a424 100644 --- a/.github/environments/values.prod.yaml +++ b/.github/environments/values.prod.yaml @@ -13,6 +13,8 @@ config: FRONTEND_OPENSTREETMAP_APIPATH: https://tile.openstreetmap.org FRONTEND_ORGBOOK_APIPATH: https://orgbook.gov.bc.ca/api/v4 SERVER_APIPATH: /api/v1 + SERVER_ATS_APIPATH: https://api.nrs.gov.bc.ca/ats-api/v1 + SERVER_ATS_TOKENURL: https://api.nrs.gov.bc.ca/oauth2/v1/oauth/token?grant_type=client_credentials&disableDeveloperFilter=true&scope=ATS_API.* SERVER_BODYLIMIT: 30mb SERVER_CHEFS_APIPATH: https://submit.digital.gov.bc.ca/app/api/v1 SERVER_CHES_APIPATH: https://ches.api.gov.bc.ca/api/v1 diff --git a/.github/environments/values.test.yaml b/.github/environments/values.test.yaml index 48ae1a40..229a040f 100644 --- a/.github/environments/values.test.yaml +++ b/.github/environments/values.test.yaml @@ -13,6 +13,8 @@ config: FRONTEND_OPENSTREETMAP_APIPATH: https://tile.openstreetmap.org FRONTEND_ORGBOOK_APIPATH: https://orgbook.gov.bc.ca/api/v4 SERVER_APIPATH: /api/v1 + SERVER_ATS_APIPATH: https://t1api.nrs.gov.bc.ca/ats-api/v1 + SERVER_ATS_TOKENURL: https://t1api.nrs.gov.bc.ca/oauth2/v1/oauth/token?grant_type=client_credentials&disableDeveloperFilter=true&scope=ATS_API.* SERVER_BODYLIMIT: 30mb SERVER_CHEFS_APIPATH: https://submit.digital.gov.bc.ca/app/api/v1 SERVER_CHES_APIPATH: https://ches-test.api.gov.bc.ca/api/v1 diff --git a/.gitignore b/.gitignore index 466a7cfd..71be360d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ files **/e2e/videos node_modules sbin + # Ignore only top-level package-lock.json /package-lock.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index a035004e..ed448352 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "42crunch.vscode-openapi", "bierner.markdown-preview-github-styles", "davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", diff --git a/.vscode/settings.json b/.vscode/settings.json index bc775e89..01e9e5c4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,8 @@ "editor.defaultFormatter": "dbaeumer.vscode-eslint", "editor.formatOnSave": true, "eslint.format.enable": true, - "files.insertFinalNewline": true + "files.insertFinalNewline": true, + "yaml.schemas": { + "openapi:v3": "./app/src/docs/v1.api-spec.yaml" + } } diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json index c3d7dc83..17d964ea 100644 --- a/app/config/custom-environment-variables.json +++ b/app/config/custom-environment-variables.json @@ -30,6 +30,12 @@ }, "server": { "apiPath": "SERVER_APIPATH", + "ats": { + "apiPath": "SERVER_ATS_APIPATH", + "clientId": "SERVER_ATS_CLIENTID", + "clientSecret": "SERVER_ATS_CLIENTSECRET", + "tokenUrl": "SERVER_ATS_TOKENURL" + }, "bodyLimit": "SERVER_BODYLIMIT", "ches": { "apiPath": "SERVER_CHES_APIPATH", diff --git a/app/deploy-utils.ts b/app/deploy-utils.ts index 362d0561..a036b663 100644 --- a/app/deploy-utils.ts +++ b/app/deploy-utils.ts @@ -5,7 +5,9 @@ import { basename, join } from 'path'; const FRONTEND_DIR = '../frontend'; const DIST_DIR = 'dist'; +const SBIN_DIR = 'sbin'; const TITLE = 'nr-permitting-navigator-service-frontend'; +const V1_DOCS = 'v1.api-spec.yaml'; try { const args = process.argv.slice(2); @@ -19,6 +21,9 @@ try { case 'deploy': deployComponents(); break; + case 'docs': + copyDocs(); + break; case 'purge': console.log(`Purging "${DIST_DIR}"...`); if (existsSync(DIST_DIR)) rmSync(DIST_DIR, { recursive: true }); @@ -69,6 +74,14 @@ function cleanComponents() { console.log(`${TITLE} has been cleaned`); } +function copyDocs() { + console.log('Copying OpenAPI docs...'); + if (existsSync(SBIN_DIR)) { + copyFileSync(`./src/docs/${V1_DOCS}`, `./sbin/src/docs/${V1_DOCS}`); + } + console.log('OpenAPI docs have been copied.'); +} + /** * @function deployComponents * @description Redeploy `nr-permitting-navigator-service-frontend` library diff --git a/app/package-lock.json b/app/package-lock.json index e4e4f26a..1fb7fec9 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -20,6 +20,7 @@ "express-winston": "^4.2.0", "helmet": "^7.1.0", "joi": "^17.13.3", + "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "pg": "^8.12.0", @@ -36,6 +37,7 @@ "@types/express": "^4.17.21", "@types/express-serve-static-core": "^4.19.5", "@types/jest": "^29.5.12", + "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.11", "@types/pg": "^8.11.6", @@ -1738,6 +1740,12 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -2267,8 +2275,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", @@ -8049,7 +8056,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, diff --git a/app/package.json b/app/package.json index 7db1d3af..8834848b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,10 +1,10 @@ { "name": "nr-permitting-navigator-service-app", "version": "0.4.0", + "license": "Apache-2.0", "private": true, "description": "PermitConnect Navigator Service ", "author": "NRM Permitting and Data Solutions ", - "license": "Apache-2.0", "scripts": { "build:all": "ts-node ./deploy-utils.ts", "build": "tsc", @@ -21,6 +21,7 @@ "migrate:up": "knex migrate:up", "migrate": "knex migrate:latest --knexfile ./sbin/knexfile.js", "postbuild:all": "npm run build", + "postbuild": "ts-node ./deploy-utils.ts docs", "postclean:all": "npm run clean", "postprisma:migrate:down": "npm run prisma:sync", "postprisma:migrate:up": "npm run prisma:sync", @@ -62,6 +63,7 @@ "express-winston": "^4.2.0", "helmet": "^7.1.0", "joi": "^17.13.3", + "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "pg": "^8.12.0", @@ -78,6 +80,7 @@ "@types/express": "^4.17.21", "@types/express-serve-static-core": "^4.19.5", "@types/jest": "^29.5.12", + "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.11", "@types/pg": "^8.11.6", diff --git a/app/src/controllers/accessRequest.ts b/app/src/controllers/accessRequest.ts index f49f9039..4ed9b9b5 100644 --- a/app/src/controllers/accessRequest.ts +++ b/app/src/controllers/accessRequest.ts @@ -19,7 +19,6 @@ const controller = { req.currentAuthorization?.groups.some( (group: GroupName) => group === GroupName.DEVELOPER || group === GroupName.ADMIN ) ?? false; - const existingUser = !!user.userId; // Groups the current user can modify @@ -43,10 +42,15 @@ const controller = { res.status(404).json({ message: 'User not found' }); } else { userGroups = await yarsService.getSubjectGroups(userResponse.sub); + if (accessRequest.grant && !modifyableGroups.includes(accessRequest.group as GroupName)) { res.status(403).json({ message: 'Cannot modify requested group' }); } - if (accessRequest.group && userGroups.map((x) => x.groupName).includes(accessRequest.group)) { + if ( + accessRequest.grant && + accessRequest.group && + userGroups.map((x) => x.groupName).includes(accessRequest.group) + ) { res.status(409).json({ message: 'User is already assigned this group' }); } if (userResponse.idp !== IdentityProvider.IDIR) { @@ -58,7 +62,6 @@ const controller = { } const isGroupUpdate = existingUser && accessRequest.grant; - let response; if (isGroupUpdate) { @@ -149,13 +152,13 @@ const controller = { if (req.body.approve) { if (accessRequest.grant) { if (!accessRequest.group || !accessRequest.group.length) { - res.status(422).json({ message: 'Must provided a role to grant' }); + return res.status(422).json({ message: 'Must provided a role to grant' }); } if (accessRequest.group && groups.map((x) => x.groupName).includes(accessRequest.group)) { - res.status(409).json({ message: 'User is already assigned this role' }); + return res.status(409).json({ message: 'User is already assigned this role' }); } if (userResponse.idp !== IdentityProvider.IDIR) { - res.status(409).json({ message: 'User must be an IDIR user to be assigned this role' }); + return res.status(409).json({ message: 'User must be an IDIR user to be assigned this role' }); } await yarsService.assignGroup( @@ -176,14 +179,17 @@ const controller = { await yarsService.removeGroup(userResponse.sub, initiative, g.groupName); } } - } - // Delete the request after processing - await accessRequestService.deleteAccessRequest(accessRequest.accessRequestId); + // Update access request status + accessRequest.status = AccessRequestStatus.APPROVED; + await accessRequestService.updateAccessRequest(accessRequest); + } else { + accessRequest.status = AccessRequestStatus.REJECTED; + await accessRequestService.updateAccessRequest(accessRequest); + } } else { - res.status(404).json({ message: 'User does not exist' }); + return res.status(404).json({ message: 'User does not exist' }); } - res.status(204).end(); } } catch (e: unknown) { diff --git a/app/src/controllers/activity.ts b/app/src/controllers/activity.ts deleted file mode 100644 index 7e843e36..00000000 --- a/app/src/controllers/activity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { activityService } from '../services'; -import { ACTIVITY_ID_LENGTH } from '../utils/constants/application'; - -import type { NextFunction, Request, Response } from 'express'; - -const controller = { - validateActivityId: async (req: Request<{ activityId: string }>, res: Response, next: NextFunction) => { - try { - const { activityId } = req.params; - const hexidecimal = parseInt(activityId, 16); - - if (activityId.length !== ACTIVITY_ID_LENGTH || !hexidecimal) { - return res.status(400).json({ message: 'Invalid activity Id format' }); - } - const activity = await activityService.getActivity(activityId); - - res.status(200).json({ valid: !!activity && !activity.isDeleted }); - } catch (e: unknown) { - next(e); - } - } -}; - -export default controller; diff --git a/app/src/controllers/ats.ts b/app/src/controllers/ats.ts new file mode 100644 index 00000000..585aa2ea --- /dev/null +++ b/app/src/controllers/ats.ts @@ -0,0 +1,37 @@ +import { atsService } from '../services'; + +import { getCurrentUsername } from '../utils/utils'; + +import type { NextFunction, Request, Response } from 'express'; +import type { ATSClientResource, ATSUserSearchParameters } from '../types'; + +const controller = { + searchATSUsers: async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const response = await atsService.searchATSUsers(req.query); + + res.status(response.status).json(response.data); + } catch (e: unknown) { + next(e); + } + }, + + createATSClient: async (req: Request, res: Response, next: NextFunction) => { + try { + const identityProvider = req.currentContext?.tokenPayload?.identity_provider.toUpperCase(); + const atsClient = req.body; + // Set the createdBy field to current user with \\ as the separator for the domain and username to match ATS DB + atsClient.createdBy = `${identityProvider}\\${getCurrentUsername(req.currentContext)}`; + const response = await atsService.createATSClient(atsClient); + res.status(response.status).json(response.data); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/document.ts b/app/src/controllers/document.ts index 9f88678f..5e9af009 100644 --- a/app/src/controllers/document.ts +++ b/app/src/controllers/document.ts @@ -31,6 +31,11 @@ const controller = { async deleteDocument(req: Request<{ documentId: string }>, res: Response, next: NextFunction) { try { const response = await documentService.deleteDocument(req.params.documentId); + + if (!response) { + return res.status(404).json({ message: 'Document not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); diff --git a/app/src/controllers/enquiry.ts b/app/src/controllers/enquiry.ts index d413e03c..c457c682 100644 --- a/app/src/controllers/enquiry.ts +++ b/app/src/controllers/enquiry.ts @@ -1,7 +1,7 @@ import { NIL, v4 as uuidv4 } from 'uuid'; import { generateCreateStamps, generateUpdateStamps } from '../db/utils/utils'; -import { activityService, enquiryService, noteService, userService } from '../services'; +import { activityService, contactService, enquiryService, noteService, userService } from '../services'; import { Initiative } from '../utils/enums/application'; import { ApplicationStatus, IntakeStatus, NoteType, SubmissionType } from '../utils/enums/housing'; import { getCurrentSubject, getCurrentUsername } from '../utils/utils'; @@ -32,26 +32,14 @@ const controller = { } }, - generateEnquiryData: async (req: Request) => { + generateEnquiryData: async (req: Request, intakeStatus: string) => { const data = req.body; const activityId = data.activityId ?? (await activityService.createActivity(Initiative.HOUSING, generateCreateStamps(req.currentContext)))?.activityId; - let applicant, basic; - - // Create applicant information - if (data.applicant) { - applicant = { - contactFirstName: data.applicant.contactFirstName, - contactLastName: data.applicant.contactLastName, - contactPhoneNumber: data.applicant.contactPhoneNumber, - contactEmail: data.applicant.contactEmail, - contactApplicantRelationship: data.applicant.contactApplicantRelationship, - contactPreference: data.applicant.contactPreference - }; - } + let basic; if (data.basic) { basic = { @@ -65,22 +53,24 @@ const controller = { // Put new enquiry together return { - ...applicant, ...basic, enquiryId: data.enquiryId ?? uuidv4(), - activityId: activityId, + activityId: activityId as string, submittedAt: data.submittedAt ?? new Date().toISOString(), // eslint-disable-next-line @typescript-eslint/no-explicit-any submittedBy: getCurrentUsername(req.currentContext), - intakeStatus: data.submit ? IntakeStatus.SUBMITTED : IntakeStatus.DRAFT, + intakeStatus: intakeStatus, enquiryStatus: data.enquiryStatus ?? ApplicationStatus.NEW, enquiryType: data?.basic?.enquiryType ?? SubmissionType.GENERAL_ENQUIRY }; }, - createDraft: async (req: Request, res: Response, next: NextFunction) => { + createEnquiry: async (req: Request, res: Response, next: NextFunction) => { try { - const enquiry = await controller.generateEnquiryData(req); + const enquiry = await controller.generateEnquiryData(req, IntakeStatus.SUBMITTED); + + // Create or update contacts + await contactService.upsertContacts(enquiry.activityId, req.body.contacts, req.currentContext); // Create new enquiry const result = await enquiryService.createEnquiry({ @@ -97,6 +87,11 @@ const controller = { deleteEnquiry: async (req: Request<{ enquiryId: string }>, res: Response, next: NextFunction) => { try { const response = await enquiryService.deleteEnquiry(req.params.enquiryId); + + if (!response) { + return res.status(404).json({ message: 'Enquiry not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); @@ -122,6 +117,11 @@ const controller = { getEnquiry: async (req: Request<{ enquiryId: string }>, res: Response, next: NextFunction) => { try { const response = await enquiryService.getEnquiry(req.params.enquiryId); + + if (!response) { + return res.status(404).json({ message: 'Enquiry not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); @@ -139,28 +139,18 @@ const controller = { updateEnquiry: async (req: Request, res: Response, next: NextFunction) => { try { + await contactService.upsertContacts(req.body.activityId, req.body.contacts, req.currentContext); + const result = await enquiryService.updateEnquiry({ ...req.body, ...generateUpdateStamps(req.currentContext) } as Enquiry); - res.status(200).json(result); - } catch (e: unknown) { - next(e); - } - }, - - updateDraft: async (req: Request, res: Response, next: NextFunction) => { - try { - const enquiry = await controller.generateEnquiryData(req); - - // Update enquiry - const result = await enquiryService.updateEnquiry({ - ...(enquiry as Enquiry), - ...generateUpdateStamps(req.currentContext) - }); + if (!result) { + return res.status(404).json({ message: 'Enquiry not found' }); + } - res.status(200).json({ activityId: result.activityId, enquiryId: result.enquiryId }); + res.status(200).json(result); } catch (e: unknown) { next(e); } @@ -177,6 +167,11 @@ const controller = { req.body.isDeleted, generateUpdateStamps(req.currentContext) ); + + if (!response) { + return res.status(404).json({ message: 'Enquiry not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index 377c40af..c5f11819 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,9 +1,10 @@ export { default as accessRequestController } from './accessRequest'; -export { default as activityController } from './activity'; +export { default as atsController } from './ats'; export { default as documentController } from './document'; export { default as enquiryController } from './enquiry'; export { default as noteController } from './note'; export { default as permitController } from './permit'; +export { default as permitNoteController } from './permitNote'; export { default as roadmapController } from './roadmap'; export { default as ssoController } from './sso'; export { default as submissionController } from './submission'; diff --git a/app/src/controllers/note.ts b/app/src/controllers/note.ts index 648ef01c..cf51f42a 100644 --- a/app/src/controllers/note.ts +++ b/app/src/controllers/note.ts @@ -21,6 +21,10 @@ const controller = { try { const response = await noteService.deleteNote(req.params.noteId, generateUpdateStamps(req.currentContext)); + if (!response) { + return res.status(404).json({ message: 'Note not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); @@ -82,6 +86,10 @@ const controller = { ...generateUpdateStamps(req.currentContext) }); + if (!response) { + return res.status(404).json({ message: 'Note not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); diff --git a/app/src/controllers/permit.ts b/app/src/controllers/permit.ts index b756b9b6..ca173d37 100644 --- a/app/src/controllers/permit.ts +++ b/app/src/controllers/permit.ts @@ -1,8 +1,9 @@ import { generateCreateStamps, generateUpdateStamps } from '../db/utils/utils'; import { permitService } from '../services'; +import { isTruthy } from '../utils/utils'; import type { NextFunction, Request, Response } from 'express'; -import type { Permit } from '../types'; +import type { ListPermitsOptions, Permit } from '../types'; const controller = { createPermit: async (req: Request, res: Response, next: NextFunction) => { @@ -21,6 +22,11 @@ const controller = { deletePermit: async (req: Request<{ permitId: string }>, res: Response, next: NextFunction) => { try { const response = await permitService.deletePermit(req.params.permitId); + + if (!response) { + return res.status(404).json({ message: 'Permit not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); @@ -36,9 +42,14 @@ const controller = { } }, - async listPermits(req: Request, res: Response, next: NextFunction) { + async listPermits(req: Request>, res: Response, next: NextFunction) { try { - const response = await permitService.listPermits(req.query?.activityId); + const options: ListPermitsOptions = { + ...req.query, + includeNotes: isTruthy(req.query.includeNotes) + }; + + const response = await permitService.listPermits(options); res.status(200).json(response); } catch (e: unknown) { next(e); @@ -51,6 +62,11 @@ const controller = { ...req.body, ...generateUpdateStamps(req.currentContext) }); + + if (!response) { + return res.status(404).json({ message: 'Permit not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); diff --git a/app/src/controllers/permitNote.ts b/app/src/controllers/permitNote.ts new file mode 100644 index 00000000..7a6470a1 --- /dev/null +++ b/app/src/controllers/permitNote.ts @@ -0,0 +1,21 @@ +import { generateCreateStamps } from '../db/utils/utils'; +import { permitNoteService } from '../services'; + +import type { NextFunction, Request, Response } from 'express'; +import type { PermitNote } from '../types'; + +const controller = { + createPermitNote: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await permitNoteService.createPermitNote({ + ...req.body, + ...generateCreateStamps(req.currentContext) + }); + res.status(201).json(response); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/submission.ts b/app/src/controllers/submission.ts index 06986cbe..f9eb5812 100644 --- a/app/src/controllers/submission.ts +++ b/app/src/controllers/submission.ts @@ -2,10 +2,19 @@ import config from 'config'; import { v4 as uuidv4 } from 'uuid'; import { generateCreateStamps, generateUpdateStamps } from '../db/utils/utils'; -import { activityService, emailService, enquiryService, submissionService, permitService } from '../services'; +import { + activityService, + contactService, + draftService, + emailService, + enquiryService, + submissionService, + permitService +} from '../services'; import { BasicResponse, Initiative } from '../utils/enums/application'; import { ApplicationStatus, + DraftCode, IntakeStatus, NumResidentialUnits, PermitNeeded, @@ -19,17 +28,53 @@ import type { NextFunction, Request, Response } from 'express'; import type { ChefsFormConfig, ChefsFormConfigData, - Submission, ChefsSubmissionExport, - Permit, + CurrentContext, + Draft, Email, + Permit, StatisticsFilters, + Submission, SubmissionIntake, SubmissionSearchParameters } from '../types'; const controller = { - checkAndStoreNewSubmissions: async () => { + /** + * @function assignPriority + * Assigns a priority level to a submission based on given criteria + * Criteria defined below + */ + assignPriority: (submission: Partial) => { + const matchesPriorityOneCriteria = // Priority 1 Criteria: + submission.singleFamilyUnits === NumResidentialUnits.GREATER_THAN_FIVE_HUNDRED || // 1. More than 50 units (any) + submission.singleFamilyUnits === NumResidentialUnits.FIFTY_TO_FIVE_HUNDRED || + submission.multiFamilyUnits === NumResidentialUnits.GREATER_THAN_FIVE_HUNDRED || + submission.multiFamilyUnits === NumResidentialUnits.FIFTY_TO_FIVE_HUNDRED || + submission.otherUnits === NumResidentialUnits.GREATER_THAN_FIVE_HUNDRED || + submission.otherUnits === NumResidentialUnits.FIFTY_TO_FIVE_HUNDRED || + submission.hasRentalUnits === 'Yes' || // 2. Supports Rental Units + submission.financiallySupportedBC === 'Yes' || // 3. Social Housing + submission.financiallySupportedIndigenous === 'Yes'; // 4. Indigenous Led + + const matchesPriorityTwoCriteria = // Priority 2 Criteria: + submission.singleFamilyUnits === NumResidentialUnits.TEN_TO_FOURTY_NINE || // 1. Single Family >= 10 Units + submission.multiFamilyUnits === NumResidentialUnits.TEN_TO_FOURTY_NINE || // 2. Has 1 or more MultiFamily Units + submission.multiFamilyUnits === NumResidentialUnits.ONE_TO_NINE || + submission.otherUnits === NumResidentialUnits.TEN_TO_FOURTY_NINE || // 3. Has 1 or more Other Units + submission.otherUnits === NumResidentialUnits.ONE_TO_NINE; + + if (matchesPriorityOneCriteria) { + submission.queuePriority = 1; + } else if (matchesPriorityTwoCriteria) { + submission.queuePriority = 2; + } else { + // Prioriy 3 Criteria: + submission.queuePriority = 3; // Everything Else + } + }, + + checkAndStoreNewSubmissions: async (currentContext: CurrentContext) => { const cfg = config.get('server.chefs.forms') as ChefsFormConfig; // Mapping of SHAS intake permit names to PCNS types @@ -121,14 +166,8 @@ const controller = { activityId: data.form.confirmationId, applicationStatus: ApplicationStatus.NEW, companyNameRegistered: data.companyNameRegistered ?? data.companyName, - contactEmail: data.contactEmail, - contactPreference: camelCaseToTitleCase(data.contactPreference), projectName: data.projectName, projectDescription: data.projectDescription, - contactPhoneNumber: data.contactPhoneNumber, - contactFirstName: data.contactFirstName, - contactLastName: data.contactLastName, - contactApplicantRelationship: camelCaseToTitleCase(data.contactApplicantRelationship), financiallySupported: Object.values(financiallySupportedValues).includes(BasicResponse.YES), ...financiallySupportedValues, housingCoopDescription: data.housingCoopName, @@ -160,7 +199,17 @@ const controller = { submittedAt: data.form.createdAt, submittedBy: data.form.username, hasAppliedProvincialPermits: toTitleCase(data.previousPermits), - permits: permits + permits: permits, + contacts: [ + { + email: data.contactEmail, + contactPreference: camelCaseToTitleCase(data.contactPreference), + phoneNumber: data.contactPhoneNumber, + firstName: data.contactFirstName, + lastName: data.contactLastName, + contactApplicantRelationship: camelCaseToTitleCase(data.contactApplicantRelationship) + } + ] }; }); }) @@ -173,32 +222,24 @@ const controller = { const notStored = exportData.filter((x) => !stored.some((activityId: string) => activityId === x.activityId)); await submissionService.createSubmissionsFromExport(notStored); + // Create each contact + notStored.map((x) => + x.contacts?.map(async (y) => await contactService.upsertContacts(x.activityId as string, [y], currentContext)) + ); + // Create each permit notStored.map((x) => x.permits?.map(async (y) => await permitService.createPermit(y))); }, - generateSubmissionData: async (req: Request, intakeStatus: string) => { - const data = req.body; - + generateSubmissionData: async (data: SubmissionIntake, intakeStatus: string, currentContext: CurrentContext) => { const activityId = data.activityId ?? - (await activityService.createActivity(Initiative.HOUSING, generateCreateStamps(req.currentContext)))?.activityId; + (await activityService.createActivity(Initiative.HOUSING, generateCreateStamps(currentContext)))?.activityId; - let applicant, basic, housing, location, permits; + let basic, housing, location, permits; let appliedPermits: Array = [], investigatePermits: Array = []; - if (data.applicant) { - applicant = { - contactFirstName: data.applicant.contactFirstName, - contactLastName: data.applicant.contactLastName, - contactPhoneNumber: data.applicant.contactPhoneNumber, - contactEmail: data.applicant.contactEmail, - contactApplicantRelationship: data.applicant.contactApplicantRelationship, - contactPreference: data.applicant.contactPreference - }; - } - if (data.basic) { basic = { consentToFeedback: data.basic.consentToFeedback ?? false, @@ -283,16 +324,15 @@ const controller = { // Put new submission together const submissionData = { submission: { - ...applicant, ...basic, ...housing, ...location, ...permits, - submissionId: data.submissionId ?? uuidv4(), + submissionId: uuidv4(), activityId: activityId, submittedAt: data.submittedAt ?? new Date().toISOString(), // eslint-disable-next-line @typescript-eslint/no-explicit-any - submittedBy: getCurrentUsername(req.currentContext), + submittedBy: getCurrentUsername(currentContext), intakeStatus: intakeStatus, applicationStatus: data.applicationStatus ?? ApplicationStatus.NEW, submissionType: data?.submissionType ?? SubmissionType.GUIDANCE @@ -301,13 +341,24 @@ const controller = { investigatePermits }; - if (data.submit) { - controller.assignPriority(submissionData.submission); - } + controller.assignPriority(submissionData.submission); return submissionData; }, + /** + * @function emailConfirmation + * Send an email with the confirmation of submission + */ + emailConfirmation: async (req: Request, res: Response, next: NextFunction) => { + try { + const { data, status } = await emailService.email(req.body); + res.status(status).json(data); + } catch (e: unknown) { + next(e); + } + }, + getActivityIds: async (req: Request, res: Response, next: NextFunction) => { try { let response = await submissionService.getSubmissions(); @@ -320,13 +371,18 @@ const controller = { } }, - createDraft: async (req: Request, res: Response, next: NextFunction) => { + createSubmission: async (req: Request, res: Response, next: NextFunction) => { try { const { submission, appliedPermits, investigatePermits } = await controller.generateSubmissionData( - req, - req.body.submit ? IntakeStatus.SUBMITTED : IntakeStatus.DRAFT + req.body, + IntakeStatus.SUBMITTED, + req.currentContext ); + // Create contacts + if (req.body.contacts) + await contactService.upsertContacts(submission.activityId, req.body.contacts, req.currentContext); + // Create new submission const result = await submissionService.createSubmission({ ...submission, @@ -336,38 +392,65 @@ const controller = { // Create each permit await Promise.all(appliedPermits.map(async (x: Permit) => await permitService.createPermit(x))); await Promise.all(investigatePermits.map(async (x: Permit) => await permitService.createPermit(x))); + res.status(201).json({ activityId: result.activityId, submissionId: result.submissionId }); } catch (e: unknown) { next(e); } }, - createSubmission: async (req: Request, res: Response, next: NextFunction) => { + deleteSubmission: async (req: Request<{ submissionId: string }>, res: Response, next: NextFunction) => { try { - const { submission, appliedPermits, investigatePermits } = await controller.generateSubmissionData( - req, - IntakeStatus.SUBMITTED - ); + const response = await submissionService.deleteSubmission(req.params.submissionId); - // Create new submission - const result = await submissionService.createSubmission({ - ...submission, - ...generateCreateStamps(req.currentContext) - }); + if (!response) { + return res.status(404).json({ message: 'Submission not found' }); + } - // Create each permit - await Promise.all(appliedPermits.map(async (x: Permit) => await permitService.createPermit(x))); - await Promise.all(investigatePermits.map(async (x: Permit) => await permitService.createPermit(x))); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, - res.status(201).json({ activityId: result.activityId, submissionId: result.submissionId }); + deleteDraft: async (req: Request<{ draftId: string }>, res: Response, next: NextFunction) => { + try { + const response = await draftService.deleteDraft(req.params.draftId); + + if (!response) { + return res.status(404).json({ message: 'Submission draft not found' }); + } + + res.status(200).json(response); } catch (e: unknown) { next(e); } }, - deleteSubmission: async (req: Request<{ submissionId: string }>, res: Response, next: NextFunction) => { + getDraft: async (req: Request<{ draftId: string }>, res: Response, next: NextFunction) => { try { - const response = await submissionService.deleteSubmission(req.params.submissionId); + const response = await draftService.getDraft(req.params.draftId); + + if (req.currentAuthorization?.attributes.includes('scope:self')) { + if (response?.createdBy !== getCurrentUsername(req.currentContext)) { + res.status(403).send(); + } + } + + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, + + getDrafts: async (req: Request, res: Response, next: NextFunction) => { + try { + let response = await draftService.getDrafts(DraftCode.SUBMISSION); + + if (req.currentAuthorization?.attributes.includes('scope:self')) { + response = response.filter((x: Draft) => x?.createdBy === req.currentContext.userId); + } + res.status(200).json(response); } catch (e: unknown) { next(e); @@ -387,6 +470,10 @@ const controller = { try { const response = await submissionService.getSubmission(req.params.submissionId); + if (!response) { + return res.status(404).json({ message: 'Submission not found' }); + } + if (req.currentAuthorization?.attributes.includes('scope:self')) { if (response?.submittedBy !== getCurrentUsername(req.currentContext)) { res.status(403).send(); @@ -407,7 +494,7 @@ const controller = { getSubmissions: async (req: Request, res: Response, next: NextFunction) => { try { // Check for and store new submissions in CHEFS - await controller.checkAndStoreNewSubmissions(); + await controller.checkAndStoreNewSubmissions(req.currentContext); // Pull from PCNS database let response = await submissionService.getSubmissions(); @@ -443,27 +530,65 @@ const controller = { } }, - updateDraft: async (req: Request, res: Response, next: NextFunction) => { + submitDraft: async (req: Request, res: Response, next: NextFunction) => { try { const { submission, appliedPermits, investigatePermits } = await controller.generateSubmissionData( - req, - req.body.submit ? IntakeStatus.SUBMITTED : IntakeStatus.DRAFT + req.body, + IntakeStatus.SUBMITTED, + req.currentContext ); - // Update submission - const result = await submissionService.updateSubmission({ + // Create contacts + if (req.body.contacts) + await contactService.upsertContacts(submission.activityId, req.body.contacts, req.currentContext); + + // Create new submission + const result = await submissionService.createSubmission({ ...submission, - ...generateUpdateStamps(req.currentContext) + ...generateCreateStamps(req.currentContext) }); - // Remove already existing permits for this activity - await permitService.deletePermitsByActivity(submission.activityId); - // Create each permit - await Promise.all(appliedPermits.map(async (x: Permit) => await permitService.createPermit(x))); - await Promise.all(investigatePermits.map(async (x: Permit) => await permitService.createPermit(x))); + await Promise.all(appliedPermits.map((x: Permit) => permitService.createPermit(x))); + await Promise.all(investigatePermits.map((x: Permit) => permitService.createPermit(x))); + + // Delete old draft + if (req.body.draftId) await draftService.deleteDraft(req.body.draftId); + + res.status(201).json({ activityId: result.activityId, submissionId: result.submissionId }); + } catch (e: unknown) { + next(e); + } + }, + + updateDraft: async (req: Request, res: Response, next: NextFunction) => { + try { + const update = req.body.draftId && req.body.activityId; + + let response; + + if (update) { + // Update draft + response = await draftService.updateDraft({ + ...req.body, + ...generateUpdateStamps(req.currentContext) + }); + } else { + const activityId = ( + await activityService.createActivity(Initiative.HOUSING, generateCreateStamps(req.currentContext)) + )?.activityId; + + // Create new draft + response = await draftService.createDraft({ + draftId: uuidv4(), + activityId: activityId, + draftCode: DraftCode.SUBMISSION, + data: req.body.data, + ...generateCreateStamps(req.currentContext) + }); + } - res.status(200).json({ activityId: result.activityId, submissionId: result.submissionId }); + res.status(update ? 200 : 201).json({ draftId: response?.draftId, activityId: response?.activityId }); } catch (e: unknown) { next(e); } @@ -480,6 +605,11 @@ const controller = { req.body.isDeleted, generateUpdateStamps(req.currentContext) ); + + if (!response) { + return res.status(404).json({ message: 'Submission not found' }); + } + res.status(200).json(response); } catch (e: unknown) { next(e); @@ -488,61 +618,21 @@ const controller = { updateSubmission: async (req: Request, res: Response, next: NextFunction) => { try { + await contactService.upsertContacts(req.body.activityId, req.body.contacts, req.currentContext); + const response = await submissionService.updateSubmission({ ...req.body, ...generateUpdateStamps(req.currentContext) }); - res.status(200).json(response); - } catch (e: unknown) { - next(e); - } - }, - /** - * @function emailConfirmation - * Send an email with the confirmation of submission - */ - emailConfirmation: async (req: Request, res: Response, next: NextFunction) => { - try { - const { data, status } = await emailService.email(req.body); - res.status(status).json(data); + if (!response) { + return res.status(404).json({ message: 'Submission not found' }); + } + + res.status(200).json(response); } catch (e: unknown) { next(e); } - }, - - /** - * @function assignPriority - * Assigns a priority level to a submission based on given criteria - * Criteria defined below - */ - assignPriority: (submission: Partial) => { - const matchesPriorityOneCriteria = // Priority 1 Criteria: - submission.singleFamilyUnits === NumResidentialUnits.GREATER_THAN_FIVE_HUNDRED || // 1. More than 50 units (any) - submission.singleFamilyUnits === NumResidentialUnits.FIFTY_TO_FIVE_HUNDRED || - submission.multiFamilyUnits === NumResidentialUnits.GREATER_THAN_FIVE_HUNDRED || - submission.multiFamilyUnits === NumResidentialUnits.FIFTY_TO_FIVE_HUNDRED || - submission.otherUnits === NumResidentialUnits.GREATER_THAN_FIVE_HUNDRED || - submission.otherUnits === NumResidentialUnits.FIFTY_TO_FIVE_HUNDRED || - submission.hasRentalUnits === 'Yes' || // 2. Supports Rental Units - submission.financiallySupportedBC === 'Yes' || // 3. Social Housing - submission.financiallySupportedIndigenous === 'Yes'; // 4. Indigenous Led - - const matchesPriorityTwoCriteria = // Priority 2 Criteria: - submission.singleFamilyUnits === NumResidentialUnits.TEN_TO_FOURTY_NINE || // 1. Single Family >= 10 Units - submission.multiFamilyUnits === NumResidentialUnits.TEN_TO_FOURTY_NINE || // 2. Has 1 or more MultiFamily Units - submission.multiFamilyUnits === NumResidentialUnits.ONE_TO_NINE || - submission.otherUnits === NumResidentialUnits.TEN_TO_FOURTY_NINE || // 3. Has 1 or more Other Units - submission.otherUnits === NumResidentialUnits.ONE_TO_NINE; - - if (matchesPriorityOneCriteria) { - submission.queuePriority = 1; - } else if (matchesPriorityTwoCriteria) { - submission.queuePriority = 2; - } else { - // Prioriy 3 Criteria: - submission.queuePriority = 3; // Everything Else - } } }; diff --git a/app/src/controllers/yars.ts b/app/src/controllers/yars.ts index 2eb24e06..f61f3fd5 100644 --- a/app/src/controllers/yars.ts +++ b/app/src/controllers/yars.ts @@ -1,6 +1,7 @@ import { yarsService } from '../services'; import type { NextFunction, Request, Response } from 'express'; +import { GroupName, Initiative } from '../utils/enums/application'; const controller = { getGroups: async (req: Request, res: Response, next: NextFunction) => { @@ -25,6 +26,23 @@ const controller = { } catch (e: unknown) { next(e); } + }, + + deleteSubjectGroup: async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const response = await yarsService.removeGroup(req.body.sub, Initiative.HOUSING, req.body.group); + + if (!response) { + return res.status(422).json({ message: 'Unable to process revocation.' }); + } + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } } }; diff --git a/app/src/db/migrations/20241002000000_011-ats-integration.ts b/app/src/db/migrations/20241002000000_011-ats-integration.ts new file mode 100644 index 00000000..86341d9b --- /dev/null +++ b/app/src/db/migrations/20241002000000_011-ats-integration.ts @@ -0,0 +1,221 @@ +/* eslint-disable max-len */ +import type { Knex } from 'knex'; + +import { Action, GroupName, Initiative, Resource } from '../../utils/enums/application'; + +const resources = [ + { + name: Resource.ATS + } +]; + +const actions = [ + { + name: Action.CREATE + }, + { + name: Action.READ + }, + { + name: Action.UPDATE + }, + { + name: Action.DELETE + } +]; + +export async function up(knex: Knex): Promise { + return Promise.resolve() + + .then(() => { + return knex('yars.resource').insert(resources); + }) + + .then(() => { + /* + * Add policies + */ + + const items = []; + for (const resource of resources) { + for (const action of actions) { + items.push({ + resource_id: knex('yars.resource').where({ name: resource.name }).select('resource_id'), + action_id: knex('yars.action').where({ name: action.name }).select('action_id') + }); + } + } + + return knex('yars.policy').insert(items); + }) + + .then(async () => { + /* + * Add roles + */ + + const items: Array<{ name: string; description: string }> = []; + + const addRolesForResource = (resourceName: string) => { + items.push( + { + name: `${resourceName.toUpperCase()}_CREATOR`, + description: `Can create ${resourceName.toLowerCase()}s` + }, + { + name: `${resourceName.toUpperCase()}_VIEWER`, + description: `Can view ${resourceName.toLowerCase()}s` + }, + { + name: `${resourceName.toUpperCase()}_EDITOR`, + description: `Can edit ${resourceName.toLowerCase()}s` + } + ); + }; + + for (const resource of resources) { + addRolesForResource(resource.name); + } + + return knex('yars.role').insert(items); + }) + + .then(async () => { + /* + * Add role to policy mappings + */ + + const policies = await knex + .select('p.policy_id', 'r.name as resource_name', 'a.name as action_name') + .from({ p: 'yars.policy' }) + .innerJoin({ r: 'yars.resource' }, 'p.resource_id', '=', 'r.resource_id') + .innerJoin({ a: 'yars.action' }, 'p.action_id', '=', 'a.action_id'); + + const items: Array<{ role_id: number; policy_id: number }> = []; + + const addRolePolicies = async (resourceName: string) => { + const creatorId = await knex('yars.role') + .where({ name: `${resourceName.toUpperCase()}_CREATOR` }) + .select('role_id'); + const viewerId = await knex('yars.role') + .where({ name: `${resourceName.toUpperCase()}_VIEWER` }) + .select('role_id'); + const editorId = await knex('yars.role') + .where({ name: `${resourceName.toUpperCase()}_EDITOR` }) + .select('role_id'); + + const resourcePolicies = policies.filter((x) => x.resource_name === resourceName); + items.push( + { + role_id: creatorId[0].role_id, + policy_id: resourcePolicies.find((x) => x.action_name == Action.CREATE).policy_id + }, + { + role_id: viewerId[0].role_id, + policy_id: resourcePolicies.find((x) => x.action_name == Action.READ).policy_id + }, + { + role_id: editorId[0].role_id, + policy_id: resourcePolicies.find((x) => x.action_name == Action.UPDATE).policy_id + }, + + { + role_id: editorId[0].role_id, + policy_id: resourcePolicies.find((x) => x.action_name == Action.DELETE).policy_id + } + ); + }; + + await addRolePolicies(Resource.ATS); + + return knex('yars.role_policy').insert(items); + }) + + .then(async () => { + /* + * Add group to role mappings + */ + + const housing_id = knex('initiative') + .where({ + code: Initiative.HOUSING + }) + .select('initiative_id'); + + const navigator_group_id = await knex('yars.group') + .where({ initiative_id: housing_id, name: GroupName.NAVIGATOR }) + .select('group_id'); + + const navigator_read_group_id = await knex('yars.group') + .where({ initiative_id: housing_id, name: GroupName.NAVIGATOR_READ_ONLY }) + .select('group_id'); + + const superviser_group_id = await knex('yars.group') + .where({ initiative_id: housing_id, name: GroupName.SUPERVISOR }) + .select('group_id'); + + const admin_group_id = await knex('yars.group') + .where({ initiative_id: housing_id, name: GroupName.ADMIN }) + .select('group_id'); + + const items: Array<{ group_id: number; role_id: number }> = []; + + const addResourceRoles = async (group_id: number, resourceName: Resource, actionNames: Array) => { + if (actionNames.includes(Action.CREATE)) { + items.push({ + group_id: group_id, + role_id: ( + await knex('yars.role') + .where({ name: `${resourceName}_CREATOR` }) + .select('role_id') + )[0].role_id + }); + } + + if (actionNames.includes(Action.READ)) { + items.push({ + group_id: group_id, + role_id: ( + await knex('yars.role') + .where({ name: `${resourceName}_VIEWER` }) + .select('role_id') + )[0].role_id + }); + } + + if (actionNames.includes(Action.UPDATE) || actionNames.includes(Action.DELETE)) { + items.push({ + group_id: group_id, + role_id: ( + await knex('yars.role') + .where({ name: `${resourceName}_EDITOR` }) + .select('role_id') + )[0].role_id + }); + } + }; + + // Note: Only UPDATE or DELETE is required to be given EDITOR role, don't include both + // prettier-ignore + { + // Add all navigator role mappings + await addResourceRoles(navigator_group_id[0].group_id, Resource.ATS, [Action.CREATE, Action.READ]); + + // Add all navigator read only role mappings + await addResourceRoles(navigator_read_group_id[0].group_id, Resource.ATS, [Action.READ]); + + + // Add all supervisor role mappings + await addResourceRoles(superviser_group_id[0].group_id, Resource.ATS, [Action.CREATE, Action.READ]); + + // Add all admin role mappings + await addResourceRoles(admin_group_id[0].group_id, Resource.ATS, [Action.READ]); + + } + return knex('yars.group_role').insert(items); + }); +} + +export async function down(): Promise { + return Promise.resolve(); +} diff --git a/app/src/db/migrations/20241120000000_012-permit-name-updates.ts b/app/src/db/migrations/20241120000000_012-permit-name-updates.ts new file mode 100644 index 00000000..ecbffe83 --- /dev/null +++ b/app/src/db/migrations/20241120000000_012-permit-name-updates.ts @@ -0,0 +1,131 @@ +/* eslint-disable max-len */ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return Promise.resolve() + .then(() => + knex.schema.raw(`update public.permit_type + set name = case + when name = 'Site Alteration Permit' then 'Archaeology Alteration Permit' + when name = 'Heritage Inspection Permit' then 'Archaeology Heritage Inspection Permit' + when name = 'Investigation Permit' then 'Archaeology Heritage Investigation Permit' + when name = 'Contaminated Sites Remediation Permit' then 'Site Remediation Authorization' + when name = 'Commercial General' then 'Commercial Lands Tenure' + when name = 'Residential' then 'Residential Lands Tenure' + when name = 'Roadways - Public' then 'Public Roadways Lands Tenure' + when name = 'Utilities' then 'Lands Utility Tenure' + when name = 'Rural subdivision' then 'Rural Subdivision' + when name = 'Municipal subdivision' then 'Municipal Subdivision' + when name = 'Other' then 'Other Transportation Permit' + when name = 'Change approval for work in and about a stream' then 'Change Approval for Work In and About a Stream' + when name = 'Notification of authorized changes in and about a stream' then 'Notification for Work In and About a Stream' + when name = 'Short-term use approval' then 'Short-term Water use approval' + when name = 'Groundwater Licence - Wells' then 'Groundwater Licence' + else name + end`) + ) + + .then(() => + knex.schema.raw(`update public.permit_type + set agency = case + when agency = 'Environment and Climate Change Strategy' then 'Environment and Parks' + when agency = 'Transportation and Infrastructure' then 'Transportation and Transit' + else agency + end`) + ) + + .then(() => { + return knex('permit_type').insert([ + { + agency: 'Water, Land and Resource Stewardship', + division: 'Water, Fisheries and Coast', + branch: 'Fisheries, Aquaculture and Wild Salmon', + business_domain: 'Fish and Wildlife', + type: 'Fish & Wildlife Application', + name: 'Fish Salvage Permit', + source_system: 'POSSE (ELicencing)', + source_system_acronym: 'ELIC' + } + ]); + }) + + .then(() => + knex.schema.raw(`update public.permit + set auth_status = case + when auth_status = 'Issued' then 'Granted' + when auth_status = 'Pending' then 'Pending client action' + when auth_status = 'In Review' then 'In progress' + else auth_status + end`) + ) + + .then(() => + knex.schema.raw(`update public.permit + set status = case + when status = 'New' then 'Pre-submission' + when status = 'Applied' then 'Application submission' + when status = 'Completed' then 'Post-decision' + else status + end`) + ); +} + +export async function down(knex: Knex): Promise { + return Promise.resolve() + .then(() => + knex.schema.raw(`update public.permit + set status = case + when status = 'Pre-submission' then 'New' + when status = 'Application submission' then 'Applied' + when status = 'Post-decision' then 'Completed' + else status + end`) + ) + + .then(() => + knex.schema.raw(`update public.permit + set auth_status = case + when auth_status = 'Granted' then 'Issued' + when auth_status = 'Pending client action' then 'Pending' + when auth_status = 'In progress' then 'In Review' + else auth_status + end`) + ) + + .then(() => + knex.schema.raw(`DELETE FROM public.permit_type + WHERE name = 'Fish & Wildlife Fish Salvage Permit'`) + ) + + .then(() => + knex.schema.raw(`update public.permit_type + set agency = case + when agency = 'Environment and Parks' then 'Environment and Climate Change Strategy' + when agency = 'Transportation and Transit' then 'Transportation and Infrastructure' + else agency + end`) + ) + + .then(() => + knex.schema.raw(`update public.permit_type + set name = case + when name = 'Archaeology Alteration Permit' then 'Site Alteration Permit' + when name = 'Archaeology Heritage Inspection Permit' then 'Heritage Inspection Permit' + when name = 'Archaeology Heritage Investigation Permit' then 'Investigation Permit' + when name = 'Site Remediation Authorization' then 'Contaminated Sites Remediation Permit' + when name = 'Commercial Lands Tenure' then 'Commercial General' + when name = 'Residential Lands Tenure' then 'Residential' + when name = 'Public Roadways Lands Tenure' then 'Roadways - Public' + when name = 'Lands Utility Tenure' then 'Utilities' + when name = 'Rural Subdivision' then 'Rural subdivision' + when name = 'Municipal Subdivision' then 'Municipal subdivision' + when name = 'Other Transportation Permit' then 'Other' + when name = 'Change Approval for Work In and About a Stream' then 'Change approval for work in and about a stream' + when name = 'Notification for Work In and About a Stream' then 'Notification of authorized changes in and about a stream' + when name = 'Short-term Water use approval' then 'Short-term use approval' + when name = 'Groundwater Licence' then 'Groundwater Licence - Wells' + when name = 'Surface Water Licence' then 'Surface Water Licence' + else name + end`) + ); +} diff --git a/app/src/db/migrations/20241121000000_013-add-permit-notes.ts b/app/src/db/migrations/20241121000000_013-add-permit-notes.ts new file mode 100644 index 00000000..83849ba2 --- /dev/null +++ b/app/src/db/migrations/20241121000000_013-add-permit-notes.ts @@ -0,0 +1,54 @@ +/* eslint-disable max-len */ +import stamps from '../stamps'; + +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return ( + Promise.resolve() + // Create public schema tables + .then(() => + knex.schema.createTable('permit_note', (table) => { + table.uuid('permit_note_id').primary(); // Primary key + table + .uuid('permit_id') + .notNullable() + .references('permit_id') + .inTable('public.permit') + .onUpdate('CASCADE') + .onDelete('CASCADE'); // Foreign key to 'permit' + table.text('note').notNullable().defaultTo(''); + table.boolean('is_deleted').notNullable().defaultTo(false); + stamps(knex, table); + }) + ) + + // Create public schema table triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER before_update_permit_note_trigger + BEFORE UPDATE ON public.permit_note + FOR EACH ROW + EXECUTE FUNCTION public.set_updated_at();`) + ) + + // Create audit triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_permit_note_trigger + AFTER UPDATE OR DELETE ON public.permit_note + FOR EACH ROW + EXECUTE PROCEDURE audit.if_modified_func();`) + ) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + // Drop the audit triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_permit_note_trigger ON public.permit_note')) + // Drop public schema table triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_permit_note_trigger ON public.permit_note')) + // Drop public schema tables + .then(() => knex.schema.dropTableIfExists('permit_note')) + ); +} diff --git a/app/src/db/migrations/20241125000000_014-user-contacts.ts b/app/src/db/migrations/20241125000000_014-user-contacts.ts new file mode 100644 index 00000000..16169985 --- /dev/null +++ b/app/src/db/migrations/20241125000000_014-user-contacts.ts @@ -0,0 +1,253 @@ +/* + * Two new tables are created: + * contact + * activity_contact + * + * Contact information from submission & enquiry is moved to these tables and then dropped from originals + */ + +/* eslint-disable max-len */ +import { v4 as uuidv4 } from 'uuid'; + +import stamps from '../stamps'; + +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return ( + Promise.resolve() + + // Create public schema tables + .then(() => + knex.schema.createTable('contact', (table) => { + table.uuid('contact_id').primary(); + table + .uuid('user_id') + .nullable() + .references('user_id') + .inTable('user') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.text('first_name'); + table.text('last_name'); + table.text('phone_number'); + table.text('email'); + table.text('contact_preference'); + table.text('contact_applicant_relationship'); + stamps(knex, table); + }) + ) + + .then(() => + knex.schema.createTable('activity_contact', (table) => { + table.primary(['activity_id', 'contact_id']); + table + .text('activity_id') + .notNullable() + .references('activity_id') + .inTable('activity') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table + .uuid('contact_id') + .notNullable() + .references('contact_id') + .inTable('contact') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + stamps(knex, table); + }) + ) + + // Create public schema table triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER before_update_contact_trigger + BEFORE UPDATE ON public."contact" + FOR EACH ROW EXECUTE PROCEDURE public.set_updated_at();`) + ) + + .then(() => + knex.schema.raw(`CREATE TRIGGER before_update_activity_contact_trigger + BEFORE UPDATE ON public."activity_contact" + FOR EACH ROW EXECUTE PROCEDURE public.set_updated_at();`) + ) + + // Create audit triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_contact_trigger + AFTER UPDATE OR DELETE ON public."contact" + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_activity_contact_trigger + AFTER UPDATE OR DELETE ON public."activity_contact" + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + + // Split data + .then(async () => { + await knex.raw(`DROP TABLE IF EXISTS temp; + DELETE FROM public.activity_contact; + DELETE FROM public.contact; + + CREATE TABLE temp (contact_id uuid, activity_id text, contact_first_name text, contact_last_name text, contact_email text, contact_phone_number text, contact_preference text, contact_applicant_relationship text);`); + + let submissions = await knex + .select( + 's.activity_id', + 's.contact_first_name', + 's.contact_last_name', + 's.contact_email', + 's.contact_phone_number', + 's.contact_preference', + 's.contact_applicant_relationship' + ) + .from({ s: 'public.submission' }); + + submissions = submissions.map((x) => ({ + contact_id: uuidv4(), + ...x + })); + + let enquiries = await knex + .select( + 'e.activity_id', + 'e.contact_first_name', + 'e.contact_last_name', + 'e.contact_email', + 'e.contact_phone_number', + 'e.contact_preference', + 'e.contact_applicant_relationship' + ) + .from({ e: 'public.enquiry' }); + + enquiries = enquiries.map((x) => ({ + contact_id: uuidv4(), + ...x + })); + + if (submissions && submissions.length) await knex('public.temp').insert(submissions); + if (enquiries && enquiries.length) await knex('public.temp').insert(enquiries); + + await knex.raw(`INSERT INTO public.contact (contact_id, first_name, last_name, email, phone_number, contact_preference, contact_applicant_relationship) + SELECT contact_id, contact_first_name, contact_last_name, contact_email, contact_phone_number, contact_preference, contact_applicant_relationship + FROM public.temp; + + INSERT INTO public.activity_contact (activity_id, contact_id) + SELECT activity_id, contact_id + FROM public.temp; + + DROP TABLE IF EXISTS temp;`); + }) + + // Drop old columns + .then(() => + knex.schema.raw(`ALTER TABLE public.submission + DROP COLUMN IF EXISTS contact_name;`) + ) + + .then(() => + knex.schema.alterTable('submission', function (table) { + table.dropColumn('contact_first_name'); + table.dropColumn('contact_last_name'); + table.dropColumn('contact_email'); + table.dropColumn('contact_phone_number'); + table.dropColumn('contact_preference'); + table.dropColumn('contact_applicant_relationship'); + }) + ) + + .then(() => + knex.schema.alterTable('enquiry', function (table) { + table.dropColumn('contact_first_name'); + table.dropColumn('contact_last_name'); + table.dropColumn('contact_email'); + table.dropColumn('contact_phone_number'); + table.dropColumn('contact_preference'); + table.dropColumn('contact_applicant_relationship'); + }) + ) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + // Add columns + .then(() => + knex.schema.alterTable('enquiry', function (table) { + table.text('contact_first_name'); + table.text('contact_last_name'); + table.text('contact_email'); + table.text('contact_phone_number'); + table.text('contact_preference'); + table.text('contact_applicant_relationship'); + }) + ) + + .then(() => + knex.schema.alterTable('submission', function (table) { + table.text('contact_first_name'); + table.text('contact_last_name'); + table.text('contact_email'); + table.text('contact_phone_number'); + table.text('contact_preference'); + table.text('contact_applicant_relationship'); + }) + ) + + .then(() => + knex.schema.raw(`ALTER TABLE public.submission + ADD COLUMN IF NOT EXISTS contact_name TEXT;`) + ) + + // Retrieve data + .then(() => + knex.schema.raw(`DROP TABLE IF EXISTS temp; + + CREATE TABLE temp (contact_id uuid, activity_id text, contact_first_name text, contact_last_name text, contact_email text, contact_phone_number text, contact_preference text, contact_applicant_relationship text); + + INSERT INTO temp (contact_id, activity_id, contact_first_name, contact_last_name, contact_email, contact_phone_number, contact_preference, contact_applicant_relationship) + SELECT c.contact_id, ac.activity_id, c.first_name, c.last_name, c.email, c.phone_number, c.contact_preference, c.contact_applicant_relationship + FROM public.contact c + JOIN public.activity_contact ac on ac.contact_id = c.contact_id; + + UPDATE public.submission AS s + set contact_first_name = t.contact_first_name, + contact_last_name = t.contact_last_name, + contact_email = t.contact_email, + contact_phone_number = t.contact_phone_number, + contact_preference = t.contact_preference, + contact_applicant_relationship = t.contact_applicant_relationship + FROM public.temp t + WHERE s.activity_id = t.activity_id; + + UPDATE public.enquiry AS e + set contact_first_name = t.contact_first_name, + contact_last_name = t.contact_last_name, + contact_email = t.contact_email, + contact_phone_number = t.contact_phone_number, + contact_preference = t.contact_preference, + contact_applicant_relationship = t.contact_applicant_relationship + FROM public.temp t + WHERE e.activity_id = t.activity_id; + + DROP TABLE IF EXISTS temp;`) + ) + + // Drop audit triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_activity_contact_trigger ON public."activity_contact"')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_contact_trigger ON public."contact"')) + + // Drop public schema table triggers + .then(() => + knex.schema.raw('DROP TRIGGER IF EXISTS before_update_activity_contact_trigger ON public."activity_contact"') + ) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_contact_trigger ON public."contact"')) + + // Drop public schema tables + .then(() => knex.schema.dropTableIfExists('activity_contact')) + .then(() => knex.schema.dropTableIfExists('contact')) + ); +} diff --git a/app/src/db/migrations/20241125000000_015-draft-tables.ts b/app/src/db/migrations/20241125000000_015-draft-tables.ts new file mode 100644 index 00000000..6ac29806 --- /dev/null +++ b/app/src/db/migrations/20241125000000_015-draft-tables.ts @@ -0,0 +1,88 @@ +/* eslint-disable max-len */ +import stamps from '../stamps'; + +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return Promise.resolve().then(() => + // Create public schema tables + knex.schema + .createTable('draft_code', (table) => { + table.text('draft_code').primary(); + stamps(knex, table); + }) + + .createTable('draft', (table) => { + table.uuid('draft_id').primary(); + table + .text('activity_id') + .notNullable() + .references('activity_id') + .inTable('activity') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table + .text('draft_code') + .notNullable() + .references('draft_code') + .inTable('draft_code') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.json('data').notNullable(); + stamps(knex, table); + }) + + // Create before update triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER before_update_draft_code_trigger + BEFORE UPDATE ON draft_code + FOR EACH ROW EXECUTE PROCEDURE public.set_updated_at();`) + ) + + .then(() => + knex.schema.raw(`CREATE TRIGGER before_update_draft_trigger + BEFORE UPDATE ON draft + FOR EACH ROW EXECUTE PROCEDURE public.set_updated_at();`) + ) + + // Create audit triggers + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_draft_code_trigger + AFTER UPDATE OR DELETE ON draft_code + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_draft_trigger + AFTER UPDATE OR DELETE ON draft + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + + // Populate Baseline Data + .then(() => { + const items = [ + { + draft_code: 'SUBMISSION' + } + ]; + return knex('draft_code').insert(items); + }) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + // Drop audit triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_draft_trigger ON draft')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_draft_code_trigger ON draft_code')) + + // Drop public schema table triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_draft_trigger ON draft')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_draft_code_trigger ON draft_code')) + + // Drop public schema tables + .then(() => knex.schema.dropTableIfExists('draft')) + .then(() => knex.schema.dropTableIfExists('draft_code')) + ); +} diff --git a/app/src/db/models/access_request.ts b/app/src/db/models/access_request.ts index fc57a667..bbedeb24 100644 --- a/app/src/db/models/access_request.ts +++ b/app/src/db/models/access_request.ts @@ -7,8 +7,10 @@ import type { AccessRequest } from '../../types/AccessRequest'; // Define types const _accessRequest = Prisma.validator()({}); +const _accessRequestWithGraph = Prisma.validator()({}); type PrismaRelationAccessRequest = Omit, keyof Stamps>; +type PrismaGraphAccessRequest = Prisma.access_requestGetPayload; export default { toPrismaModel(input: AccessRequest): PrismaRelationAccessRequest { @@ -21,13 +23,14 @@ export default { }; }, - fromPrismaModel(input: PrismaRelationAccessRequest): AccessRequest { + fromPrismaModel(input: PrismaGraphAccessRequest): AccessRequest { return { accessRequestId: input.access_request_id, grant: input.grant, group: input.group as GroupName | null, userId: input.user_id as string, - status: input.status as AccessRequestStatus + status: input.status as AccessRequestStatus, + createdAt: input.created_at?.toISOString() }; } }; diff --git a/app/src/db/models/activity.ts b/app/src/db/models/activity.ts index 988950b7..71a97cef 100644 --- a/app/src/db/models/activity.ts +++ b/app/src/db/models/activity.ts @@ -17,9 +17,7 @@ export default { }; }, - fromPrismaModel(input: PrismaRelationActivity | null): Activity | null { - if (!input) return null; - + fromPrismaModel(input: PrismaRelationActivity): Activity { return { activityId: input.activity_id, initiativeId: input.initiative_id, diff --git a/app/src/db/models/contact.ts b/app/src/db/models/contact.ts new file mode 100644 index 00000000..524611a2 --- /dev/null +++ b/app/src/db/models/contact.ts @@ -0,0 +1,37 @@ +import { Prisma } from '@prisma/client'; + +import type { Stamps } from '../stamps'; +import type { Contact } from '../../types/Contact'; + +// Define types +const _contact = Prisma.validator()({}); + +type PrismaRelationContact = Omit, keyof Stamps>; + +export default { + toPrismaModel(input: Contact): PrismaRelationContact { + return { + contact_id: input.contactId, + user_id: input.userId, + first_name: input.firstName, + last_name: input.lastName, + phone_number: input.phoneNumber, + email: input.email, + contact_preference: input.contactPreference, + contact_applicant_relationship: input.contactApplicantRelationship + }; + }, + + fromPrismaModel(input: PrismaRelationContact): Contact { + return { + contactId: input.contact_id, + userId: input.user_id, + firstName: input.first_name, + lastName: input.last_name, + phoneNumber: input.phone_number, + email: input.email, + contactPreference: input.contact_preference, + contactApplicantRelationship: input.contact_applicant_relationship + }; + } +}; diff --git a/app/src/db/models/draft.ts b/app/src/db/models/draft.ts new file mode 100644 index 00000000..9c50ebcb --- /dev/null +++ b/app/src/db/models/draft.ts @@ -0,0 +1,22 @@ +import { Prisma } from '@prisma/client'; + +import type { Draft } from '../../types'; + +// Define types +const _draft = Prisma.validator()({}); + +type PrismaGraphDraft = Prisma.draftGetPayload; + +export default { + fromPrismaModel(input: PrismaGraphDraft): Draft { + return { + draftId: input.draft_id, + activityId: input.activity_id, + draftCode: input.draft_code, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: input.data as any, + createdBy: input.created_by, + updatedAt: input.updated_at?.toISOString() ?? null + }; + } +}; diff --git a/app/src/db/models/enquiry.ts b/app/src/db/models/enquiry.ts index 37977509..b18fdf3e 100644 --- a/app/src/db/models/enquiry.ts +++ b/app/src/db/models/enquiry.ts @@ -1,17 +1,44 @@ import { Prisma } from '@prisma/client'; +import contact from './contact'; import user from './user'; import type { Enquiry } from '../../types'; // Define types const _enquiry = Prisma.validator()({}); -const _enquiryWithGraph = Prisma.validator()({}); -const _enquiryWithUserGraph = Prisma.validator()({ include: { user: true } }); +const _enquiryWithContactGraph = Prisma.validator()({ + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } + } +}); +const _enquiryWithUserGraph = Prisma.validator()({ + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + }, + user: true + } +}); type PrismaRelationEnquiry = Prisma.enquiryGetPayload; -type PrismaGraphEnquiry = Prisma.enquiryGetPayload; -type PrismaGraphEnquiryUser = Prisma.enquiryGetPayload; +type PrismaGraphEnquiry = Prisma.enquiryGetPayload; +type PrismaGraphEnquiryWithContact = Prisma.enquiryGetPayload; +type PrismaGraphEnquiryWithUser = Prisma.enquiryGetPayload; export default { toPrismaModel(input: Enquiry): PrismaRelationEnquiry { @@ -22,12 +49,6 @@ export default { enquiry_type: input.enquiryType, submitted_at: new Date(input.submittedAt ?? Date.now()), submitted_by: input.submittedBy, - contact_first_name: input.contactFirstName, - contact_last_name: input.contactLastName, - contact_phone_number: input.contactPhoneNumber, - contact_email: input.contactEmail, - contact_preference: input.contactPreference, - contact_applicant_relationship: input.contactApplicantRelationship, is_related: input.isRelated, related_activity_id: input.relatedActivityId, enquiry_description: input.enquiryDescription, @@ -50,12 +71,6 @@ export default { enquiryType: input.enquiry_type, submittedAt: input.submitted_at?.toISOString() as string, submittedBy: input.submitted_by, - contactFirstName: input.contact_first_name, - contactLastName: input.contact_last_name, - contactPhoneNumber: input.contact_phone_number, - contactEmail: input.contact_email, - contactPreference: input.contact_preference, - contactApplicantRelationship: input.contact_applicant_relationship, isRelated: input.is_related, relatedActivityId: input.related_activity_id, enquiryDescription: input.enquiry_description, @@ -63,6 +78,7 @@ export default { intakeStatus: input.intake_status, enquiryStatus: input.enquiry_status, waitingOn: input.waiting_on, + contacts: [], user: null, createdAt: input.created_at?.toISOString() ?? null, createdBy: input.created_by, @@ -71,10 +87,17 @@ export default { }; }, - fromPrismaModelWithUser(input: PrismaGraphEnquiryUser | null): Enquiry | null { - if (!input) return null; - + fromPrismaModelWithContact(input: PrismaGraphEnquiryWithContact): Enquiry { const enquiry = this.fromPrismaModel(input); + if (enquiry && input.activity.activity_contact) { + enquiry.contacts = input.activity.activity_contact.map((x) => contact.fromPrismaModel(x.contact)); + } + + return enquiry; + }, + + fromPrismaModelWithUser(input: PrismaGraphEnquiryWithUser): Enquiry { + const enquiry = this.fromPrismaModelWithContact(input); if (enquiry && input.user) { enquiry.user = user.fromPrismaModel(input.user); } diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index 2d29975a..b3515604 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,10 +1,13 @@ export { default as activity } from './activity'; export { default as access_request } from './access_request'; +export { default as contact } from './contact'; export { default as document } from './document'; +export { default as draft } from './draft'; export { default as enquiry } from './enquiry'; export { default as identity_provider } from './identity_provider'; export { default as note } from './note'; export { default as permit } from './permit'; +export { default as permit_note } from './permit_note'; export { default as permit_type } from './permit_type'; export { default as submission } from './submission'; export { default as user } from './user'; diff --git a/app/src/db/models/permit.ts b/app/src/db/models/permit.ts index 46045932..b570844b 100644 --- a/app/src/db/models/permit.ts +++ b/app/src/db/models/permit.ts @@ -1,16 +1,20 @@ import { Prisma } from '@prisma/client'; +import permit_note from './permit_note'; + import type { Stamps } from '../stamps'; import type { Permit } from '../../types'; // Define types const _permit = Prisma.validator()({}); -const _permitWithGraph = Prisma.validator()({ - include: { permit_type: true } +const _permitWithGraph = Prisma.validator()({ include: { permit_type: true } }); +const _permitWithNotesGraph = Prisma.validator()({ + include: { permit_note: true, permit_type: true } }); type PrismaRelationPermit = Omit, 'activity' | keyof Stamps>; type PrismaGraphPermit = Prisma.permitGetPayload; +type PrismaGraphPermitNotes = Prisma.permitGetPayload; export default { toPrismaModel(input: Permit): PrismaRelationPermit { @@ -45,5 +49,14 @@ export default { updatedAt: input.updated_at?.toISOString() ?? null, updatedBy: input.updated_by }; + }, + + fromPrismaModelWithNotes(input: PrismaGraphPermitNotes): Permit { + const permit = this.fromPrismaModel(input); + if (permit && input.permit_note) { + permit.permitNote = input.permit_note.map((note) => permit_note.fromPrismaModel(note)); + } + + return permit; } }; diff --git a/app/src/db/models/permit_note.ts b/app/src/db/models/permit_note.ts new file mode 100644 index 00000000..592682c5 --- /dev/null +++ b/app/src/db/models/permit_note.ts @@ -0,0 +1,32 @@ +import { Prisma } from '@prisma/client'; + +import type { Stamps } from '../stamps'; +import type { PermitNote } from '../../types'; + +// Define types +const _permitNote = Prisma.validator()({}); + +type PrismaRelationPermitNote = Omit, keyof Stamps>; +type PrismaRelationPermitNoteWithStamps = Prisma.permit_noteGetPayload; + +export default { + toPrismaModel(input: PermitNote): PrismaRelationPermitNote { + return { + permit_note_id: input.permitNoteId, + permit_id: input.permitId, + note: input.note, + is_deleted: input.isDeleted + }; + }, + + fromPrismaModel(input: PrismaRelationPermitNoteWithStamps): PermitNote { + return { + permitNoteId: input.permit_note_id, + permitId: input.permit_id, + note: input.note, + isDeleted: input.is_deleted, + updatedAt: input.updated_at?.toISOString() as string, + createdAt: input.created_at?.toISOString() as string + }; + } +}; diff --git a/app/src/db/models/submission.ts b/app/src/db/models/submission.ts index 538eeffb..ce6564e6 100644 --- a/app/src/db/models/submission.ts +++ b/app/src/db/models/submission.ts @@ -1,6 +1,7 @@ import { Prisma } from '@prisma/client'; import { Decimal } from '@prisma/client/runtime/library'; +import contact from './contact'; import user from './user'; import { BasicResponse } from '../../utils/enums/application'; @@ -9,12 +10,38 @@ import type { Submission } from '../../types'; // Define types const _submission = Prisma.validator()({}); -const _submissionWithGraph = Prisma.validator()({}); -const _submissionWithUserGraph = Prisma.validator()({ include: { user: true } }); +const _submissionWithContactGraph = Prisma.validator()({ + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } + } +}); +const _submissionWithUserGraph = Prisma.validator()({ + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + }, + user: true + } +}); type PrismaRelationSubmission = Omit, keyof Stamps>; -type PrismaGraphSubmission = Prisma.submissionGetPayload; -type PrismaGraphSubmissionUser = Prisma.submissionGetPayload; +type PrismaGraphSubmission = Prisma.submissionGetPayload; +type PrismaGraphSubmissionWithContact = Prisma.submissionGetPayload; +type PrismaGraphSubmissionWithUser = Prisma.submissionGetPayload; export default { toPrismaModel(input: Submission): PrismaRelationSubmission { @@ -28,13 +55,6 @@ export default { submitted_by: input.submittedBy, consent_to_feedback: input.consentToFeedback, location_pids: input.locationPIDs, - contact_name: `${input.contactFirstName} ${input.contactLastName}`, - contact_first_name: input.contactFirstName, - contact_last_name: input.contactLastName, - contact_applicant_relationship: input.contactApplicantRelationship, - contact_phone_number: input.contactPhoneNumber, - contact_email: input.contactEmail, - contact_preference: input.contactPreference, company_name_registered: input.companyNameRegistered, single_family_units: input.singleFamilyUnits, has_rental_units: input.hasRentalUnits, @@ -86,10 +106,6 @@ export default { submittedAt: input.submitted_at?.toISOString() as string, submittedBy: input.submitted_by, locationPIDs: input.location_pids, - contactApplicantRelationship: input.contact_applicant_relationship, - contactPhoneNumber: input.contact_phone_number, - contactEmail: input.contact_email, - contactPreference: input.contact_preference, consentToFeedback: input.consent_to_feedback, projectName: input.project_name, projectDescription: input.project_description, @@ -131,18 +147,26 @@ export default { indigenousDescription: input.indigenous_description, nonProfitDescription: input.non_profit_description, housingCoopDescription: input.housing_coop_description, - contactFirstName: input.contact_first_name, - contactLastName: input.contact_last_name, submissionType: input.submission_type, relatedEnquiries: null, createdBy: input.created_by, updatedAt: input.updated_at?.toISOString() as string, + contacts: [], user: null }; }, - fromPrismaModelWithUser(input: PrismaGraphSubmissionUser): Submission { + fromPrismaModelWithContact(input: PrismaGraphSubmissionWithContact): Submission { const submission = this.fromPrismaModel(input); + if (submission && input.activity.activity_contact) { + submission.contacts = input.activity.activity_contact.map((x) => contact.fromPrismaModel(x.contact)); + } + + return submission; + }, + + fromPrismaModelWithUser(input: PrismaGraphSubmissionWithUser): Submission { + const submission = this.fromPrismaModelWithContact(input); if (submission && input.user) { submission.user = user.fromPrismaModel(input.user); } diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index c648fe8b..3f7a8d88 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -9,36 +9,70 @@ datasource db { schemas = ["public", "yars"] } -model knex_migrations { - id Int @id @default(autoincrement()) - name String? @db.VarChar(255) - batch Int? - migration_time DateTime? @db.Timestamptz(6) +model access_request { + access_request_id String @id @db.Uuid + user_id String @db.Uuid + group String? + status access_request_status_enum @default(Pending) + grant Boolean + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + user user @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction, map: "access_request_user_id_foreign") @@schema("public") } -model knex_migrations_lock { - index Int @id @default(autoincrement()) - is_locked Int? +model activity { + activity_id String @id + initiative_id String @db.Uuid + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + is_deleted Boolean @default(false) + initiative initiative @relation(fields: [initiative_id], references: [initiative_id], onDelete: Cascade, map: "activity_initiative_id_foreign") + activity_contact activity_contact[] + document document[] + draft draft[] + enquiry enquiry[] + note note[] + permit permit[] + submission submission[] @@schema("public") } -model activity { - activity_id String @id - initiative_id String @db.Uuid - created_by String? @default("00000000-0000-0000-0000-000000000000") - created_at DateTime? @default(now()) @db.Timestamptz(6) - updated_by String? - updated_at DateTime? @db.Timestamptz(6) - is_deleted Boolean @default(false) - initiative initiative @relation(fields: [initiative_id], references: [initiative_id], onDelete: Cascade, map: "activity_initiative_id_foreign") - document document[] - enquiry enquiry[] - note note[] - permit permit[] - submission submission[] +model activity_contact { + activity_id String + contact_id String @db.Uuid + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "activity_contact_activity_id_foreign") + contact contact @relation(fields: [contact_id], references: [contact_id], onDelete: Cascade, map: "activity_contact_contact_id_foreign") + + @@id([activity_id, contact_id]) + @@schema("public") +} + +model contact { + contact_id String @id @db.Uuid + user_id String? @db.Uuid + first_name String? + last_name String? + phone_number String? + email String? + contact_preference String? + contact_applicant_relationship String? + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + activity_contact activity_contact[] + user user? @relation(fields: [user_id], references: [user_id], onDelete: Cascade, map: "contact_user_id_foreign") @@schema("public") } @@ -59,6 +93,30 @@ model document { @@schema("public") } +model enquiry { + enquiry_id String @id @db.Uuid + activity_id String + assigned_user_id String? @db.Uuid + enquiry_type String? + submitted_at DateTime @db.Timestamptz(6) + submitted_by String + is_related String? + related_activity_id String? + enquiry_description String? + apply_for_permit_connect String? + intake_status String? + enquiry_status String? + waiting_on String? + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "enquiry_activity_id_foreign") + user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "enquiry_assigned_user_id_foreign") + + @@schema("public") +} + model identity_provider { idp String @id active Boolean @default(true) @@ -85,6 +143,22 @@ model initiative { @@schema("public") } +model knex_migrations { + id Int @id @default(autoincrement()) + name String? @db.VarChar(255) + batch Int? + migration_time DateTime? @db.Timestamptz(6) + + @@schema("public") +} + +model knex_migrations_lock { + index Int @id @default(autoincrement()) + is_locked Int? + + @@schema("public") +} + model note { note_id String @id @db.Uuid activity_id String @@ -104,7 +178,7 @@ model note { } model permit { - permit_id String @id @db.Uuid + permit_id String @id @db.Uuid permit_type_id Int activity_id String issued_permit_id String? @@ -112,20 +186,35 @@ model permit { auth_status String? needed String? status String? - submitted_date DateTime? @db.Timestamptz(6) - adjudication_date DateTime? @db.Timestamptz(6) - created_by String? @default("00000000-0000-0000-0000-000000000000") - created_at DateTime? @default(now()) @db.Timestamptz(6) + submitted_date DateTime? @db.Timestamptz(6) + adjudication_date DateTime? @db.Timestamptz(6) + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) updated_by String? - updated_at DateTime? @db.Timestamptz(6) - status_last_verified DateTime? @db.Timestamptz(6) - activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "permit_activity_id_foreign") - permit_type permit_type @relation(fields: [permit_type_id], references: [permit_type_id], onDelete: Cascade, map: "permit_permit_type_id_foreign") + updated_at DateTime? @db.Timestamptz(6) + status_last_verified DateTime? @db.Timestamptz(6) + activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "permit_activity_id_foreign") + permit_type permit_type @relation(fields: [permit_type_id], references: [permit_type_id], onDelete: Cascade, map: "permit_permit_type_id_foreign") + permit_note permit_note[] @@unique([permit_id, permit_type_id, activity_id], map: "permit_permit_id_permit_type_id_activity_id_unique") @@schema("public") } +model permit_note { + permit_note_id String @id @db.Uuid + permit_id String @db.Uuid + note String @default("") + is_deleted Boolean @default(false) + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + permit permit @relation(fields: [permit_id], references: [permit_id], onDelete: Cascade, map: "permit_note_permit_id_foreign") + + @@schema("public") +} + model permit_type { permit_type_id Int @id @default(autoincrement()) agency String @@ -158,9 +247,6 @@ model submission { submitted_by String location_pids String? company_name_registered String? - contact_name String? - contact_phone_number String? - contact_email String? project_name String? single_family_units String? street_address String? @@ -188,8 +274,6 @@ model submission { created_at DateTime? @default(now()) @db.Timestamptz(6) updated_by String? updated_at DateTime? @db.Timestamptz(6) - contact_preference String? - contact_applicant_relationship String? has_rental_units String? project_description String? is_developed_by_company_or_org String? @@ -208,8 +292,6 @@ model submission { non_profit_description String? housing_coop_description String? submission_type String? - contact_first_name String? - contact_last_name String? consent_to_feedback Boolean @default(false) activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "submission_activity_id_foreign") user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "submission_assigned_user_id_foreign") @@ -232,6 +314,7 @@ model user { updated_by String? updated_at DateTime? @db.Timestamptz(6) access_request access_request[] + contact contact[] enquiry enquiry[] submission submission[] identity_provider identity_provider? @relation(fields: [idp], references: [idp], onDelete: Cascade, map: "user_idp_foreign") @@ -243,51 +326,6 @@ model user { @@schema("public") } -model enquiry { - enquiry_id String @id @db.Uuid - activity_id String - assigned_user_id String? @db.Uuid - enquiry_type String? - submitted_at DateTime @db.Timestamptz(6) - submitted_by String - contact_first_name String? - contact_last_name String? - contact_phone_number String? - contact_email String? - contact_preference String? - contact_applicant_relationship String? - is_related String? - related_activity_id String? - enquiry_description String? - apply_for_permit_connect String? - intake_status String? - enquiry_status String? - waiting_on String? - created_by String? @default("00000000-0000-0000-0000-000000000000") - created_at DateTime? @default(now()) @db.Timestamptz(6) - updated_by String? - updated_at DateTime? @db.Timestamptz(6) - activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "enquiry_activity_id_foreign") - user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "enquiry_assigned_user_id_foreign") - - @@schema("public") -} - -model access_request { - access_request_id String @id @db.Uuid - user_id String @db.Uuid - group String? - status access_request_status_enum @default(Pending) - grant Boolean - created_by String? @default("00000000-0000-0000-0000-000000000000") - created_at DateTime? @default(now()) @db.Timestamptz(6) - updated_by String? - updated_at DateTime? @db.Timestamptz(6) - user user @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction, map: "access_request_user_id_foreign") - - @@schema("public") -} - model action { action_id Int @id @default(autoincrement()) name String @unique(map: "action_name_unique") @@ -445,6 +483,32 @@ model subject_group { @@schema("yars") } +model draft { + draft_id String @id @db.Uuid + activity_id String + draft_code String + data Json @db.Json + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "draft_activity_id_foreign") + draft_code_draft_draft_codeTodraft_code draft_code @relation("draft_draft_codeTodraft_code", fields: [draft_code], references: [draft_code], onDelete: Cascade, map: "draft_draft_code_foreign") + + @@schema("public") +} + +model draft_code { + draft_code String @id + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + draft_draft_draft_codeTodraft_code draft[] @relation("draft_draft_codeTodraft_code") + + @@schema("public") +} + view group_role_policy_vw { row_number BigInt @unique group_id Int? diff --git a/app/src/docs/docs.ts b/app/src/docs/docs.ts new file mode 100644 index 00000000..d780521a --- /dev/null +++ b/app/src/docs/docs.ts @@ -0,0 +1,23 @@ +const docs = { + getDocHTML: (version: string) => ` + + + NR PermitConnect Navigator Service (PCNS) API - Documentation ${version} + + + + + + + + + + + + + ` +}; + +export default docs; diff --git a/app/src/docs/v1.api-spec.yaml b/app/src/docs/v1.api-spec.yaml new file mode 100644 index 00000000..3df6f921 --- /dev/null +++ b/app/src/docs/v1.api-spec.yaml @@ -0,0 +1,2752 @@ +--- +openapi: 3.0.2 +info: + version: 1.0.0 + title: NR PermitConnect Navigator Service (PCNS) + description: >- + A case management application meant to serve the needs of the NRM Permitting + Solutions Branch. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: /api/v1 + description: This server +security: + - BearerAuth: [] + OpenID: [] +tags: + - name: Document + description: Operations for managing Documents + - name: Enquiry + description: Operations for managing Enquiries + - name: Note + description: Operations for managing Notes + - name: Permit + description: Operations for managing Permits + - name: Permit Note + description: Operations for Permit Notes + - name: Roadmap + description: Operations for validating Roadmaps + - name: Submission + description: Operations for managing Submissions, Drafts, and Statistics + - name: User + description: Operations for managing users +paths: + /document: + put: + summary: Create a document + description: >- + Creates a link between an activity and a previously existing object in + COMS. + operationId: createDocument + tags: + - Document + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-Document' + responses: + '201': + description: Document created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Document' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /document/{documentId}: + delete: + summary: Delete a document by ID + description: Deletes the document associated with the provided document ID. + operationId: deleteDocument + tags: + - Document + parameters: + - $ref: '#/components/parameters/Path-DocumentId' + responses: + '200': + description: Document deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Document' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /document/list/{activityId}: + get: + summary: List documents by Activity ID + description: Retrieves a list of documents associated with a given activity. + operationId: listDocuments + tags: + - Document + parameters: + - $ref: '#/components/parameters/Path-ActivityId' + responses: + '200': + description: A list of documents for the specified activity + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DB-Document' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /enquiry: + get: + summary: Gets a list of enquiries + description: >- + Gets a list of enquiries, if the current authorization includes scope:self + the list returned will contain just the enquiries made by the user. + operationId: getEnquiries + tags: + - Enquiry + responses: + '200': + description: A list of enquiries + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DB-Enquiry' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + default: + $ref: '#/components/responses/Error' + put: + summary: Create an enquiry + description: Creates an enquiry and set status to Submitted + operationId: putEnquiry + tags: + - Enquiry + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-CreateEnquiry' + responses: + '201': + description: Enquiry created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Response-CreateEnquiry' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /enquiry/{enquiryId}: + get: + summary: Get a specific enquiry + operationId: getEnquiry + tags: + - Enquiry + parameters: + - $ref: '#/components/parameters/Path-EnquiryId' + responses: + '200': + description: Details of the specific enquiry + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Enquiry' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + default: + $ref: '#/components/responses/Error' + put: + summary: Update an enquiry + operationId: updateEnquiry + tags: + - Enquiry + parameters: + - $ref: '#/components/parameters/Path-EnquiryId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Enquiry' + responses: + '200': + description: Enquiry updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Enquiry' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + delete: + summary: Delete an enquiry + description: >- + Deletes the enquiry, followed by the associated activity. This action + will cascade delete across all linked items + operationId: deleteEnquiry + tags: + - Enquiry + parameters: + - $ref: '#/components/parameters/Path-EnquiryId' + responses: + '200': + description: Enquiry deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Enquiry' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + default: + $ref: '#/components/responses/Error' + /enquiry/{enquiryId}/delete: + patch: + summary: Soft delete + description: A soft delete of an enquiry using a is_deleted flag + operationId: enquiry-updateIsDeletedFlag + tags: + - Enquiry + parameters: + - $ref: '#/components/parameters/Path-EnquiryId' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + isDeleted: + type: boolean + example: true + responses: + '200': + description: The is_deleted flag updated successfully + content: + application/json: + schema: + allOf: + - type: object + required: + - is_deleted + properties: + is_deleted: + type: boolean + example: true + - $ref: '#/components/schemas/DB-Enquiry' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /note: + put: + summary: Create a note + operationId: createNote + tags: + - Note + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-Notes' + responses: + '201': + description: Note created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Note' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /note/{noteId}: + put: + summary: Update a note + operationId: updateNote + tags: + - Note + parameters: + - $ref: '#/components/parameters/Path-NoteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Note' + responses: + '200': + description: Note updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Note' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + delete: + summary: Delete a note + description: Soft deletes a note by marking isDeleted true + operationId: deleteNote + tags: + - Note + parameters: + - $ref: '#/components/parameters/Path-NoteId' + responses: + '200': + description: Note deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Note' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + default: + $ref: '#/components/responses/Error' + /note/bringForward: + get: + summary: List notes with bring forward type + description: Retrieve a list of notes with the Bring forward type given + operationId: listBringForward + tags: + - Note + parameters: + - $ref: '#/components/parameters/Query-BringForwardState' + responses: + '200': + description: A list of bring forward notes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Response-BringForwardNotes' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + default: + $ref: '#/components/responses/Error' + /note/list/{activityId}: + get: + summary: List notes by Activity ID + description: Return a list of notes that have the given Activity ID + operationId: listNotes + tags: + - Note + parameters: + - $ref: '#/components/parameters/Path-ActivityId' + responses: + '200': + description: A list of notes for the specified activity + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DB-Note' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /permit: + get: + summary: List permits by Activity ID + description: Gets a list of permits that are associated to a specific activity + operationId: listPermits + tags: + - Permit + parameters: + - $ref: '#/components/parameters/Query-ActivityId' + - in: query + name: includeNotes + description: Whether to include associated permit notes in the response. + required: false + schema: + type: boolean + example: true + responses: + '200': + description: A list of permits for the specified activity + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/DB-Permit' + - type: object + properties: + permitNotes: + type: array + description: List of notes associated with the permit (if `includeNotes` is true) + items: + $ref: '#/components/schemas/DB-PermitNote' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + put: + summary: Create a permit + operationId: createPermit + tags: + - Permit + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-Permit' + responses: + '201': + description: Permit created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Permit' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /permit/{permitId}: + put: + summary: Update a permit + operationId: updatePermit + tags: + - Permit + parameters: + - in: path + name: permitId + required: true + schema: + type: string + description: The ID of the permit to update + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-Permit' + responses: + '200': + description: Permit updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Permit' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + delete: + summary: Delete a permit + operationId: deletePermit + tags: + - Permit + parameters: + - $ref: '#/components/parameters/Path-PermitId' + responses: + '200': + description: Permit deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Permit' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /permit/types: + get: + summary: Get all permit types + operationId: getPermitTypes + tags: + - Permit + responses: + '200': + description: A list of all permit types + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DB-PermitType' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + default: + $ref: '#/components/responses/Error' + /permit/note: + put: + summary: Create a permit note + description: Creates a new permit note for a specified permit. + operationId: createPermitNote + tags: + - Permit Note + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-PermitNote' + responses: + '201': + description: Permit note created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-PermitNote' + '401': + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/UnprocessableEntity' + default: + $ref: '#/components/responses/Error' + /roadmap: + put: + summary: Send an email with the roadmap data + operationId: send + tags: + - Roadmap + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-Roadmap' + responses: + '201': + description: Permit created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Response-Email' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /submission: + get: + summary: Get a list of submissions + operationId: getSubmissions + tags: + - Submission + responses: + '200': + description: A list of submissions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + default: + $ref: '#/components/responses/Error' + put: + summary: Create a submission + operationId: createSubmission + tags: + - Submission + responses: + '201': + description: Submission created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Response-SubmissionCreate' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /submission/{submissionId}: + get: + summary: Get a specific submission + operationId: getSubmission + tags: + - Submission + parameters: + - $ref: '#/components/parameters/Path-SubmissionId' + responses: + '200': + description: Details of the specific submission + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + put: + summary: Update a submission + operationId: updateSubmission + tags: + - Submission + parameters: + - $ref: '#/components/parameters/Path-SubmissionId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-SubmissionUpdate' + responses: + '200': + description: Submission updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + delete: + summary: Delete a submission + operationId: deleteSubmission + tags: + - Submission + parameters: + - $ref: '#/components/parameters/Path-SubmissionId' + responses: + '200': + description: Submission deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /submission/{submissionId}/delete: + patch: + summary: Soft delete + description: A soft delete of a submission using a is_deleted flag + operationId: submission-updateIsDeletedFlag + tags: + - Submission + parameters: + - $ref: '#/components/parameters/Path-SubmissionId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - isDeleted + properties: + isDeleted: + type: boolean + description: Set to true to change is deleted tag + example: true + responses: + '200': + description: The is_deleted flag updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /submission/draft: + put: + summary: Create or update a draft submission + description: >- + Creates or updates an intake and set status to Draft + so unfinished/unvalidatd submissions can be saved + operationId: submission-createDraft + tags: + - Submission + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-SaveSubmissionDraft' + responses: + '201': + description: Draft submission created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Response-DraftSubmission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + default: + $ref: '#/components/responses/Error' + /submission/draft/submit: + put: + summary: Submit a draft submission + description: Creates or updates an intake and set status to Submitted + operationId: submission-updateDraft + tags: + - Submission + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request-SaveSubmissionDraft' + responses: + '200': + description: Draft submission updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + default: + $ref: '#/components/responses/Error' + /submission/email: + put: + summary: Send confirmation Email + operationId: emailConfirmation + tags: + - Submission + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmailData' + responses: + '201': + description: Email Confirmation was set + content: + application/json: + schema: + $ref: '#/components/schemas/Response-Email' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /submission/search: + get: + summary: Search submissions + description: Returns a list of submissions based on the given queries + operationId: searchSubmissions + tags: + - Submission + parameters: + - $ref: '#/components/parameters/Query-IncludeUser' + - in: query + name: activityId + required: false + schema: + type: array + description: The Activity IDs to filter on + items: + $ref: '#/components/parameters/Path-ActivityId' + - in: query + name: submissionId + required: false + schema: + type: array + description: The Submission IDs to filter on + items: + $ref: '#/components/parameters/Path-SubmissionId' + - in: query + name: intakeStatus + required: false + schema: + type: array + description: The intake statuses to filter on + items: + $ref: '#/components/parameters/IntakeStatus' + - in: query + name: submissionType + required: false + schema: + type: array + description: The submission types to filter on + items: + $ref: '#/components/parameters/Query-SubmissionType' + responses: + '200': + description: A list of submissions matching the search criteria + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DB-Submission' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /submission/statistics: + get: + summary: Get submission and enquiry statistics + description: Gets a set of submission and enquiry related statistics + operationId: getStatistics + tags: + - Submission + parameters: + - in: query + name: dateFrom + required: false + schema: + type: string + format: date + description: Start date for the statistics + - in: query + name: dateTo + required: false + schema: + type: string + format: date + description: End date for the statistics + - in: query + name: monthYear + required: false + schema: + type: string + description: Month and year for the statistics + - in: query + name: userId + required: false + schema: + type: string + description: User ID to filter statistics + responses: + '200': + description: Submission statistics + content: + application/json: + schema: + $ref: '#/components/schemas/Response-SubmissionStatistics' + "401": + $ref: "#/components/responses/Unauthorized" + '403': + $ref: '#/components/responses/Forbidden' + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: '#/components/responses/Error' + /user: + get: + summary: Search users + description: Gets a list of users based on given parameters + operationId: searchUsers + tags: + - User + responses: + '200': + description: Users + content: + application/json: + schema: + $ref: '#/components/schemas/Response-SearchUsers' + "401": + $ref: "#/components/responses/Unauthorized" +components: + parameters: + IntakeStatus: + in: path + name: intakeStatus + description: The intake status type of a submission + required: true + schema: + type: string + example: Assigned + Path-ActivityId: + in: path + name: activityId + description: ID of an Activity + required: true + schema: + type: string + example: 2DE67F13 + Path-DocumentId: + in: path + name: documentId + description: UUID of a Document + required: true + schema: + type: string + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f701 + Path-EnquiryId: + in: path + name: enquiryId + description: UUID of an Enquiry + required: true + schema: + type: string + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db60921 + Path-NoteId: + in: path + name: noteId + description: UUID of a Note + required: true + schema: + type: string + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f555 + Path-PermitId: + in: path + name: permitId + description: UUID of a Permit + required: true + schema: + type: string + format: uuid + example: da5c5031-0e84-4234-8559-1ba846997482 + Path-SubmissionId: + in: path + name: submissionId + description: UUID of a Submission + required: true + schema: + type: string + format: uuid + example: da5c5031-0e84-4234-7766-1ba846997482 + Query-ActivityId: + in: query + name: activityId + description: ID of an Activity + required: true + schema: + type: string + example: 2DE67F13 + Query-BringForwardState: + in: query + name: bringForwardState + description: The state to filter on for bring forward notes + required: true + schema: + type: string + example: true + Query-IncludeUser: + in: query + name: includeUser + description: Used to retrieve submissions connected to a specific user + required: false + schema: + type: boolean + example: true + Query-SubmissionType: + in: query + name: submissionType + description: The type of submission + required: false + schema: + type: string + example: Guidance + enum: [Guidance, Inapplicable] + schemas: + DB-Contact: + title: DB Contact + type: object + required: + - contactId + properties: + contactId: + type: string + format: uuid + description: The ID of the contact + example: b76469a3-896c-4435-aa83-b8b87db6f701 + userId: + type: string + format: uuid + description: The ID of the user associated with the contact + example: b76469a3-896c-4435-aa83-b8b87db6f701 + firstName: + type: string + description: First name of the contact person + example: John + lastName: + type: string + description: Last name of the contact person + example: Smith + phoneNumber: + type: string + description: Phone number for contact + example: (123) 456-7890 + email: + type: string + format: email + description: Email address for contact + example: john.smith@example.com + contactApplicantRelationship: + type: string + enum: [Owner, Employee, Agent, Consultant, Other] + description: Relationship of the contact person to the project + example: Agent + contactPreference: + type: string + enum: [Phone call, Email, Either] + description: Preferred method of contact + example: Email + DB-Document: + title: DB Document + type: object + required: + - activityId + - documentId + - filename + - mimeType + - length + properties: + activityId: + type: string + description: The ID of the activity associated with the document + example: 2DE67F13 + documentId: + type: string + description: The unique identifier of the document + example: b76469a3-896c-4435-aa83-b8b87db6f701 + filename: + type: string + description: The original filename of the document + example: example.jpeg + mimeType: + type: string + description: The MIME type of the document + example: image/jpeg + length: + type: number + description: The size of the document in bytes + example: 7414 + createdByFullName: + type: string + description: The full name of the user who created the document + example: Smith, John + DB-Enquiry: + title: DB Enquiry + type: object + required: + - enquiryId + - submittedBy + properties: + enquiryId: + type: string + description: UUID for an enquiry + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f701 + activityId: + type: string + description: ID for the activity + example: 2DE67F13 + enquiryType: + type: string + description: The type of enquiry + example: General enquiry + assignedUserId: + type: string + description: User assigned to enquiry + example: Smith, John + submittedAt: + type: string + format: date-time + description: Date time enquiry was submitted + example: '2024-08-27T02:11:45.986Z' + submittedBy: + type: string + description: User who submitted enquiry + example: JSMITH + isRelated: + type: string + description: Is this related to a project + example: 'yes' + relatedActivityId: + type: string + description: Activity ID of related project + example: 699BFA81 + enquiryDescription: + type: string + description: User description for enquiry + example: This is a description for some enquiry + applyForPermitConnect: + type: string + description: If applied to Permit Connect + example: 'yes' + createdAt: + type: string + format: date-time + description: Date time for when note is to be brought forward + example: '2024-08-14T07:00:00.000Z' + createdBy: + type: string + description: UUID for user that created note + format: uuid + example: 68a9a188-4d67-46e3-92a4-b5717435cda8 + user: + $ref: '#/components/schemas/DB-User' + DB-Note: + title: DB Note + type: object + required: + - noteId + - activityId + - isDeleted + - note + - noteType + - title + - createdBy + - createdAt + - updatedBy + - updatedAt + properties: + noteId: + type: string + description: UUID of a note + format: uuid + example: 17e5c858-66c1-49ba-bb2e-2b286525f532 + note: + type: string + description: The note text + example: this is a note about something in the project + activityId: + type: string + description: The ID of the activity the note is associated with + example: 2DE67F13 + noteType: + type: string + description: Type of note + example: Bring forward + title: + type: string + description: Title of the note + example: Some Title + bringForwardDate: + type: string + format: date-time + description: Date time for when note is to be brought forward + example: '2029-08-14T07:00:00.000Z' + bringForwardState: + type: string + description: State of a bring forward tyoe note + example: Unresolved + isDeleted: + type: boolean + description: Deletion flag + example: false + createdAt: + type: string + format: date-time + description: Date time for when note is to be brought forward + example: '2024-08-14T07:00:00.000Z' + createdBy: + type: string + description: UUID for user that created note + format: uuid + example: 68a9a188-4d67-46e3-92a4-b5717435cda8 + updatedAt: + type: string + format: date-time + description: Date time for when note was updated + example: '2024-08-14T07:00:00.000Z' + updatedBy: + type: string + description: UUID for user that updated note + format: uuid + example: 68a9a188-4d67-46e3-92a4-b5717435cda8 + DB-Permit: + title: DB Permit + type: object + required: + - permitId + - activityId + - permitTypeId + - status + - needed + - updatedAt + - updatedBy + properties: + permitId: + type: string + description: UUID for permit + format: uuid + example: 68a9a188-4d67-46e3-92a4-b57174354231 + activityId: + type: string + description: The ID of the activity associated with the permit + example: 2DE67F13 + permitTypeId: + type: number + description: ID for the associated permit type + example: 1 + issuedPermitId: + type: string + description: ID of the issued permit + example: '123' + trackingId: + type: string + description: ID for tracking permit + example: H7kidnhd948594 + authStatus: + type: string + description: Status of authentication for the permit + example: Pending + needed: + type: string + description: Whether permit is needed, or still uner investigation + example: Under Investigation + status: + type: string + description: Status of the permit + example: New + submittedDate: + type: string + description: Date the permit was submitted + format: date-time + example: '2024-08-14T07:00:00.000Z' + adjudicationDate: + type: string + description: Date of the permit's adjudication + format: date-time + example: '2024-08-14T07:00:00.000Z' + statusLastVerified: + type: string + description: Date of the last verified status of permit + format: date-time + example: '2024-08-14T07:00:00.000Z' + updatedAt: + type: string + format: date-time + description: Date time for when permit was updated + example: '2024-08-14T07:00:00.000Z' + updatedBy: + type: string + description: UUID for user that updated permit + format: uuid + example: 68a9a188-4d67-46e3-92a4-b5717435cda8 + permitNotes: + type: array + description: A list of notes associated with the permit (if `includeNotes` is true) + items: + $ref: '#/components/schemas/DB-PermitNote' + DB-PermitNote: + title: DB Permit Note + type: object + required: + - permitNoteId + - permitId + - note + - isDeleted + - createdAt + - createdBy + properties: + permitNoteId: + type: string + description: UUID of the permit note + format: uuid + example: e76469a3-896c-4435-aa83-b8b87db6f701 + permitId: + type: string + description: UUID of the permit associated with the note + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + note: + type: string + description: The text content of the note + example: "This is a note about the permit." + isDeleted: + type: boolean + description: Flag indicating if the note is soft-deleted + example: false + createdAt: + type: string + format: date-time + description: The timestamp of when the note was created + example: '2024-11-15T10:00:00.000Z' + createdBy: + type: string + description: The ID of the user who created the note + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f701 + DB-PermitType: + title: DB Permit Type + type: object + required: + - agency + - businessDomain + - name + - permitTypeId + - sourceSystemAcronym + - trackedInATS + - type + properties: + permitTypeId: + type: number + description: ID for the permit type + example: 1 + acronym: + type: string + description: Acronym for permit type name + example: SAP + agency: + type: string + description: Government agency + example: Water, Land and Resource Stewardship + branch: + type: string + description: Government branch + example: Archaeology + businessDomain: + type: string + description: Government business domain + example: Archaeology + division: + type: string + description: Government division + example: Forest Resiliency and Archaeology + family: + type: string + description: Family associated with permit type + example: Crown Land Tenure + name: + type: string + description: Name of permit type + example: Site Alteration Permit + nameSubtype: + type: string + description: Subtype of a permit type + example: some name subtype + sourceSystem: + type: string + description: Source of the system used for this permit type + example: Archaeology Permit Tracking System + sourceSystemAcronym: + type: string + description: Acronym for source system + example: APTS + trackedInATS: + type: boolean + description: Whether this permit type is tracked in ATS or not + example: false + type: + type: string + description: Type of permit type + example: Alteration + DB-Submission: + type: object + required: + - submissionId + - activityId + - submittedAt + - submittedBy + - addedToATS + - ltsaCompleted + - bcOnlineCompleted + - naturalDisaster + - aaiUpdated + properties: + submissionId: + type: string + description: UUID for the submission + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + activityId: + type: string + description: Activity ID + example: D95F1DE6 + assignedUserId: + type: string + description: User assigned to the submission + example: Smith, John + submittedAt: + type: string + description: Date it was submitted + format: date-time + example: '2024-04-03T00:57:09.070Z' + submittedBy: + type: string + description: Who made the submission + example: JSMITH + locationPIDs: + type: string + description: Location PIDs + example: 006-209-521, 007-209-522 + projectName: + type: string + description: Name of the Project + example: New Project + projectDescription: + type: string + description: A description of the project + example: Project description example. + companyNameRegistered: + type: string + description: Name of the company connected to the project + example: QW URBAN FOOD LTD. + singleFamilyUnits: + type: string + description: Range of units to be for single family + example: 10-49 + hasRentalUnits: + type: string + description: Whether or not the project will have rental units + example: 'Yes' + streetAddress: + type: string + description: Street address of project + example: 2975 Jutland Rd + latitude: + type: number + description: Latitude Cordinate for project + example: 48.440531 + longitude: + type: number + description: Longitude Cordinate for project + example: -123.377677 + queuePriority: + type: number + description: Navigator assigned priority rank + example: 3 + relatedPermits: + type: string + description: Permits related to the project + astNotes: + type: string + description: Notes from AST + astUpdated: + type: boolean + description: Whether updated by AST + example: false + addedToATS: + type: boolean + description: Whether added to AST + example: false + atsClientNumber: + type: string + description: Client number from ATS + ltsaCompleted: + type: boolean + description: Ltsa completed + example: false + bcOnlineCompleted: + type: boolean + description: Comlpeted + example: false + naturalDisaster: + type: string + description: Is it affected by Natural disaster? + example: 'No' + financiallySupported: + type: boolean + description: Is it financially supported? + example: false + financiallySupportedBC: + type: string + description: Is is financially supported by BC Housing? + example: 'No' + financiallySupportedIndigenous: + type: string + description: Is it an Indigenous led project? + example: 'No' + financiallySupportedNonProfit: + type: string + description: Is is financially supported by a Non-Profit + example: 'No' + financiallySupportedHousingCoop: + type: string + description: Is is financially supported by a House Cooperative + example: 'No' + aaiUpdated: + type: boolean + description: Updated by aai + example: false + intakeStatus: + type: string + description: Intake status type + example: Submitted + applicationStatus: + type: string + description: Status of the application + example: New + isDevelopedByCompanyOrOrg: + type: string + description: Is the project being developed by a company or organization + example: 'Yes' + isDevelopedInBC: + type: string + description: Is the project developed in BC? + example: 'Yes' + multiFamilyUnits: + type: string + description: Range of multi-family units in the project + example: 10-49 + otherUnits: + type: string + description: Range of "other" units in the project + example: 10-49 + otherUnitsDescription: + type: string + description: Description of the "other" units + example: detached garages + rentalUnits: + type: string + description: Range of rental units + example: 1-9 + projectLocation: + type: string + description: Project's location + example: Street address + projectLocationDescription: + type: string + description: Description of the location of the project + example: 'no' + locality: + type: string + description: City, town, etc. (Location) of project + example: Maple Ridge + province: + type: string + description: Province (Location) of project + example: BC + hasAppliedProvincialPermits: + type: string + description: Has applied for provincial permits + example: 'Yes' + checkProvincialPermits: + type: string + description: Needs to check for provincial permits + example: 'Yes' + indigenousDescription: + type: string + description: Description for the Indigenous Led + nonProfitDescription: + type: string + description: Description for Non-Profit + housingCoopDescription: + type: string + description: Description for COOP Housing + DB-User: + type: object + properties: + userId: + type: string + description: UUID for the user + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb4464231 + identityId: + type: string + description: UUID for the user + format: uuid + example: 333c6b2f-3845-4628-b638-ca4ac52ea123 + idp: + type: string + description: Account/ Identity source + example: idir + sub: + type: string + description: Identity ID and source + example: 333c6b2f38454628b638ca4ac52ea123@idir + firstName: + type: string + description: First name for user + example: John + fullName: + type: string + description: Full name for user + example: John Smith + lastName: + type: string + description: Last name for user + example: Smith + email: + type: string + description: Email for for user + example: john.smith@email.com + active: + type: boolean + description: Active status of user + example: true + EmailData: + type: object + required: + - bodyType + - body + - from + - subject + - to + properties: + bcc: + type: array + items: + type: string + format: email + description: Array of BCC email addresses + bodyType: + type: string + description: Type of the email body + body: + type: string + description: Content of the email body + cc: + type: array + items: + type: string + format: email + description: Array of CC email addresses + delayTS: + type: number + description: Timestamp for delayed sending + from: + type: string + format: email + description: Sender's email address + priority: + type: string + description: Priority of the email + subject: + type: string + description: Subject of the email + tag: + type: string + description: Tag associated with the email + to: + type: array + items: + type: string + format: email + description: Array of recipient email addresses + Enquiry-Basic: + type: object + properties: + enquiryType: + type: string + description: The type of enquiry + example: General enquiry + isRelated: + type: string + description: Is this related to a project + example: 'yes' + relatedActivityId: + type: string + description: Activity id of related project + example: 699BFA81 + enquiryDescription: + type: string + description: User description for enquiry + example: This is a description for some enquiry + applyForPermitConnect: + type: string + description: If applied to Permit Connect + example: 'yes' + Request-CreateEnquiry: + type: object + required: + - contacts + - basic + properties: + contacts: + type: array + items: + $ref: '#/components/schemas/DB-Contact' + basic: + $ref: '#/components/schemas/Enquiry-Basic' + Request-Document: + type: object + properties: + activityId: + type: string + description: The ID of the activity associated with the document + example: 2DE67F13 + documentId: + type: string + description: The unique identifier of the document + example: b76469a3-896c-4435-aa83-b8b87db6f701 + filename: + type: string + description: The original filename of the document + example: example.jpeg + mimeType: + type: string + description: The MIME type of the document + example: image/jpeg + length: + type: number + description: The size of the document in bytes + example: 7414 + Request-Notes: + title: Request Notes + type: object + required: + - note + - activityId + - noteType + - title + - bringForwardDate + - bringForwardState + - createdAt + properties: + note: + type: string + description: The note text + example: this is a note about something in the project + activityId: + type: string + description: The ID of the activity the note is associated with + example: 2DE67F13 + noteType: + type: string + description: Type of note + example: Bring forward + title: + type: string + description: Title of the note + example: Some Title + bringForwardDate: + type: string + format: date-time + description: date time for when note is to be brought forward + example: '2029-08-14T07:00:00.000Z' + bringForwardState: + type: string + description: state of a bring forward tyoe note + example: Unresolved + createdAt: + type: string + format: date-time + description: date time for when note is to be brought forward + example: '2024-08-14T07:00:00.000Z' + Request-Permit: + title: Request Permit + type: object + required: + - activityId + - needed + - permitType + - permitTypeId + - status + properties: + activityId: + type: string + description: The ID of the activity associated with the permit + example: 2DE67F13 + needed: + type: string + description: Whether permit is needed, or still uner investigation + example: Under Investigation + permitType: + $ref: '#/components/schemas/DB-PermitType' + permitTypeId: + type: number + description: ID for the associated permit type + example: 1 + status: + type: string + description: Status of the permit + example: New + adjudicationDate: + type: string + description: Date of the permit's adjudication + format: date-time + example: '2024-08-14T07:00:00.000Z' + authStatus: + type: string + description: Status of authentication for the permit + example: Pending + issuedPermitId: + type: string + description: ID of the issued permit + example: '123' + statusLastVerified: + type: string + description: Date of the last verified status of permit + format: date-time + example: '2024-08-14T07:00:00.000Z' + submittedDate: + type: string + description: Date the permit was submitted + format: date-time + example: '2024-08-14T07:00:00.000Z' + trackingId: + type: string + description: ID for tracking permit + example: H7kidnhd948594 + Request-PermitNote: + title: Request Permit Note + type: object + required: + - permitId + - note + properties: + permitId: + type: string + description: UUID of the permit associated with the note + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + note: + type: string + description: The text content of the note + example: "This is a note about the permit." + Request-Roadmap: + title: Request Roadmap + type: object + required: + - activityId + - selectedFileIds + - emailData + properties: + activityId: + type: string + description: ID of the activity + example: null + selectedFileIds: + type: array + items: + type: string + format: uuid + description: Array of selected file IDs + emailData: + $ref: '#/components/schemas/EmailData' + Request-SaveSubmissionDraft: + title: Save draft of Submission + type: object + required: + - contacts + - basic + - housing + - location + - permits + properties: + contacts: + type: array + items: + $ref: '#/components/schemas/DB-Contact' + basic: + type: object + properties: + isDevelopedByCompanyOrOrg: + type: boolean + description: >- + Indicates if the project is being developed by a company or + organization + example: true + isDevelopedInBC: + type: boolean + description: >- + Indicates if the project is being developed in British Columbia + (BC) + example: true + companyNameRegistered: + type: string + description: Name of the company associated with the project + example: QW URBAN FOOD LTD. + housing: + type: object + properties: + projectName: + type: string + description: Name of the Project + example: New Project + projectDescription: + type: string + description: A description of the project + example: Project description example. + singleFamilyUnits: + type: string + description: Range of single-family units in the project + example: 10-49 + multiFamilyUnits: + type: string + description: Range of multi-family units in the project + example: 10-49 + otherUnitsDescription: + type: string + description: Description of the "other" units + example: detached garages + maxLength: 255 + otherUnits: + type: string + description: Range of "other" units in the project + example: 10-49 + hasRentalUnits: + type: string + description: Whether or not the project will have rental units + example: 'Yes' + financiallySupportedBC: + type: string + description: Is it financially supported by BC Housing? + example: 'No' + financiallySupportedIndigenous: + type: string + description: Is it an Indigenous-led project? + example: 'No' + financiallySupportedNonProfit: + type: string + description: Is it financially supported by a Non-Profit? + example: 'No' + financiallySupportedHousingCoop: + type: string + description: Is it financially supported by a Housing Cooperative? + example: 'No' + rentalUnits: + type: string + description: Range of rental units + example: 1-9 + indigenousDescription: + type: string + description: Description for the Indigenous-led project + example: Indigenous project details + maxLength: 255 + nonProfitDescription: + type: string + description: Description for Non-Profit supported project + example: Non-Profit project details + maxLength: 255 + housingCoopDescription: + type: string + description: Description for Housing Cooperative supported project + example: Housing Cooperative project details + maxLength: 255 + location: + type: object + properties: + naturalDisaster: + type: string + description: Indicates if the project is affected by a natural disaster + example: 'No' + projectLocation: + type: string + description: Location of the project + example: Street address + projectLocationDescription: + type: string + description: Description of the project’s location + example: Near the downtown area + locationPIDs: + type: string + description: Location PIDs for the project + example: 006-209-521, 007-209-522 + latitude: + type: number + description: Latitude coordinate for the project + example: 48.440531 + longitude: + type: number + description: Longitude coordinate for the project + example: -123.377677 + streetAddress: + type: string + description: Street address of the project + example: 2975 Jutland Rd + locality: + type: string + description: City, town, etc. (location) of the project + example: Maple Ridge + province: + type: string + description: Province (location) of the project + example: BC + permits: + type: object + properties: + hasAppliedProvincialPermits: + type: boolean + description: Indicates whether the project has applied for provincial permits + example: true + checkProvincialPermits: + type: boolean + description: >- + Indicates whether the project needs to check for provincial + permits + example: true + appliedPermits: + type: array + items: + $ref: '#/components/schemas/DB-Permit' + investigatePermits: + type: array + items: + $ref: '#/components/schemas/DB-Permit' + submissionId: + type: string + description: UUID for the submission + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + activityId: + type: string + description: Activity ID + example: D95F1DE6 + submittedAt: + type: string + format: date-time + description: Date it was submitted + example: '2024-04-03T00:57:09.070Z' + submittedBy: + type: string + description: Who made the submission + example: JSMITH + intakeStatus: + type: string + description: Intake status type + example: Submitted + applicationStatus: + type: string + description: Status of the application + example: New + submissionType: + type: string + description: The type of submission + example: Guidance + Request-SubmissionSearch: + title: Request Submission Search + type: object + required: + - activityId + - intakeStatus + - includeUser + - submissionId + - submissionType + properties: + activityId: + type: string + description: ID of the activity + example: 2DE67F13 + intakeStatus: + type: string + description: Intake status type + example: Assigned + includeUser: + type: string + description: If set it retrieves only this user's submissions + example: Smith, John + submissionId: + type: string + description: UUID of submission + format: Uuid + example: ac246e31-c807-496c-bc93-cd8bc2f1b2b4 + submissionType: + type: string + description: Type of the submission + example: Guidance + Request-SubmissionUpdate: + title: Request to Update Submission + type: object + required: + - submissionId + - activityId + - queuePriority + - submissionType + - submittedAt + - projectName + - hasRentalUnits + - financiallySupportedBC + - financiallySupportedIndigenous + - financiallySupportedNonProfit + - financiallySupportedHousingCoop + - naturalDisaster + - addedToATS + - ltsaCompleted + - bcOnlineCompleted + - aaiUpdated + properties: + submissionId: + type: string + description: UUID for the submission + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + activityId: + type: string + description: Activity ID + example: D95F1DE6 + queuePriority: + type: number + description: Navigator assigned priority rank + format: integer + example: 2 + default: 3 + submissionType: + type: string + description: The type of submission + example: Guidance + submittedAt: + type: string + description: Date it was submitted + format: date-time + example: '2024-04-03T00:57:09.070Z' + relatedEnquiries: + type: string + description: Related enquiries + example: Some enquiry + companyNameRegistered: + type: string + description: Name of the company connected to the project + example: QW URBAN FOOD LTD. + isDevelopedInBC: + type: string + description: Is the project developed in BC? + example: 'Yes' + projectName: + type: string + description: Name of the Project + example: New Project + projectDescription: + type: string + description: Description of the project + example: Project description example. + nullable: true + singleFamilyUnits: + type: string + description: Range of single-family units in the project + example: 10-49 + multiFamilyUnits: + type: string + description: Range of multi-family units in the project + example: 10-49 + otherUnitsDescription: + type: string + description: Description of the "other" units + example: detached garages + maxLength: 255 + otherUnits: + type: string + description: Range of "other" units in the project + example: 10-49 + hasRentalUnits: + type: string + description: Whether or not the project will have rental units + example: 'Yes' + rentalUnits: + type: string + description: Range of rental units + example: 1-9 + nullable: true + financiallySupportedBC: + type: string + description: Is it financially supported by BC Housing? + example: 'No' + financiallySupportedIndigenous: + type: string + description: Is it an Indigenous-led project? + example: 'No' + indigenousDescription: + type: string + description: Description for the Indigenous-led project + example: Indigenous project details + maxLength: 255 + financiallySupportedNonProfit: + type: string + description: Is it financially supported by a Non-Profit? + example: 'No' + nonProfitDescription: + type: string + description: Description for Non-Profit supported project + example: Non-Profit project details + maxLength: 255 + financiallySupportedHousingCoop: + type: string + description: Is it financially supported by a Housing Cooperative? + example: 'No' + housingCoopDescription: + type: string + description: Description for Housing Cooperative supported project + example: Housing Cooperative project details + maxLength: 255 + streetAddress: + type: string + description: Street address of project + example: 2975 Jutland Rd + maxLength: 255 + locality: + type: string + description: City, town, etc. (Location) of project + example: Victoria + maxLength: 255 + province: + type: string + description: Province (Location) of project + example: BC + maxLength: 255 + locationPIDs: + type: string + description: Location PIDs + example: 006-209-521, 007-209-522 + maxLength: 255 + latitude: + type: number + description: Latitude coordinate for the project + example: 48.440531 + longitude: + type: number + description: Longitude coordinate for the project + example: -123.377677 + geomarkUrl: + type: string + description: URL for geomark + example: http://geomark.com + maxLength: 255 + naturalDisaster: + type: string + description: Is it affected by a natural disaster? + example: 'No' + projectLocationDescription: + type: string + description: Description of the location of the project + example: Near the river + maxLength: 4000 + addedToATS: + type: boolean + description: Whether added to ATS + example: false + atsClientNumber: + type: string + description: Client number from ATS + example: ATS-001 + maxLength: 255 + ltsaCompleted: + type: boolean + description: Ltsa completed + example: false + bcOnlineCompleted: + type: boolean + description: BC Online completed + example: false + aaiUpdated: + type: boolean + description: Updated by AAI + example: false + astNotes: + type: string + description: Notes from AST + example: AST notes here + maxLength: 4000 + intakeStatus: + type: string + description: Intake status type + example: Submitted + assignedUserId: + type: string + description: User ID assigned to the submission + format: uuid + example: 123e4567-e89b-12d3-a456-426614174000 + applicationStatus: + type: string + description: Status of the application + example: New + waitingOn: + type: string + description: Any dependencies or things waiting on + example: Waiting on review + maxLength: 255 + Response-BadRequest: + title: Response Bad Request + type: object + allOf: + - $ref: '#/components/schemas/Response-Problem' + - type: object + properties: + status: + example: 400 + title: + example: Bad Request + type: + example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 + Response-BringForwardNotes: + type: object + required: + - activityId + - noteId + - title + - projectName + - createdByFullName + - bringForwardDate + properties: + activityId: + type: string + description: The ID of the activity the note is associated with + example: 2DE67F13 + noteId: + type: string + description: UUID of a note + format: uuid + example: 17e5c858-66c1-49ba-bb2e-2b286525f532 + submissionId: + type: string + description: UUID of a submission + format: uuid + example: 17e5c858-66c1-49ba-bb2e-2b2865635421 + enquiryId: + type: string + description: UUID for an enquiry + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f701 + title: + type: string + description: Title of the note + example: Some Title + createdByFullName: + type: string + description: The full name of the user who created the note + example: Smith, John + bringForwardDate: + type: string + format: date-time + description: Date time for when note is to be brought forward + example: '2029-08-14T07:00:00.000Z' + projectName: + type: string + description: Name of the project the note is attached to + example: The Project + Response-DeletedEnquiry: + type: object + allOf: + - type: object + required: + - is_deleted + properties: + is_deleted: + type: boolean + example: true + - $ref: '#/components/schemas/DB-Enquiry' + Response-CreateEnquiry: + type: object + required: + - activityId + - enquiryId + properties: + activityId: + type: string + enquiryId: + type: string + Response-DraftSubmission: + title: Response Draft Submission + type: object + required: + - activityId + - submissionId + properties: + submissionId: + type: string + description: UUID for the submission + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + activityId: + type: string + description: Activity Id + example: D95F1DE6 + Response-Email: + type: object + properties: + messages: + type: array + items: + type: object + properties: + msgId: + type: string + description: UUID for email message + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f701 + to: + type: array + items: + type: string + description: The To email addresses + txId: + type: string + format: uuid + example: b76469a3-896c-4435-aa83-b8b87db6f701 + Response-Error: + title: Response Error + type: object + allOf: + - $ref: '#/components/schemas/Response-Problem' + - type: object + properties: + status: + example: 500 + title: + example: Internal Server Error + type: + example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 + Response-Forbidden: + title: Response Forbidden + type: object + allOf: + - $ref: '#/components/schemas/Response-Problem' + - type: object + properties: + type: + example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403 + title: + example: Forbidden + status: + example: 403 + detail: + example: User lacks permission to complete this action + Response-NotFound: + title: Response Not Found + type: object + allOf: + - $ref: '#/components/schemas/Response-Problem' + - type: object + properties: + status: + example: 404 + title: + example: Not Found + type: + example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 + Response-Problem: + title: Response Problem + type: object + required: + - type + - title + - status + properties: + type: + type: string + description: What type of problem, link to explanation of problem + title: + type: string + description: Title of problem, generally the HTTP Status Code description + status: + type: string + description: The HTTP Status code + detail: + type: string + description: >- + A short, human-readable explanation specific to this occurrence of + the problem + instance: + type: string + description: >- + A URI reference that identifies the specific occurrence of the + problem + Response-SearchUsers: + title: Response for searching users + type: array + items: + allOf: + - $ref: '#/components/schemas/DB-User' + - type: object + properties: + groups: + type: array + description: List of groups (associated roles) for a user + items: + type: string + example: + - NAVIGATOR + Response-SubmissionCreate: + title: Response for Create Submission + type: object + required: + - activityId + - submissionId + properties: + activityId: + type: string + description: Activity ID + example: D95F1DE6 + submissionId: + type: string + description: UUID for the submission + format: uuid + example: d95f1de6-698b-4d9d-b938-487eb446ace8 + Response-SubmissionStatistics: + type: object + properties: + total_submissions: + type: number + example: 112 + total_submissions_between: + type: number + example: 0 + total_submissions_monthyear: + type: number + example: 0 + total_submissions_assignedto: + type: number + example: 0 + intake_submitted: + type: number + example: 80 + intake_assigned: + type: number + example: 6 + intake_completed: + type: number + example: 1 + state_new: + type: number + example: 112 + state_inprogress: + type: number + example: 0 + state_delayed: + type: number + example: 0 + state_completed: + type: number + example: 0 + waiting_on: + type: number + example: 0 + queue_1: + type: number + example: 7 + queue_2: + type: number + example: 4 + queue_3: + type: number + example: 50 + escalation: + type: number + example: 0 + general_enquiry: + type: number + example: 0 + guidance: + type: number + example: 50 + inapplicable: + type: number + example: 0 + status_request: + type: number + example: 0 + Response-Unauthorized: + title: Response Unauthorized + type: object + allOf: + - $ref: '#/components/schemas/Response-Problem' + - type: object + properties: + type: + example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 + title: + example: Unauthorized + status: + example: 401 + detail: + example: Invalid authorization credentials + Response-ValidationError: + title: Response Validation Error + type: object + allOf: + - $ref: "#/components/schemas/Response-Problem" + - type: object + required: + - errors + properties: + errors: + type: array + items: + type: object + required: + - message + properties: + value: + type: object + description: Contents of the field that was in error. + example: {} + message: + type: string + description: The error message for the field. + example: Invalid value `encoding`. + status: + example: 422 + title: + example: Unprocessable Entity + type: + example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422 + responses: + BadRequest: + description: Bad Request (Request is missing content or malformed) + content: + application/json: + schema: + $ref: '#/components/schemas/Response-BadRequest' + Error: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Response-Error' + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Response-Forbidden' + NotFound: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Response-NotFound' + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Response-Unauthorized' + UnprocessableEntity: + description: Unprocessable Content (Generally validation error(s)) + content: + application/json: + schema: + $ref: "#/components/schemas/Response-ValidationError" + securitySchemes: + BearerAuth: + type: http + description: Bearer token auth using an OIDC issued JWT token + scheme: bearer + bearerFormat: JWT + OpenID: + type: openIdConnect + description: OpenID Connect endpoint for acquiring JWT tokens + openIdConnectUrl: >- + https://logonproxy.gov.bc.ca/auth/realms/your-realm-name/.well-known/openid-configuration diff --git a/app/src/middleware/authentication.ts b/app/src/middleware/authentication.ts index 3b3047f8..ceddea06 100644 --- a/app/src/middleware/authentication.ts +++ b/app/src/middleware/authentication.ts @@ -9,6 +9,8 @@ import { AuthType, Initiative } from '../utils/enums/application'; import type { NextFunction, Request, Response } from 'express'; import type { CurrentContext } from '../types'; +// TODO: Implement a 401 for unrecognized users. + /** * @function _spkiWrapper * Wraps an SPKI key with PEM header and footer diff --git a/app/src/middleware/authorization.ts b/app/src/middleware/authorization.ts index 0f1bff65..1a2becc1 100644 --- a/app/src/middleware/authorization.ts +++ b/app/src/middleware/authorization.ts @@ -4,6 +4,7 @@ import { NIL } from 'uuid'; import { documentService, + draftService, enquiryService, noteService, permitService, @@ -108,6 +109,7 @@ export const hasAuthorization = (resource: string, action: string) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const paramMap = new Map any>([ ['documentId', documentService.getDocument], + ['draftId', draftService.getDraft], ['enquiryId', enquiryService.getEnquiry], ['noteId', noteService.getNote], ['permitId', permitService.getPermit], diff --git a/app/src/routes/v1/activity.ts b/app/src/routes/v1/activity.ts deleted file mode 100644 index a72f0fcc..00000000 --- a/app/src/routes/v1/activity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import express from 'express'; -import { activityController } from '../../controllers'; -import { requireSomeAuth } from '../../middleware/requireSomeAuth'; -import { requireSomeGroup } from '../../middleware/requireSomeGroup'; - -import type { NextFunction, Request, Response } from 'express'; - -const router = express.Router(); -router.use(requireSomeAuth); -router.use(requireSomeGroup); - -//** Validates an Activity Id */ -router.get('/validate/:activityId', (req: Request<{ activityId: string }>, res: Response, next: NextFunction): void => { - activityController.validateActivityId(req, res, next); -}); - -export default router; diff --git a/app/src/routes/v1/ats.ts b/app/src/routes/v1/ats.ts new file mode 100644 index 00000000..43ea5788 --- /dev/null +++ b/app/src/routes/v1/ats.ts @@ -0,0 +1,35 @@ +import express from 'express'; + +import { atsController } from '../../controllers'; +import { hasAuthorization } from '../../middleware/authorization'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { requireSomeGroup } from '../../middleware/requireSomeGroup'; +import { Action, Resource } from '../../utils/enums/application'; +import { atsValidator } from '../../validators'; + +import type { NextFunction, Request, Response } from 'express'; +import type { ATSClientResource, ATSUserSearchParameters } from '../../types'; + +const router = express.Router(); +router.use(requireSomeAuth); +router.use(requireSomeGroup); + +router.get( + '/clients', + hasAuthorization(Resource.ATS, Action.READ), + (req: Request, res: Response, next: NextFunction): void => { + atsController.searchATSUsers(req, res, next); + } +); + +/** Creates a client in ATS */ +router.post( + '/client', + hasAuthorization(Resource.ATS, Action.CREATE), + atsValidator.createATSClient, + (req: Request, res: Response, next: NextFunction): void => { + atsController.createATSClient(req, res, next); + } +); + +export default router; diff --git a/app/src/routes/v1/docs.ts b/app/src/routes/v1/docs.ts new file mode 100644 index 00000000..a2e93349 --- /dev/null +++ b/app/src/routes/v1/docs.ts @@ -0,0 +1,55 @@ +import { Router, Request, Response } from 'express'; +import { readFileSync } from 'fs'; +import helmet from 'helmet'; +import yaml from 'js-yaml'; +import { join } from 'path'; +import docs from '../../docs/docs'; + +const router = Router(); + +interface OpenAPISpec { + servers: { url: string }[]; + components: { + securitySchemes: { + OpenID: { + openIdConnectUrl?: string; + }; + }; + }; +} + +/** Gets the OpenAPI specification */ +function getSpec(): OpenAPISpec | undefined { + const rawSpec = readFileSync(join(__dirname, '../../docs/v1.api-spec.yaml'), 'utf8'); + const spec = yaml.load(rawSpec) as OpenAPISpec; + spec.servers[0].url = '/api/v1'; + return spec; +} + +router.use( + helmet({ + contentSecurityPolicy: { + directives: { + 'img-src': ['data:', 'https://cdn.redoc.ly'], + 'script-src': ['blob:', 'https://cdn.redoc.ly'] + } + } + }) +); + +/** OpenAPI Docs */ +router.get('/', (_req: Request, res: Response) => { + res.send(docs.getDocHTML('v1')); +}); + +/** OpenAPI YAML Spec */ +router.get('/api-spec.yaml', (_req: Request, res: Response) => { + res.status(200).type('application/yaml').send(yaml.dump(getSpec())); +}); + +/** OpenAPI JSON Spec */ +router.get('/api-spec.json', (_req: Request, res: Response) => { + res.status(200).json(getSpec()); +}); + +export default router; diff --git a/app/src/routes/v1/enquiry.ts b/app/src/routes/v1/enquiry.ts index 78f20648..e1a4714a 100644 --- a/app/src/routes/v1/enquiry.ts +++ b/app/src/routes/v1/enquiry.ts @@ -8,25 +8,12 @@ import { Action, Resource } from '../../utils/enums/application'; import { enquiryValidator } from '../../validators'; import type { NextFunction, Request, Response } from 'express'; -import type { Enquiry, EnquiryIntake, Middleware } from '../../types'; +import type { Enquiry, EnquiryIntake } from '../../types'; const router = express.Router(); router.use(requireSomeAuth); router.use(requireSomeGroup); -const decideValidation = (validator: Middleware) => { - return (req: Request, _res: Response, next: NextFunction) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const body: any = req.body; - - if (body.submit) { - return validator(req, _res, next); - } else { - return next(); - } - }; -}; - /** Gets a list of enquiries */ router.get( '/', @@ -61,24 +48,13 @@ router.delete( } ); -/** Creates an enquiry with Draft status */ +/** Creates an enquiry and set status to Submitted */ router.put( - '/draft', + '/', hasAuthorization(Resource.ENQUIRY, Action.CREATE), - decideValidation(enquiryValidator.createDraft), - (req: Request, res: Response, next: NextFunction): void => { - enquiryController.createDraft(req, res, next); - } -); - -/** Updates an enquiry with Draft status */ -router.put( - '/draft/:enquiryId', - hasAuthorization(Resource.ENQUIRY, Action.UPDATE), - hasAccess('enquiryId'), - decideValidation(enquiryValidator.updateDraft), + enquiryValidator.createEnquiry, (req: Request, res: Response, next: NextFunction): void => { - enquiryController.updateDraft(req, res, next); + enquiryController.createEnquiry(req, res, next); } ); diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index eb4e1a2d..fe02c3b7 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -3,7 +3,8 @@ import express from 'express'; import { currentContext } from '../../middleware/authentication'; import accessRequest from './accessRequest'; -import activity from './activity'; +import ats from './ats'; +import docs from './docs'; import document from './document'; import enquiry from './enquiry'; import note from './note'; @@ -24,7 +25,8 @@ router.get('/', (_req, res) => { res.status(200).json({ endpoints: [ '/accessRequest', - '/activity', + '/ats', + '/docs', '/document', '/enquiry', '/note', @@ -39,7 +41,8 @@ router.get('/', (_req, res) => { }); router.use('/accessRequest', accessRequest); -router.use('/activity', activity); +router.use('/docs', docs); +router.use('/ats', ats); router.use('/document', document); router.use('/enquiry', enquiry); router.use('/note', note); diff --git a/app/src/routes/v1/permit.ts b/app/src/routes/v1/permit.ts index e3f4eec5..a265c42e 100644 --- a/app/src/routes/v1/permit.ts +++ b/app/src/routes/v1/permit.ts @@ -1,5 +1,6 @@ import express from 'express'; +import permitNote from './permitNote'; import { permitController } from '../../controllers'; import { hasAccess, hasAuthorization } from '../../middleware/authorization'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; @@ -8,18 +9,19 @@ import { Action, Resource } from '../../utils/enums/application'; import { permitValidator } from '../../validators'; import type { NextFunction, Request, Response } from 'express'; -import type { Permit } from '../../types'; +import type { ListPermitsOptions, Permit } from '../../types'; const router = express.Router(); router.use(requireSomeAuth); router.use(requireSomeGroup); +router.use('/note', permitNote); // Permit list endpoint router.get( '/', hasAuthorization(Resource.PERMIT, Action.READ), permitValidator.listPermits, - (req: Request, res: Response, next: NextFunction): void => { + (req: Request>, res: Response, next: NextFunction): void => { permitController.listPermits(req, res, next); } ); diff --git a/app/src/routes/v1/permitNote.ts b/app/src/routes/v1/permitNote.ts new file mode 100644 index 00000000..055da6a8 --- /dev/null +++ b/app/src/routes/v1/permitNote.ts @@ -0,0 +1,59 @@ +// @ts-expect-error api-problem lacks a defined interface; code still works fine +import Problem from 'api-problem'; +import express from 'express'; + +import { permitNoteController } from '../../controllers'; +import { hasAccess, hasAuthorization } from '../../middleware/authorization'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { requireSomeGroup } from '../../middleware/requireSomeGroup'; +import { Action, Resource } from '../../utils/enums/application'; +import { permitNoteValidator } from '../../validators'; + +import type { NextFunction, Request, Response } from 'express'; +import type { PermitNote } from '../../types'; + +const router = express.Router(); +router.use(requireSomeAuth); +router.use(requireSomeGroup); + +// Permit note create endpoint +router.put( + '/', + hasAuthorization(Resource.PERMIT, Action.CREATE), + permitNoteValidator.createPermitNote, + (req: Request, res: Response, next: NextFunction): void => { + permitNoteController.createPermitNote(req, res, next); + } +); + +// Permit note update endpoint +// TODO implement update +router.put( + '/:permitId', + hasAuthorization(Resource.PERMIT, Action.UPDATE), + hasAccess('permitId'), + // permitNoteValidator.updatePermitNote, + (req: Request): void => { + throw new Problem(501, { + detail: 'Not implemented.', + instance: req.originalUrl + }); + } +); + +// Permit note delete endpoint +// TODO implement soft delete +router.delete( + '/:permitId', + hasAuthorization(Resource.PERMIT, Action.DELETE), + hasAccess('permitId'), + // permitValidator.deletePermitNote, + (req: Request<{ permitId: string }>): void => { + throw new Problem(501, { + detail: 'Not implemented.', + instance: req.originalUrl + }); + } +); + +export default router; diff --git a/app/src/routes/v1/submission.ts b/app/src/routes/v1/submission.ts index 5e84b9a2..3a3ab946 100644 --- a/app/src/routes/v1/submission.ts +++ b/app/src/routes/v1/submission.ts @@ -8,7 +8,14 @@ import { Action, Resource } from '../../utils/enums/application'; import { submissionValidator } from '../../validators'; import type { NextFunction, Request, Response } from 'express'; -import type { Email, StatisticsFilters, Submission, SubmissionIntake, SubmissionSearchParameters } from '../../types'; +import type { + Draft, + Email, + StatisticsFilters, + Submission, + SubmissionIntake, + SubmissionSearchParameters +} from '../../types'; const router = express.Router(); router.use(requireSomeAuth); @@ -52,28 +59,46 @@ router.get( } ); -/** Creates a submission with Draft status */ +/** Gets a list of submission drafts */ +router.get( + '/draft/:draftId', + hasAuthorization(Resource.SUBMISSION, Action.READ), + (req: Request<{ draftId: string }>, res: Response, next: NextFunction): void => { + submissionController.getDraft(req, res, next); + } +); + +/** Gets a list of submission drafts */ +router.get( + '/draft', + hasAuthorization(Resource.SUBMISSION, Action.READ), + (req: Request, res: Response, next: NextFunction): void => { + submissionController.getDrafts(req, res, next); + } +); + +/** Creates or updates an intake and set status to Draft */ router.put( '/draft', hasAuthorization(Resource.SUBMISSION, Action.CREATE), - (req: Request, res: Response, next: NextFunction): void => { - submissionController.createDraft(req, res, next); + (req: Request, res: Response, next: NextFunction): void => { + submissionController.updateDraft(req, res, next); } ); -/** Updates a submission with Draft status */ +/** Creates or updates an intake and set status to Submitted */ router.put( - '/draft/:submissionId', - hasAuthorization(Resource.SUBMISSION, Action.UPDATE), - hasAccess('submissionId'), + '/draft/submit', + hasAuthorization(Resource.SUBMISSION, Action.CREATE), + submissionValidator.createSubmission, (req: Request, res: Response, next: NextFunction): void => { - submissionController.updateDraft(req, res, next); + submissionController.submitDraft(req, res, next); } ); // Send an email with the confirmation of submission router.put( - '/emailConfirmation', + '/email', hasAuthorization(Resource.SUBMISSION, Action.CREATE), submissionValidator.emailConfirmation, (req: Request, res: Response, next: NextFunction): void => { @@ -81,7 +106,7 @@ router.put( } ); -/** Creates a submission */ +/** Creates a blank submission */ router.put( '/', hasAuthorization(Resource.SUBMISSION, Action.CREATE), @@ -102,6 +127,17 @@ router.delete( } ); +/** Hard deletes a submission draft */ +router.delete( + '/draft/:draftId', + hasAuthorization(Resource.SUBMISSION, Action.DELETE), + hasAccess('draftId'), + submissionValidator.deleteDraft, + (req: Request<{ draftId: string }>, res: Response, next: NextFunction): void => { + submissionController.deleteDraft(req, res, next); + } +); + /** Search all submissions */ router.get( '/search', diff --git a/app/src/routes/v1/yars.ts b/app/src/routes/v1/yars.ts index a6951043..f674f43a 100644 --- a/app/src/routes/v1/yars.ts +++ b/app/src/routes/v1/yars.ts @@ -3,6 +3,7 @@ import express from 'express'; import { yarsController } from '../../controllers'; import { requireSomeAuth } from '../../middleware/requireSomeAuth'; import { requireSomeGroup } from '../../middleware/requireSomeGroup'; +import { GroupName } from '../../utils/enums/application'; import type { NextFunction, Request, Response } from 'express'; @@ -18,4 +19,11 @@ router.get('/permissions', (req: Request, res: Response, next: NextFunction): vo yarsController.getPermissions(req, res, next); }); +router.delete( + '/subject/group', + (req: Request, res: Response, next: NextFunction): void => { + yarsController.deleteSubjectGroup(req, res, next); + } +); + export default router; diff --git a/app/src/services/accessRequest.ts b/app/src/services/accessRequest.ts index fb49b8d2..0f3f76f2 100644 --- a/app/src/services/accessRequest.ts +++ b/app/src/services/accessRequest.ts @@ -35,20 +35,6 @@ const service = { return access_request.fromPrismaModel(accessRequestResponse); }, - /** - * @function deleteAccessRequests - * Deletes the access request - * @returns {Promise} The result of running the delete operation - */ - deleteAccessRequest: async (accessRequestId: string) => { - const response = await prisma.access_request.delete({ - where: { - access_request_id: accessRequestId - } - }); - return access_request.fromPrismaModel(response); - }, - /** * @function getAccessRequest * Get an access request @@ -72,6 +58,23 @@ const service = { getAccessRequests: async () => { const response = await prisma.access_request.findMany(); return response.map((x) => access_request.fromPrismaModel(x)); + }, + + /** + * @function updateAccessRequest + * Updates a specific enquiry + * @param {Enquiry} data Enquiry to update + * @returns {Promise} The result of running the update operation + */ + updateAccessRequest: async (data: AccessRequest) => { + const result = await prisma.access_request.update({ + data: { ...access_request.toPrismaModel(data), updated_at: data.updatedAt, updated_by: data.updatedBy }, + where: { + access_request_id: data.accessRequestId + } + }); + + return access_request.fromPrismaModel(result); } }; diff --git a/app/src/services/activity.ts b/app/src/services/activity.ts index f19cf096..8937c208 100644 --- a/app/src/services/activity.ts +++ b/app/src/services/activity.ts @@ -61,7 +61,7 @@ const service = { } }); - return activity.fromPrismaModel(response); + return response ? activity.fromPrismaModel(response) : null; } }; diff --git a/app/src/services/ats.ts b/app/src/services/ats.ts new file mode 100644 index 00000000..54c4dd52 --- /dev/null +++ b/app/src/services/ats.ts @@ -0,0 +1,103 @@ +import axios from 'axios'; +import config from 'config'; + +import type { AxiosInstance } from 'axios'; +import type { ATSClientResource, ATSUserSearchParameters } from '../types'; + +/** + * @function getToken + * Gets Auth token using ATS client credentials + * @returns + */ +async function getToken() { + const response = await axios({ + method: 'GET', + url: config.get('server.ats.tokenUrl'), + auth: { + username: config.get('server.ats.clientId') as string, + password: config.get('server.ats.clientSecret') as string + }, + headers: { + 'Content-type': 'application/x-www-form-urlencoded' + }, + withCredentials: true + }); + + return response.data.access_token; +} + +/** + * @function atsAxios + * Returns an Axios instance with Authorization header + * @param {AxiosRequestConfig} options Axios request config options + * @returns {AxiosInstance} An axios instance + */ +function atsAxios(): AxiosInstance { + // Create axios instance + const atsAxios = axios.create({ + baseURL: config.get('server.ats.apiPath'), + timeout: 10000 + }); + // Add bearer token + atsAxios.interceptors.request.use(async (config) => { + const token = await getToken(); + const auth = token ? `Bearer ${token}` : ''; + config.headers['Authorization'] = auth; + return config; + }); + return atsAxios; +} + +const service = { + /** + * @function searchATSUsers + * Searches for ATS users + * @param {ATSUserSearchParameters} data The search parameters + * @returns {Promise} The result of calling the search api + */ + searchATSUsers: async (params?: ATSUserSearchParameters) => { + try { + const { data, status } = await atsAxios().get('/clients', { params: params }); + return { data: data, status }; + } catch (e) { + if (axios.isAxiosError(e)) { + return { + data: e.response?.data.message, + status: e.response ? e.response.status : 500 + }; + } else { + return { + data: 'Error', + status: 500 + }; + } + } + }, + + /** + * @function createATSClient + * Creates a client in ATS + * @param {ATSClientResource} data The client data + * @returns {Promise} The result of calling the post api + */ + createATSClient: async (atsClient: ATSClientResource) => { + try { + const { data, status } = await atsAxios().post('/clients', atsClient); + return { data: data, status }; + } catch (e: unknown) { + if (axios.isAxiosError(e)) { + return { + data: e.response?.data.message, + status: e.response ? e.response.status : 500 + }; + } else { + return { + data: 'Error', + status: 500 + }; + } + } + } +}; + +export default service; diff --git a/app/src/services/contact.ts b/app/src/services/contact.ts new file mode 100644 index 00000000..d974ff53 --- /dev/null +++ b/app/src/services/contact.ts @@ -0,0 +1,48 @@ +import { v4 as uuidv4 } from 'uuid'; + +import prisma from '../db/dataConnection'; +import { contact } from '../db/models'; +import { generateCreateStamps } from '../db/utils/utils'; +import { Contact, CurrentContext } from '../types'; + +const service = { + /** + * @function upsertContacts + * Creates or updates the given contacts + * Generates IDs and timestamps automatically + * @returns {Promise} The result of running the transaction + */ + upsertContacts: async (activityId: string, data: Array, currentContext: CurrentContext) => { + return await prisma.$transaction(async (trx) => { + await Promise.all( + data.map(async (x: Contact) => { + if (!x.contactId) { + const response = await trx.contact.create({ + data: contact.toPrismaModel({ + ...x, + contactId: uuidv4(), + ...generateCreateStamps(currentContext) + }) + }); + + await trx.activity_contact.create({ + data: { + activity_id: activityId, + contact_id: response.contact_id + } + }); + } else { + await trx.contact.update({ + data: contact.toPrismaModel({ ...x, ...generateCreateStamps(currentContext) }), + where: { + contact_id: x.contactId + } + }); + } + }) + ); + }); + } +}; + +export default service; diff --git a/app/src/services/draft.ts b/app/src/services/draft.ts new file mode 100644 index 00000000..60440480 --- /dev/null +++ b/app/src/services/draft.ts @@ -0,0 +1,91 @@ +import prisma from '../db/dataConnection'; +import { draft } from '../db/models'; + +import type { Draft } from '../types'; +import { DraftCode } from '../utils/enums/housing'; + +const service = { + /** + * @function createDraft + * Create a draft + * @param {Draft} data Draft data + * @returns {Promise} The result of running the create operation + */ + createDraft: async (data: Draft) => { + const result = await prisma.draft.create({ + data: { + draft_id: data.draftId, + activity_id: data.activityId, + draft_code: data.draftCode, + data: data.data, + created_at: data.createdAt, + created_by: data.createdBy + } + }); + + return draft.fromPrismaModel(result); + }, + + /** + * @function deleteDraft + * Deletes the draft + * @param {string} draftId Draft ID + * @returns {Promise} The result of running the delete operation + */ + deleteDraft: async (draftId: string) => { + const result = await prisma.draft.delete({ + where: { + draft_id: draftId + } + }); + + return draft.fromPrismaModel(result); + }, + + /** + * @function getDraft + * Gets a specific draft from the PCNS database + * @param {string} draftId Draft ID + * @returns {Promise | null>} The result of running the findFirst operation + */ + getDraft: async (draftId: string) => { + const result = await prisma.draft.findFirst({ + where: { + draft_id: draftId + } + }); + + return result ? draft.fromPrismaModel(result) : null; + }, + + /** + * @function getDrafts + * Gets a list of drafts + * @param {DraftCode} draftCode Optional draft code to filter on + * @returns {Promise[]>} The result of running the findMany operation + */ + getDrafts: async (draftCode?: DraftCode) => { + const result = await prisma.draft.findMany({ where: { draft_code: draftCode } }); + + return result.map((x) => draft.fromPrismaModel(x)); + }, + + /** + * @function updateDraft + * Updates a specific draft + * @param {Draft} data Draft data + * @returns {Promise} The result of running the update operation + */ + updateDraft: async (data: Draft) => { + const result = await prisma.draft.update({ + data: { data: data.data, updated_at: data?.updatedAt, updated_by: data?.updatedBy }, + where: { + draft_id: data.draftId + } + }); + + return draft.fromPrismaModel(result); + } +}; + +export default service; diff --git a/app/src/services/enquiry.ts b/app/src/services/enquiry.ts index 35139b6a..2df82f06 100644 --- a/app/src/services/enquiry.ts +++ b/app/src/services/enquiry.ts @@ -13,10 +13,21 @@ const service = { */ createEnquiry: async (data: Partial) => { const response = await prisma.enquiry.create({ - data: { ...enquiry.toPrismaModel(data as Enquiry), created_at: data.createdAt, created_by: data.createdBy } + data: { ...enquiry.toPrismaModel(data as Enquiry), created_at: data.createdAt, created_by: data.createdBy }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } + } }); - return enquiry.fromPrismaModel(response); + return enquiry.fromPrismaModelWithContact(response); }, /** @@ -61,6 +72,15 @@ const service = { } }, include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + }, user: true } }); @@ -82,10 +102,21 @@ const service = { const result = await prisma.enquiry.findFirst({ where: { enquiry_id: enquiryId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); - return result ? enquiry.fromPrismaModel(result) : null; + return result ? enquiry.fromPrismaModelWithContact(result) : null; } catch (e: unknown) { throw e; } @@ -103,7 +134,18 @@ const service = { searchEnquiries: async (params: EnquirySearchParameters) => { try { const result = await prisma.enquiry.findMany({ - include: { user: params.includeUser }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + }, + user: params.includeUser + }, where: { AND: [ { @@ -119,7 +161,11 @@ const service = { } }); - return result.map((x) => enquiry.fromPrismaModel(x)); + const enquiries = params.includeUser + ? result.map((x) => enquiry.fromPrismaModelWithUser(x)) + : result.map((x) => enquiry.fromPrismaModelWithContact(x)); + + return enquiries; } catch (e: unknown) { throw e; } @@ -160,10 +206,21 @@ const service = { data: { ...enquiry.toPrismaModel(data), updated_at: data.updatedAt, updated_by: data.updatedBy }, where: { enquiry_id: data.enquiryId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); - return enquiry.fromPrismaModel(result); + return enquiry.fromPrismaModelWithContact(result); } catch (e: unknown) { throw e; } @@ -180,6 +237,17 @@ const service = { const deleteEnquiry = await prisma.enquiry.findUnique({ where: { enquiry_id: enquiryId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); if (deleteEnquiry) { @@ -189,7 +257,7 @@ const service = { activity_id: deleteEnquiry.activity_id } }); - return enquiry.fromPrismaModel(deleteEnquiry); + return enquiry.fromPrismaModelWithContact(deleteEnquiry); } } }; diff --git a/app/src/services/index.ts b/app/src/services/index.ts index b98f9d22..23621712 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1,12 +1,16 @@ export { default as accessRequestService } from './accessRequest'; export { default as activityService } from './activity'; +export { default as atsService } from './ats'; export { default as comsService } from './coms'; +export { default as contactService } from './contact'; export { default as documentService } from './document'; +export { default as draftService } from './draft'; export { default as emailService } from './email'; export { default as enquiryService } from './enquiry'; export { default as initiativeService } from './initiative'; export { default as noteService } from './note'; export { default as permitService } from './permit'; +export { default as permitNoteService } from './permitNote'; export { default as ssoService } from './sso'; export { default as submissionService } from './submission'; export { default as userService } from './user'; diff --git a/app/src/services/permit.ts b/app/src/services/permit.ts index 802dce18..6c0d6612 100644 --- a/app/src/services/permit.ts +++ b/app/src/services/permit.ts @@ -4,7 +4,7 @@ import prisma from '../db/dataConnection'; import { permit, permit_type } from '../db/models'; import { v4 as uuidv4 } from 'uuid'; -import type { Permit } from '../types'; +import type { ListPermitsOptions, Permit } from '../types'; const service = { /** @@ -103,13 +103,14 @@ const service = { * @param {string} activityId PCNS Activity ID * @returns {Promise} The result of running the findMany operation */ - listPermits: async (activityId?: string) => { + listPermits: async (options?: ListPermitsOptions) => { const response = await prisma.permit.findMany({ include: { - permit_type: true + permit_type: true, + permit_note: options?.includeNotes ? { orderBy: { created_at: 'desc' } } : false }, where: { - activity_id: activityId ? activityId : undefined + activity_id: options?.activityId || undefined }, orderBy: { permit_type: { @@ -118,6 +119,10 @@ const service = { } }); + if (options?.includeNotes) { + return response.map((x) => permit.fromPrismaModelWithNotes(x)); + } + return response.map((x) => permit.fromPrismaModel(x)); }, diff --git a/app/src/services/permitNote.ts b/app/src/services/permitNote.ts new file mode 100644 index 00000000..8b9b136e --- /dev/null +++ b/app/src/services/permitNote.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-useless-catch */ + +import prisma from '../db/dataConnection'; +import { permit_note } from '../db/models'; +import { v4 as uuidv4 } from 'uuid'; + +import type { PermitNote } from '../types'; + +const service = { + /** + * @function createPermitNote + * Creates a Permit Note + * @param {PermitNote} data Permit Note object + * @returns {Promise} The result of running the create operation + */ + createPermitNote: async (data: PermitNote) => { + try { + const newPermitNote = { ...data, permitNoteId: uuidv4() }; + + const create = await prisma.permit_note.create({ + data: { ...permit_note.toPrismaModel(newPermitNote), created_by: data.createdBy } + }); + return permit_note.fromPrismaModel(create); + } catch (e: unknown) { + throw e; + } + } +}; + +export default service; diff --git a/app/src/services/submission.ts b/app/src/services/submission.ts index 04758ff2..34fbe3f5 100644 --- a/app/src/services/submission.ts +++ b/app/src/services/submission.ts @@ -9,8 +9,8 @@ import { ApplicationStatus } from '../utils/enums/housing'; import { getChefsApiKey } from '../utils/utils'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import type { IStamps } from '../interfaces/IStamps'; import type { Submission, SubmissionSearchParameters } from '../types'; -import { IStamps } from '../interfaces/IStamps'; /** * @function chefsAxios @@ -35,10 +35,21 @@ const service = { */ createSubmission: async (data: Partial) => { const response = await prisma.submission.create({ - data: { ...submission.toPrismaModel(data as Submission), created_at: data.createdAt, created_by: data.createdBy } + data: { ...submission.toPrismaModel(data as Submission), created_at: data.createdAt, created_by: data.createdBy }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } + } }); - return submission.fromPrismaModel(response); + return submission.fromPrismaModelWithContact(response); }, /** @@ -69,12 +80,6 @@ const service = { activity_id: x.activityId as string, application_status: ApplicationStatus.NEW, company_name_registered: x.companyNameRegistered, - contact_email: x.contactEmail, - contact_phone_number: x.contactPhoneNumber, - contact_first_name: x.contactFirstName, - contact_last_name: x.contactLastName, - contact_preference: x.contactPreference, - contact_applicant_relationship: x.contactApplicantRelationship, financially_supported: x.financiallySupported, financially_supported_bc: x.financiallySupportedBC, financially_supported_indigenous: x.financiallySupportedIndigenous, @@ -123,6 +128,17 @@ const service = { const del = await trx.submission.delete({ where: { submission_id: submissionId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); @@ -135,7 +151,7 @@ const service = { return del; }); - return submission.fromPrismaModel(response); + return submission.fromPrismaModelWithContact(response); }, /** @@ -183,7 +199,7 @@ const service = { /** * @function getSubmission * Gets a specific submission from the PCNS database - * @param {string} activityId PCNS Activity ID + * @param {string} submissionId PCNS Submission ID * @returns {Promise} The result of running the findFirst operation */ getSubmission: async (submissionId: string) => { @@ -191,23 +207,47 @@ const service = { const result = await prisma.submission.findFirst({ where: { submission_id: submissionId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); - return result ? submission.fromPrismaModel(result) : null; + return result ? submission.fromPrismaModelWithContact(result) : null; } catch (e: unknown) { throw e; } }, - /* + /** * @function getSubmissions * Gets a list of submissions * @returns {Promise<(Submission | null)[]>} The result of running the findMany operation */ getSubmissions: async () => { try { - const result = await prisma.submission.findMany({ include: { user: true } }); + const result = await prisma.submission.findMany({ + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + }, + user: true + } + }); return result.map((x) => submission.fromPrismaModelWithUser(x)); } catch (e: unknown) { @@ -227,7 +267,18 @@ const service = { */ searchSubmissions: async (params: SubmissionSearchParameters) => { let result = await prisma.submission.findMany({ - include: { user: params.includeUser, activity: true }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + }, + user: params.includeUser + }, where: { AND: [ { @@ -251,7 +302,7 @@ const service = { const submissions = params.includeUser ? result.map((x) => submission.fromPrismaModelWithUser(x)) - : result.map((x) => submission.fromPrismaModel(x)); + : result.map((x) => submission.fromPrismaModelWithContact(x)); return submissions; }, @@ -267,6 +318,17 @@ const service = { const deleteSubmission = await prisma.submission.findUnique({ where: { submission_id: submissionId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); @@ -277,7 +339,7 @@ const service = { activity_id: deleteSubmission?.activity_id } }); - return submission.fromPrismaModel(deleteSubmission); + return submission.fromPrismaModelWithContact(deleteSubmission); } }, @@ -293,10 +355,21 @@ const service = { data: { ...submission.toPrismaModel(data), updated_at: data.updatedAt, updated_by: data.updatedBy }, where: { submission_id: data.submissionId + }, + include: { + activity: { + include: { + activity_contact: { + include: { + contact: true + } + } + } + } } }); - return submission.fromPrismaModel(result); + return submission.fromPrismaModelWithContact(result); } catch (e: unknown) { throw e; } diff --git a/app/src/types/ATSClientResource.ts b/app/src/types/ATSClientResource.ts new file mode 100644 index 00000000..e861deb5 --- /dev/null +++ b/app/src/types/ATSClientResource.ts @@ -0,0 +1,21 @@ +type AddressResource = { + '@type': string; + addressLine1: string; + addressLine2: string | null; + city: string; + provinceCode: string; + countryCode: string; + postalCode: string | null; + primaryPhone: string; + email: string; +}; + +export type ATSClientResource = { + '@type': string; + address: AddressResource; + firstName: string; + surName: string; + regionName: string; + optOutOfBCStatSurveyInd: string; + createdBy: string; +}; diff --git a/app/src/types/ATSUserSearchParameters.ts b/app/src/types/ATSUserSearchParameters.ts new file mode 100644 index 00000000..d6fbfbf0 --- /dev/null +++ b/app/src/types/ATSUserSearchParameters.ts @@ -0,0 +1,7 @@ +export type ATSUserSearchParameters = { + atsClientId?: string; + email?: string; + firstName: string; + lastName: string; + phone?: string; +}; diff --git a/app/src/types/Contact.ts b/app/src/types/Contact.ts new file mode 100644 index 00000000..b9cc4c1c --- /dev/null +++ b/app/src/types/Contact.ts @@ -0,0 +1,12 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type Contact = { + contactId: string; // Primary Key + userId: string | null; + firstName: string | null; + lastName: string | null; + phoneNumber: string | null; + email: string | null; + contactPreference: string | null; + contactApplicantRelationship: string | null; +} & Partial; diff --git a/app/src/types/Draft.ts b/app/src/types/Draft.ts new file mode 100644 index 00000000..5d309294 --- /dev/null +++ b/app/src/types/Draft.ts @@ -0,0 +1,9 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type Draft = { + draftId: string; // Primary key + activityId: string; + draftCode: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; +} & Partial; diff --git a/app/src/types/Enquiry.ts b/app/src/types/Enquiry.ts index e1c1df64..d17718e3 100644 --- a/app/src/types/Enquiry.ts +++ b/app/src/types/Enquiry.ts @@ -1,5 +1,5 @@ import { IStamps } from '../interfaces/IStamps'; - +import { Contact } from './Contact'; import type { User } from './User'; export type Enquiry = { @@ -9,12 +9,6 @@ export type Enquiry = { enquiryType: string | null; submittedAt: string; submittedBy: string; - contactFirstName: string | null; - contactLastName: string | null; - contactPhoneNumber: string | null; - contactEmail: string | null; - contactPreference: string | null; - contactApplicantRelationship: string | null; isRelated: string | null; relatedActivityId: string | null; enquiryDescription: string | null; @@ -22,5 +16,6 @@ export type Enquiry = { intakeStatus: string | null; enquiryStatus: string | null; waitingOn: string | null; + contacts: Array; user: User | null; } & Partial; diff --git a/app/src/types/EnquiryIntake.ts b/app/src/types/EnquiryIntake.ts index e64cbf51..fcba4abe 100644 --- a/app/src/types/EnquiryIntake.ts +++ b/app/src/types/EnquiryIntake.ts @@ -1,3 +1,4 @@ +import { Contact } from './Contact'; import { ApplicationStatus, SubmissionType } from '../utils/enums/housing'; export type EnquiryIntake = { @@ -8,15 +9,6 @@ export type EnquiryIntake = { enquiryType?: SubmissionType; submit?: boolean; - applicant?: { - contactFirstName?: string; - contactLastName?: string; - contactPhoneNumber?: string; - contactEmail?: string; - contactApplicantRelationship?: string; - contactPreference?: string; - }; - basic?: { enquiryType?: string; isRelated?: string; @@ -24,4 +16,6 @@ export type EnquiryIntake = { enquiryDescription?: string; applyForPermitConnect: string; }; + + contacts: Array; }; diff --git a/app/src/types/ListPermitsOptions.ts b/app/src/types/ListPermitsOptions.ts new file mode 100644 index 00000000..4ef3dde7 --- /dev/null +++ b/app/src/types/ListPermitsOptions.ts @@ -0,0 +1,6 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type ListPermitsOptions = { + activityId?: string; + includeNotes?: boolean; +} & Partial; diff --git a/app/src/types/Permit.ts b/app/src/types/Permit.ts index b5fed0b4..667cd2ee 100644 --- a/app/src/types/Permit.ts +++ b/app/src/types/Permit.ts @@ -1,4 +1,5 @@ import { IStamps } from '../interfaces/IStamps'; +import { PermitNote } from './PermitNote'; export type Permit = { permitId: string; // Primary Key @@ -12,4 +13,5 @@ export type Permit = { submittedDate: string | null; adjudicationDate: string | null; statusLastVerified: string | null; + permitNote?: Array; } & Partial; diff --git a/app/src/types/PermitNote.ts b/app/src/types/PermitNote.ts new file mode 100644 index 00000000..becc75c6 --- /dev/null +++ b/app/src/types/PermitNote.ts @@ -0,0 +1,8 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type PermitNote = { + permitNoteId: string; // Primary Key + permitId: string; + note: string; + isDeleted: boolean; +} & Partial; diff --git a/app/src/types/Submission.ts b/app/src/types/Submission.ts index f49fde21..49725c25 100644 --- a/app/src/types/Submission.ts +++ b/app/src/types/Submission.ts @@ -1,5 +1,6 @@ import { IStamps } from '../interfaces/IStamps'; +import type { Contact } from './Contact'; import type { User } from './User'; export type Submission = { @@ -10,10 +11,6 @@ export type Submission = { submittedBy: string; locationPIDs: string | null; companyNameRegistered: string | null; - contactApplicantRelationship: string | null; - contactPhoneNumber: string | null; - contactEmail: string | null; - contactPreference: string | null; consentToFeedback: boolean; projectName: string | null; projectDescription: string | null; @@ -40,7 +37,6 @@ export type Submission = { waitingOn: string | null; intakeStatus: string | null; applicationStatus: string | null; - isDevelopedByCompanyOrOrg: string | null; isDevelopedInBC: string | null; multiFamilyUnits: string | null; @@ -55,9 +51,8 @@ export type Submission = { indigenousDescription: string | null; nonProfitDescription: string | null; housingCoopDescription: string | null; - contactFirstName: string | null; - contactLastName: string | null; submissionType: string | null; relatedEnquiries: string | null; + contacts: Array; user: User | null; } & Partial; diff --git a/app/src/types/SubmissionIntake.ts b/app/src/types/SubmissionIntake.ts index 6378971f..75d3d6c6 100644 --- a/app/src/types/SubmissionIntake.ts +++ b/app/src/types/SubmissionIntake.ts @@ -1,22 +1,13 @@ -import { ApplicationStatus, SubmissionType } from '../utils/enums/housing'; +import { Contact } from './Contact'; import { Permit } from './Permit'; +import { ApplicationStatus, SubmissionType } from '../utils/enums/housing'; export type SubmissionIntake = { activityId?: string; - submissionId?: string; + draftId?: string; submittedAt?: string; applicationStatus?: ApplicationStatus; submissionType?: SubmissionType; - submit?: boolean; - - applicant?: { - contactFirstName?: string; - contactLastName?: string; - contactPhoneNumber?: string; - contactEmail?: string; - contactApplicantRelationship?: string; - contactPreference?: string; - }; basic?: { consentToFeedback?: boolean; @@ -62,4 +53,6 @@ export type SubmissionIntake = { appliedPermits?: Array; investigatePermits?: Array; + + contacts?: Array; }; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index da71e31d..a4a9914f 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -1,12 +1,16 @@ -export type { Activity } from './Activity'; export type { AccessRequest } from './AccessRequest'; +export type { Activity } from './Activity'; +export type { ATSClientResource } from './ATSClientResource'; +export type { ATSUserSearchParameters } from './ATSUserSearchParameters'; export type { BceidSearchParameters } from './BceidSearchParameters'; export type { BringForward } from './BringForward'; export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionExport } from './ChefsSubmissionExport'; +export type { Contact } from './Contact'; export type { CurrentAuthorization } from './CurrentAuthorization'; export type { CurrentContext } from './CurrentContext'; export type { Document } from './Document'; +export type { Draft } from './Draft'; export type { Email } from './Email'; export type { Enquiry } from './Enquiry'; export type { EnquiryIntake } from './EnquiryIntake'; @@ -15,9 +19,11 @@ export type { EmailAttachment } from './EmailAttachment'; export type { IdentityProvider } from './IdentityProvider'; export type { IdirSearchParameters } from './IdirSearchParameters'; export type { IdpAttributes } from './IdpAttributes'; +export type { ListPermitsOptions } from './ListPermitsOptions'; export type { Middleware } from './Middleware'; export type { Note } from './Note'; export type { Permit } from './Permit'; +export type { PermitNote } from './PermitNote'; export type { PermitType } from './PermitType'; export type { StatisticsFilters } from './StatisticsFilters'; export type { Submission } from './Submission'; diff --git a/app/src/utils/constants/application.ts b/app/src/utils/constants/application.ts index 7b05d599..2584fd6b 100644 --- a/app/src/utils/constants/application.ts +++ b/app/src/utils/constants/application.ts @@ -1,7 +1,5 @@ import { BasicResponse, GroupName } from '../enums/application'; -export const ACTIVITY_ID_LENGTH = 8; - /** Default CORS settings used across the entire application */ export const DEFAULTCORS = Object.freeze({ /** Tells browsers to cache preflight requests for Access-Control-Max-Age seconds */ diff --git a/app/src/utils/constants/housing.ts b/app/src/utils/constants/housing.ts index f79678f4..e595743f 100644 --- a/app/src/utils/constants/housing.ts +++ b/app/src/utils/constants/housing.ts @@ -82,14 +82,24 @@ export const PROJECT_RELATIONSHIP_LIST = [ export const PERMIT_AUTHORIZATION_STATUS_LIST = [ PermitAuthorizationStatus.ISSUED, - PermitAuthorizationStatus.DENIED, PermitAuthorizationStatus.PENDING, PermitAuthorizationStatus.IN_REVIEW, + PermitAuthorizationStatus.DENIED, + PermitAuthorizationStatus.CANCELLED, + PermitAuthorizationStatus.WITHDRAWN, + PermitAuthorizationStatus.ABANDONED, PermitAuthorizationStatus.NONE ]; + export const PERMIT_NEEDED_LIST = [PermitNeeded.YES, PermitNeeded.UNDER_INVESTIGATION, PermitNeeded.NO]; -export const PERMIT_STATUS_LIST = [PermitStatus.NEW, PermitStatus.APPLIED, PermitStatus.COMPLETED]; +export const PERMIT_STATUS_LIST = [ + PermitStatus.NEW, + PermitStatus.APPLIED, + PermitStatus.COMPLETED, + PermitStatus.TECHNICAL_REVIEW, + PermitStatus.PENDING +]; export const PROJECT_LOCATION_LIST = [ProjectLocation.LOCATION_COORDINATES, ProjectLocation.STREET_ADDRESS]; diff --git a/app/src/utils/enums/application.ts b/app/src/utils/enums/application.ts index 3011d078..a53a1352 100644 --- a/app/src/utils/enums/application.ts +++ b/app/src/utils/enums/application.ts @@ -47,6 +47,7 @@ export enum Regex { export enum Resource { ACCESS_REQUEST = 'ACCESS_REQUEST', + ATS = 'ATS', DOCUMENT = 'DOCUMENT', ENQUIRY = 'ENQUIRY', NOTE = 'NOTE', diff --git a/app/src/utils/enums/housing.ts b/app/src/utils/enums/housing.ts index 6bf3c548..3677fa1c 100644 --- a/app/src/utils/enums/housing.ts +++ b/app/src/utils/enums/housing.ts @@ -20,6 +20,10 @@ export enum ContactPreference { EITHER = 'Either' } +export enum DraftCode { + SUBMISSION = 'SUBMISSION' +} + export enum IntakeFormCategory { APPLICANT = 'applicant', BASIC = 'basic', @@ -52,10 +56,13 @@ export enum NumResidentialUnits { } export enum PermitAuthorizationStatus { - ISSUED = 'Issued', + ISSUED = 'Granted', + PENDING = 'Pending client action', + IN_REVIEW = 'In progress', DENIED = 'Denied', - PENDING = 'Pending', - IN_REVIEW = 'In Review', + CANCELLED = 'Cancelled', + WITHDRAWN = 'Withdrawn', + ABANDONED = 'Abandoned', NONE = 'None' } @@ -66,9 +73,11 @@ export enum PermitNeeded { } export enum PermitStatus { - NEW = 'New', - APPLIED = 'Applied', - COMPLETED = 'Completed' + NEW = 'Pre-submission', + APPLIED = 'Application submission', + COMPLETED = 'Post-decision', + TECHNICAL_REVIEW = 'Technical review', + PENDING = 'Pending decision' } export enum ProjectRelationship { diff --git a/app/src/validators/applicant.ts b/app/src/validators/applicant.ts deleted file mode 100644 index 03ee48c9..00000000 --- a/app/src/validators/applicant.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Joi from 'joi'; - -import { email, phoneNumber } from './common'; -import { CONTACT_PREFERENCE_LIST, PROJECT_RELATIONSHIP_LIST } from '../utils/constants/housing'; - -export const applicant = Joi.object({ - contactPreference: Joi.string().valid(...CONTACT_PREFERENCE_LIST), - contactEmail: email.required(), - contactFirstName: Joi.string().required().max(255), - contactLastName: Joi.string().required().max(255), - contactPhoneNumber: phoneNumber.required(), - contactApplicantRelationship: Joi.string() - .required() - .valid(...PROJECT_RELATIONSHIP_LIST) -}); diff --git a/app/src/validators/ats.ts b/app/src/validators/ats.ts new file mode 100644 index 00000000..33b2242d --- /dev/null +++ b/app/src/validators/ats.ts @@ -0,0 +1,33 @@ +import Joi from 'joi'; + +import { validate } from '../middleware/validation'; + +import { BasicResponse } from '../utils/enums/application'; + +const addressBody = { + '@type': Joi.string().valid('AddressResource'), + addressLine1: Joi.string().max(255).allow(null), + city: Joi.string().max(255).allow(null), + provinceCode: Joi.string().max(255).allow(null), + primaryPhone: Joi.string().max(255).allow(null), + email: Joi.string().max(255).allow(null) +}; + +const clientBody = { + '@type': Joi.string().valid('ClientResource'), + firstName: Joi.string().max(255).required(), + surName: Joi.string().max(255).required(), + regionName: Joi.string().max(255).required(), + optOutOfBCStatSurveyInd: Joi.string().valid(BasicResponse.NO.toUpperCase()), + address: Joi.object(addressBody).allow(null) +}; + +const schema = { + createATSClient: { + body: Joi.object(clientBody) + } +}; + +export default { + createATSClient: validate(schema.createATSClient) +}; diff --git a/app/src/validators/basic.ts b/app/src/validators/basic.ts index ef155d4d..e0d6d761 100644 --- a/app/src/validators/basic.ts +++ b/app/src/validators/basic.ts @@ -9,13 +9,17 @@ export const basicIntake = Joi.object({ isDevelopedByCompanyOrOrg: Joi.string() .required() .valid(...YES_NO_LIST), - isDevelopedInBC: Joi.string() - .required() - .valid(...YES_NO_LIST), - registeredName: Joi.when('isDevelopedInBC', { + isDevelopedInBC: Joi.when('isDevelopedByCompanyOrOrg', { is: BasicResponse.YES, + then: Joi.string() + .required() + .valid(...YES_NO_LIST), + otherwise: Joi.string().allow(null) + }), + registeredName: Joi.when('isDevelopedInBC', { + is: Joi.valid(BasicResponse.YES, BasicResponse.NO), then: Joi.string().required().max(255).trim(), - otherwise: Joi.forbidden() + otherwise: Joi.string().allow(null) }) }); diff --git a/app/src/validators/contacts.ts b/app/src/validators/contacts.ts new file mode 100644 index 00000000..51d90c4c --- /dev/null +++ b/app/src/validators/contacts.ts @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +import { email, phoneNumber, uuidv4 } from './common'; +import { CONTACT_PREFERENCE_LIST, PROJECT_RELATIONSHIP_LIST } from '../utils/constants/housing'; + +export const contacts = Joi.array() + .items( + Joi.object({ + contactId: uuidv4.allow(null), + userId: uuidv4.allow(null), + contactPreference: Joi.string().valid(...CONTACT_PREFERENCE_LIST), + email: email.required(), + firstName: Joi.string().required().max(255), + lastName: Joi.string().required().max(255), + phoneNumber: phoneNumber.required(), + contactApplicantRelationship: Joi.string() + .required() + .valid(...PROJECT_RELATIONSHIP_LIST) + }) + ) + .allow(null); diff --git a/app/src/validators/enquiry.ts b/app/src/validators/enquiry.ts index a1d2e597..5b784de8 100644 --- a/app/src/validators/enquiry.ts +++ b/app/src/validators/enquiry.ts @@ -1,18 +1,19 @@ import Joi from 'joi'; -import { applicant } from './applicant'; import { basicEnquiry } from './basic'; -import { email, phoneNumber, uuidv4 } from './common'; +import { uuidv4 } from './common'; +import { contacts } from './contacts'; import { validate } from '../middleware/validation'; import { YES_NO_LIST } from '../utils/constants/application'; import { APPLICATION_STATUS_LIST, INTAKE_STATUS_LIST } from '../utils/constants/housing'; const schema = { - createDraft: { + createEnquiry: { body: Joi.object({ - applicant: applicant, + contacts: contacts, basic: basicEnquiry, - submit: Joi.boolean() + activityId: Joi.string(), + enquiryId: Joi.string() }) }, deleteEnquiry: { @@ -20,15 +21,6 @@ const schema = { enquiryId: uuidv4.required() }) }, - updateDraft: { - body: Joi.object({ - applicant: applicant, - basic: basicEnquiry, - submit: Joi.boolean(), - enquiryId: Joi.string().required(), - activityId: Joi.string().required() - }) - }, updateIsDeletedFlag: { params: Joi.object({ enquiryId: uuidv4.required() @@ -44,12 +36,6 @@ const schema = { enquiryType: Joi.string().allow(null), submittedAt: Joi.date(), submittedBy: Joi.string().max(255).required(), - contactFirstName: Joi.string().max(255).required(), - contactLastName: Joi.string().max(255).required(), - contactPhoneNumber: phoneNumber, - contactEmail: email.required(), - contactPreference: Joi.string().max(255).required(), - contactApplicantRelationship: Joi.string().max(255).required(), isRelated: Joi.string() .valid(...Object.values(YES_NO_LIST)) .allow(null), @@ -64,6 +50,7 @@ const schema = { assignedUserId: uuidv4.allow(null), enquiryStatus: Joi.string().valid(...APPLICATION_STATUS_LIST), waitingOn: Joi.string().allow(null).max(255), + contacts: contacts, createdAt: Joi.date().allow(null), createdBy: Joi.string().allow(null), updatedAt: Joi.date().allow(null), @@ -73,9 +60,8 @@ const schema = { }; export default { - createDraft: validate(schema.createDraft), + createEnquiry: validate(schema.createEnquiry), deleteEnquiry: validate(schema.deleteEnquiry), - updateDraft: validate(schema.updateDraft), updateIsDeletedFlag: validate(schema.updateIsDeletedFlag), updateEnquiry: validate(schema.updateEnquiry) }; diff --git a/app/src/validators/housing.ts b/app/src/validators/housing.ts index 70f531fb..ea88fc7e 100644 --- a/app/src/validators/housing.ts +++ b/app/src/validators/housing.ts @@ -51,7 +51,7 @@ export const housing = Joi.object({ otherwise: Joi.forbidden() }), otherUnitsDescription: Joi.when('otherSelected', { - is: Joi.exist(), + is: true, then: Joi.string().required().max(255).trim(), otherwise: Joi.forbidden() }), diff --git a/app/src/validators/index.ts b/app/src/validators/index.ts index f7f533c2..e6ab99e2 100644 --- a/app/src/validators/index.ts +++ b/app/src/validators/index.ts @@ -1,8 +1,10 @@ export { default as accessRequestValidator } from './accessRequest'; +export { default as atsValidator } from './ats'; export { default as documentValidator } from './document'; export { default as enquiryValidator } from './enquiry'; export { default as noteValidator } from './note'; export { default as permitValidator } from './permit'; +export { default as permitNoteValidator } from './permitNote'; export { default as roadmapValidator } from './roadmap'; export { default as submissionValidator } from './submission'; export { default as userValidator } from './user'; diff --git a/app/src/validators/permit.ts b/app/src/validators/permit.ts index cf1f8341..861b0396 100644 --- a/app/src/validators/permit.ts +++ b/app/src/validators/permit.ts @@ -29,7 +29,8 @@ const schema = { }, listPermits: { query: Joi.object({ - activityId: Joi.string().min(8).max(8).allow(null) + activityId: Joi.string().min(8).max(8).allow(null), + includeNotes: Joi.boolean().allow(null) }) }, updatePermit: { diff --git a/app/src/validators/permitNote.ts b/app/src/validators/permitNote.ts new file mode 100644 index 00000000..19d3458b --- /dev/null +++ b/app/src/validators/permitNote.ts @@ -0,0 +1,43 @@ +import Joi from 'joi'; + +import { uuidv4 } from './common'; +import { validate } from '../middleware/validation'; + +const sharedPermitNoteSchema = { + permitId: uuidv4.required(), + note: Joi.string().required(), + isDeleted: Joi.boolean() +}; + +const schema = { + createPermitNote: { + body: Joi.object(sharedPermitNoteSchema) + }, + listPermitNotes: { + params: Joi.object({ + permitId: uuidv4.required() + }) + } + // TODO impliment update & delete validators + // updatePermitNote: { + // body: Joi.object({ + // ...sharedPermitSchema, + // permitNoteId: uuidv4.required() + // }), + // query: Joi.object({ + // permitId: uuidv4.required() + // }) + // }, + // deletePermitNote: { + // query: Joi.object({ + // permitId: uuidv4.required() + // }) + // }, +}; + +export default { + createPermitNote: validate(schema.createPermitNote), + listPermitNotes: validate(schema.listPermitNotes) + // deletePermit: validate(schema.deletePermit), + // updatePermit: validate(schema.updatePermit) +}; diff --git a/app/src/validators/submission.ts b/app/src/validators/submission.ts index 3f766454..a73d9fd2 100644 --- a/app/src/validators/submission.ts +++ b/app/src/validators/submission.ts @@ -1,9 +1,9 @@ import Joi from 'joi'; -import { applicant } from './applicant'; import { appliedPermit } from './appliedPermit'; import { basicIntake } from './basic'; import { activityId, email, uuidv4 } from './common'; +import { contacts } from './contacts'; import { housing } from './housing'; import { permits } from './permits'; @@ -19,17 +19,15 @@ import { BasicResponse } from '../utils/enums/application'; import { IntakeStatus } from '../utils/enums/housing'; const schema = { - createDraft: { - body: Joi.object({ - applicant: applicant - }) - }, createSubmission: { body: Joi.object({ - applicant: applicant, + draftId: uuidv4.allow(null), + activityId: Joi.string().min(8).max(8).allow(null), + contacts: contacts, appliedPermits: Joi.array().items(appliedPermit).allow(null), basic: basicIntake, housing: housing, + location: Joi.any(), investigatePermits: Joi.array() .items(Joi.object({ permitTypeId: Joi.number().allow(null) })) .allow(null), @@ -52,6 +50,11 @@ const schema = { submissionId: uuidv4.required() }) }, + deleteDraft: { + params: Joi.object({ + draftId: uuidv4.required() + }) + }, getStatistics: { query: Joi.object({ dateFrom: Joi.date().allow(null), @@ -166,11 +169,7 @@ const schema = { .required(), projectLocationDescription: Joi.string().allow(null).max(4000), addedToATS: Joi.boolean().required(), - atsClientNumber: Joi.when('addedToATS', { - is: true, - then: Joi.string().required().max(255), - otherwise: Joi.string().allow(null) - }), + atsClientNumber: Joi.string().allow(null).max(255), ltsaCompleted: Joi.boolean().required(), bcOnlineCompleted: Joi.boolean().required(), aaiUpdated: Joi.boolean().required(), @@ -182,8 +181,9 @@ const schema = { otherwise: uuidv4.allow(null) }), applicationStatus: Joi.string().valid(...APPLICATION_STATUS_LIST), - waitingOn: Joi.string().allow(null).max(255) - }).concat(applicant), + waitingOn: Joi.string().allow(null).max(255), + contacts: contacts + }), params: Joi.object({ submissionId: uuidv4.required() }) @@ -191,10 +191,10 @@ const schema = { }; export default { - createDraft: validate(schema.createDraft), createSubmission: validate(schema.createSubmission), emailConfirmation: validate(schema.emailConfirmation), deleteSubmission: validate(schema.deleteSubmission), + deleteDraft: validate(schema.deleteDraft), getStatistics: validate(schema.getStatistics), getSubmission: validate(schema.getSubmission), searchSubmissions: validate(schema.searchSubmissions), diff --git a/app/tests/unit/controllers/activity.spec.ts b/app/tests/unit/controllers/activity.spec.ts deleted file mode 100644 index 53b192a7..00000000 --- a/app/tests/unit/controllers/activity.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { activityController } from '../../../src/controllers'; -import { activityService } from '../../../src/services'; - -// Mock config library - @see {@link https://stackoverflow.com/a/64819698} -jest.mock('config'); - -const mockResponse = () => { - const res: { status?: jest.Mock; json?: jest.Mock; end?: jest.Mock } = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - - return res; -}; - -let res: { status?: jest.Mock; json?: jest.Mock; end?: jest.Mock }; -beforeEach(() => { - res = mockResponse(); -}); - -afterEach(() => { - /* - * Must use clearAllMocks when using the mocked config - * resetAllMocks seems to cause strange issues such as - * functions not calling as expected - */ - jest.clearAllMocks(); -}); - -const CURRENT_USER = { authType: 'BEARER', tokenPayload: null }; - -const ACTIVITY = { - activityId: '12345678', - initiativeId: '59cd9e86-7cce-4791-b071-69002c731315', - isDeleted: false -}; - -const DELETED_ACTIVITY = { - activityId: '87654321', - initiativeId: '59cd9e86-7cce-4791-b071-69002c731315', - isDeleted: true -}; - -describe('validateActivityId', () => { - const next = jest.fn(); - - // Mock service calls - const activityServiceSpy = jest.spyOn(activityService, 'getActivity'); - - it('shoulld return status 200 and valid true if activityId exists and isDeleted is false', async () => { - const req = { - params: { activityId: '12345678' }, - currentUser: CURRENT_USER - }; - - activityServiceSpy.mockResolvedValue(ACTIVITY); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await activityController.validateActivityId(req as any, res as any, next); - - expect(activityServiceSpy).toHaveBeenCalledTimes(1); - expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ valid: true }); - }); - - it('shoulld return status 200 and valid false if activityId exists but isDeleted is true', async () => { - const req = { - params: { activityId: '87654321' }, - currentUser: CURRENT_USER - }; - - activityServiceSpy.mockResolvedValue(DELETED_ACTIVITY); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await activityController.validateActivityId(req as any, res as any, next); - - expect(activityServiceSpy).toHaveBeenCalledTimes(1); - expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ valid: false }); - }); - - it('shoulld return status 200 and valid false if activityId does not exist', async () => { - const req = { - params: { activityId: 'FFFFFFFF' }, - currentUser: CURRENT_USER - }; - - activityServiceSpy.mockResolvedValue(null); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await activityController.validateActivityId(req as any, res as any, next); - - expect(activityServiceSpy).toHaveBeenCalledTimes(1); - expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ valid: false }); - }); - - it('Should return 400 and error message if activityId does not have correct length', async () => { - const req = { - params: { activityId: '12345' }, - currentUser: CURRENT_USER - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await activityController.validateActivityId(req as any, res as any, next); - - expect(activityServiceSpy).toHaveBeenCalledTimes(0); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ message: 'Invalid activity Id format' }); - }); - - it('Should return 400 and error message if activityId is not hexidecimal value', async () => { - const req = { - params: { activityId: 'GGGGGGGG' }, - currentUser: CURRENT_USER - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await activityController.validateActivityId(req as any, res as any, next); - - expect(activityServiceSpy).toHaveBeenCalledTimes(0); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ message: 'Invalid activity Id format' }); - }); - - it('Should call next with error if getActivity fails', async () => { - const req = { - params: { activityId: '12345678' }, - currentUser: CURRENT_USER - }; - - const error = new Error(); - - activityServiceSpy.mockRejectedValue(error); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await activityController.validateActivityId(req as any, res as any, next); - - expect(activityServiceSpy).toHaveBeenCalledTimes(1); - expect(activityServiceSpy).toHaveBeenCalledWith(req.params.activityId); - expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith(error); - }); -}); diff --git a/app/tests/unit/controllers/ats.spec.ts b/app/tests/unit/controllers/ats.spec.ts new file mode 100644 index 00000000..12b8e26b --- /dev/null +++ b/app/tests/unit/controllers/ats.spec.ts @@ -0,0 +1,228 @@ +import { atsController } from '../../../src/controllers'; +import { atsService } from '../../../src/services'; + +// Mock config library - @see {@link https://stackoverflow.com/a/64819698} +jest.mock('config'); + +const mockResponse = () => { + const res: { status?: jest.Mock; json?: jest.Mock; end?: jest.Mock } = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + + return res; +}; + +let res = mockResponse(); +beforeEach(() => { + res = mockResponse(); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +const CURRENT_CONTEXT = { authType: 'BEARER', tokenPayload: null, userId: 'abc-123' }; + +describe('createATSClient', () => { + const next = jest.fn(); + + // Mock service calls + const createSpy = jest.spyOn(atsService, 'createATSClient'); + + it('should return 201 if all good', async () => { + const req = { + body: { + '@type': 'ClientResource', + address: { + '@type': 'AddressResource', + addressLine1: null, + city: null, + provinceCode: null, + primaryPhone: '(213) 213-2132', + email: 's@s.com' + }, + firstName: 'Gill', + surName: 'Bates', + regionName: 'HOUSING', + optOutOfBCStatSurveyInd: 'NO' + }, + currentContext: CURRENT_CONTEXT + }; + + const created = { + data: { + '@type': 'ClientResource', + address: { + '@type': 'AddressResource', + addressLine1: null, + city: null, + provinceCode: null, + primaryPhone: '(213) 213-2132', + email: 's@s.com' + }, + firstName: 'Gill', + surName: 'Bates', + regionName: 'HOUSING', + optOutOfBCStatSurveyInd: 'NO' + }, + status: 201 + }; + + createSpy.mockResolvedValue(created); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.createATSClient(req as any, res as any, next); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith({ + ...req.body + }); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('calls next if the ats service fails to create', async () => { + const req = { + body: { + '@type': 'ClientResource', + address: { + '@type': 'AddressResource', + addressLine1: null, + city: null, + provinceCode: null, + primaryPhone: '(213) 213-2132', + email: 's@s.com' + }, + firstName: 'Gill', + surName: 'Bates', + optOutOfBCStatSurveyInd: 'NO' + }, + currentContext: CURRENT_CONTEXT + }; + + createSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.createATSClient(req as any, res as any, next); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith({ + ...req.body + }); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +describe('searchATSUsers', () => { + const next = jest.fn(); + + // Mock service calls + const searchATSUsersSpy = jest.spyOn(atsService, 'searchATSUsers'); + + it('should return 200 if all good', async () => { + const req = { + query: { firstName: 'John' }, + currentContext: CURRENT_CONTEXT + }; + + const atsUsers = { + data: { + '@type': 'ClientsResource', + links: [ + { + '@type': 'RelLink', + rel: 'self', + href: 'https://t1api.nrs.gov.bc.ca/ats-api/clients?firstName=John', + method: 'GET' + }, + { + '@type': 'RelLink', + rel: 'next', + href: 'https://t1api.nrs.gov.bc.ca/ats-api/clients?firstName=John', + method: 'GET' + } + ], + pageNumber: 0, + pageRowCount: 956, + totalRowCount: 956, + totalPageCount: 1, + clients: [ + { + '@type': 'ClientResource', + links: [ + { + '@type': 'RelLink', + rel: 'self', + href: 'https://t1api.nrs.gov.bc.ca/ats-api/clients', + method: 'GET' + } + ], + clientId: 96, + address: { + '@type': 'AddressResource', + links: [], + addressId: 443, + addressLine1: null, + addressLine2: null, + city: 'Fqmrpml', + provinceCode: 'Alberta', + countryCode: 'Canada', + postalCode: null, + primaryPhone: null, + secondaryPhone: null, + fax: null, + email: null, + createdBy: null, + createdDateTime: null, + updatedBy: null, + updatedDateTime: null + }, + businessOrgCode: null, + firstName: 'John', + surName: 'Nike', + companyName: null, + organizationNumber: null, + confirmedIndicator: false, + createdBy: 'IDIR\\JNNIKE', + createdDateTime: 1166734440000, + updatedBy: 'ATS', + updatedDateTime: 1166734440000, + regionName: 'Skeena', + optOutOfBCStatSurveyInd: 'NO' + } + ] + }, + status: 200 + }; + + searchATSUsersSpy.mockResolvedValue(atsUsers); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.searchATSUsers(req as any, res as any, next); + + expect(searchATSUsersSpy).toHaveBeenCalledTimes(1); + expect(searchATSUsersSpy).toHaveBeenCalledWith({ firstName: 'John' }); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('calls next if the ats service fails to get ats users', async () => { + const req = { + query: { firstName: 'John' }, + currentContext: CURRENT_CONTEXT + }; + + searchATSUsersSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await atsController.searchATSUsers(req as any, res as any, next); + + expect(searchATSUsersSpy).toHaveBeenCalledTimes(1); + expect(searchATSUsersSpy).toHaveBeenCalledWith({ firstName: 'John' }); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/tests/unit/controllers/permit.spec.ts b/app/tests/unit/controllers/permit.spec.ts index 7e8ae857..199d9171 100644 --- a/app/tests/unit/controllers/permit.spec.ts +++ b/app/tests/unit/controllers/permit.spec.ts @@ -275,7 +275,53 @@ describe('listPermits', () => { await permitController.listPermits(req as any, res as any, next); expect(listSpy).toHaveBeenCalledTimes(1); - expect(listSpy).toHaveBeenCalledWith(req.query.activityId); + expect(listSpy).toHaveBeenCalledWith(req.query); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(permitList); + }); + + it('should return 200 and include notes if requested', async () => { + const now = new Date(); + const req = { + query: { activityId: 'ACT_ID', includeNotes: 'true' }, + currentContext: CURRENT_CONTEXT + }; + + const permitList = [ + { + permitId: '12345', + permitTypeId: 123, + activityId: 'ACT_ID', + issuedPermitId: '1', + trackingId: '2', + authStatus: 'ACTIVE', + needed: 'true', + status: 'FOO', + submittedDate: now.toISOString(), + adjudicationDate: now.toISOString(), + statusLastVerified: now.toISOString(), + permitNotes: [ + { + permitNoteId: 'NOTE123', + permitId: '12345', + note: 'A sample note', + createdAt: now.toISOString(), + createdBy: 'abc-123' + } + ] + } + ]; + + listSpy.mockResolvedValue(permitList); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await permitController.listPermits(req as any, res as any, next); + + expect(listSpy).toHaveBeenCalledTimes(1); + expect(listSpy).toHaveBeenCalledWith({ + activityId: 'ACT_ID', + includeNotes: true + }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(permitList); }); @@ -294,7 +340,7 @@ describe('listPermits', () => { await permitController.listPermits(req as any, res as any, next); expect(listSpy).toHaveBeenCalledTimes(1); - expect(listSpy).toHaveBeenCalledWith(req.query.activityId); + expect(listSpy).toHaveBeenCalledWith(req.query); expect(res.status).toHaveBeenCalledTimes(0); expect(next).toHaveBeenCalledTimes(1); }); diff --git a/app/tests/unit/controllers/permitNote.spec.ts b/app/tests/unit/controllers/permitNote.spec.ts new file mode 100644 index 00000000..9dfa6eae --- /dev/null +++ b/app/tests/unit/controllers/permitNote.spec.ts @@ -0,0 +1,93 @@ +import { permitNoteController } from '../../../src/controllers'; +import { permitNoteService } from '../../../src/services'; + +jest.mock('config'); + +const mockResponse = () => { + const res: { status?: jest.Mock; json?: jest.Mock; end?: jest.Mock } = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + + return res; +}; + +let res = mockResponse(); +beforeEach(() => { + res = mockResponse(); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +const CURRENT_CONTEXT = { authType: 'BEARER', tokenPayload: null, userId: 'abc-123' }; +const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +describe('createPermitNote', () => { + const next = jest.fn(); + + // Mock service calls + const createSpy = jest.spyOn(permitNoteService, 'createPermitNote'); + + it('should return 201 if all good', async () => { + const now = new Date(); + const req = { + body: { + permitId: 'PERMIT123', + note: 'This is a permit note.', + isDeleted: false + }, + currentContext: CURRENT_CONTEXT + }; + + const created = { + permitNoteId: 'NOTE123', + permitId: 'PERMIT123', + note: 'This is a permit note.', + isDeleted: false, + createdAt: now.toISOString(), + createdBy: 'abc-123' + }; + + createSpy.mockResolvedValue(created); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await permitNoteController.createPermitNote(req as any, res as any, next); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith({ + ...req.body, + createdAt: expect.stringMatching(isoPattern), + createdBy: 'abc-123' + }); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(created); + }); + + it('calls next if the permitNote service fails to create', async () => { + const req = { + body: { + permitId: 'PERMIT123', + note: 'This is a permit note.', + isDeleted: false + }, + currentContext: CURRENT_CONTEXT + }; + + createSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await permitNoteController.createPermitNote(req as any, res as any, next); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith({ + ...req.body, + createdAt: expect.stringMatching(isoPattern), + createdBy: 'abc-123' + }); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/tests/unit/controllers/submission.spec.ts b/app/tests/unit/controllers/submission.spec.ts index 6d8fb4fc..4f82f30f 100644 --- a/app/tests/unit/controllers/submission.spec.ts +++ b/app/tests/unit/controllers/submission.spec.ts @@ -1,16 +1,17 @@ import config from 'config'; import submissionController from '../../../src/controllers/submission'; -import { activityService, enquiryService, permitService, submissionService } from '../../../src/services'; -import type { Permit, Submission } from '../../../src/types'; import { - ApplicationStatus, - ContactPreference, - IntakeStatus, - NumResidentialUnits, - ProjectRelationship -} from '../../../src/utils/enums/housing'; -import { BasicResponse, Initiative } from '../../../src/utils/enums/application'; + activityService, + contactService, + enquiryService, + permitService, + draftService, + submissionService +} from '../../../src/services'; +import type { Permit, Submission, Draft } from '../../../src/types'; +import { ApplicationStatus, IntakeStatus, PermitNeeded, PermitStatus } from '../../../src/utils/enums/housing'; +import { AuthType, BasicResponse, Initiative } from '../../../src/utils/enums/application'; // Mock config library - @see {@link https://stackoverflow.com/a/64819698} jest.mock('config'); @@ -38,8 +39,9 @@ afterEach(() => { }); const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const uuidv4Pattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/; -const CURRENT_CONTEXT = { authType: 'BEARER', tokenPayload: null, userId: 'abc-123' }; +const CURRENT_CONTEXT = { authType: AuthType.BEARER, tokenPayload: undefined, userId: 'abc-123' }; const FORM_EXPORT_1 = { form: { @@ -146,12 +148,6 @@ const FORM_SUBMISSION_1: Partial { } }); + const req = { + currentContext: CURRENT_CONTEXT + }; + permitTypesSpy.mockResolvedValue(PERMIT_TYPES); formExportSpy.mockResolvedValueOnce([FORM_EXPORT_1]).mockResolvedValueOnce([]); searchSubmissionsSpy.mockResolvedValue([]); createSubmissionsFromExportSpy.mockResolvedValue(); - await submissionController.checkAndStoreNewSubmissions(); + await submissionController.checkAndStoreNewSubmissions(req.currentContext); expect(permitTypesSpy).toHaveBeenCalledTimes(1); expect(formExportSpy).toHaveBeenCalledTimes(2); @@ -323,6 +315,10 @@ describe.skip('checkAndStoreNewSubmissions', () => { } }); + const req = { + currentContext: CURRENT_CONTEXT + }; + permitTypesSpy.mockResolvedValue(PERMIT_TYPES); formExportSpy.mockResolvedValueOnce([FORM_EXPORT_1, FORM_EXPORT_2]).mockResolvedValueOnce([]); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -330,7 +326,7 @@ describe.skip('checkAndStoreNewSubmissions', () => { createSubmissionsFromExportSpy.mockResolvedValue(); createPermitSpy.mockResolvedValue({} as Permit); - await submissionController.checkAndStoreNewSubmissions(); + await submissionController.checkAndStoreNewSubmissions(req.currentContext); expect(permitTypesSpy).toHaveBeenCalledTimes(1); expect(formExportSpy).toHaveBeenCalledTimes(2); @@ -365,13 +361,17 @@ describe.skip('checkAndStoreNewSubmissions', () => { } }); + const req = { + currentContext: CURRENT_CONTEXT + }; + permitTypesSpy.mockResolvedValue(PERMIT_TYPES); formExportSpy.mockResolvedValueOnce([FORM_EXPORT_2]).mockResolvedValueOnce([]); searchSubmissionsSpy.mockResolvedValue([]); createSubmissionsFromExportSpy.mockResolvedValue(); createPermitSpy.mockResolvedValue({} as Permit); - await submissionController.checkAndStoreNewSubmissions(); + await submissionController.checkAndStoreNewSubmissions(req.currentContext); expect(permitTypesSpy).toHaveBeenCalledTimes(1); expect(createPermitSpy).toHaveBeenCalledTimes(1); @@ -386,201 +386,6 @@ describe.skip('checkAndStoreNewSubmissions', () => { }); }); -describe('createDraft', () => { - // Mock service calls - const createPermitSpy = jest.spyOn(permitService, 'createPermit'); - const createSubmissionSpy = jest.spyOn(submissionService, 'createSubmission'); - const createActivitySpy = jest.spyOn(activityService, 'createActivity'); - - it('creates submission with unique activity ID', async () => { - const req = { - body: { ...SUBMISSION_1, activityId: undefined, submissionId: undefined }, - currentContext: CURRENT_CONTEXT - }; - const next = jest.fn(); - - createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); - createSubmissionSpy.mockResolvedValue({ activityId: '00000000', submissionId: '11111111' } as Submission); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.createDraft(req as any, res as any, next); - - expect(createActivitySpy).toHaveBeenCalledTimes(1); - expect(createSubmissionSpy).toHaveBeenCalledTimes(1); - expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith({ activityId: '00000000', submissionId: '11111111' }); - }); - - it('populates data from body if it exists', async () => { - const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - - const req = { - body: { - applicant: { - contactFirstName: 'Test', - contactLastName: 'User', - contactPhoneNumber: '1234567890', - contactEmail: 'test@user.com', - contactApplicantRelationship: ProjectRelationship.AGENT, - contactPreference: ContactPreference.EITHER - }, - basic: { - isDevelopedByCompanyOrOrg: true, - isDevelopedInBC: true, - companyNameRegistered: 'ABC' - }, - housing: { - projectName: 'TheProject', - projectDescription: 'Description', - singleFamilyUnits: NumResidentialUnits.ONE_TO_NINE, - hasRentalUnits: false, - financiallySupportedBC: true, - financiallySupportedIndigenous: false, - financiallySupportedNonProfit: false, - financiallySupportedHousingCoop: false - }, - location: { - naturalDisaster: BasicResponse.NO, - projectLocation: 'Some place', - projectLocationDescription: 'Description', - locationPIDs: '123, 456', - latitude: 48, - longitude: -114, - streetAddress: '123 Test St', - locality: 'City', - province: 'BC' - }, - permits: { - hasAppliedProvincialPermits: true - } - }, - currentContext: CURRENT_CONTEXT - }; - const next = jest.fn(); - - createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); - createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.createDraft(req as any, res as any, next); - - expect(createActivitySpy).toHaveBeenCalledTimes(1); - expect(createSubmissionSpy).toHaveBeenCalledTimes(1); - expect(createSubmissionSpy).toHaveBeenCalledWith( - expect.objectContaining({ - contactFirstName: `${req.body.applicant.contactFirstName}`, - contactLastName: `${req.body.applicant.contactLastName}`, - isDevelopedByCompanyOrOrg: true, - projectName: 'TheProject', - projectLocation: 'Some place', - hasAppliedProvincialPermits: true, - submissionId: expect.any(String), - activityId: '00000000', - submittedAt: expect.stringMatching(isoPattern), - intakeStatus: IntakeStatus.DRAFT, - applicationStatus: ApplicationStatus.NEW - }) - ); - }); - - it('sets intake status to Submitted when submit flag given', async () => { - const req = { - body: { - activityId: '00000000', - submissionId: '11111111', - submit: true - }, - currentContext: CURRENT_CONTEXT - }; - const next = jest.fn(); - - createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.createDraft(req as any, res as any, next); - - expect(createActivitySpy).toHaveBeenCalledTimes(0); - expect(createSubmissionSpy).toHaveBeenCalledTimes(1); - expect(createSubmissionSpy).toHaveBeenCalledWith( - expect.objectContaining({ - intakeStatus: IntakeStatus.SUBMITTED - }) - ); - }); - - it('creates permits if they exist', async () => { - const now = new Date().toISOString(); - - const req = { - body: { - appliedPermits: [ - { - permitTypeId: 1, - trackingId: '123', - status: 'Applied', - statusLastVerified: now - }, - { - permitTypeId: 3, - trackingId: '456', - status: 'Applied', - statusLastVerified: now - } - ], - investigatePermits: [ - { - permitTypeId: 12, - needed: 'Under investigation', - statusLastVerified: now - } - ] - }, - currentContext: CURRENT_CONTEXT - }; - const next = jest.fn(); - - createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); - createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); - createPermitSpy.mockResolvedValue({} as Permit); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.createDraft(req as any, res as any, next); - - expect(createActivitySpy).toHaveBeenCalledTimes(1); - expect(createSubmissionSpy).toHaveBeenCalledTimes(1); - - expect(createPermitSpy).toHaveBeenCalledTimes(3); - expect(createPermitSpy).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - permitTypeId: 1, - activityId: '00000000', - trackingId: '123', - status: 'Applied', - statusLastVerified: now - }) - ); - expect(createPermitSpy).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - permitTypeId: 3, - activityId: '00000000', - trackingId: '456', - status: 'Applied', - statusLastVerified: now - }) - ); - expect(createPermitSpy).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - permitTypeId: 12, - activityId: '00000000', - needed: 'Under investigation', - statusLastVerified: now - }) - ); - }); -}); - describe('createSubmission', () => { // Mock service calls const createPermitSpy = jest.spyOn(permitService, 'createPermit'); @@ -611,10 +416,7 @@ describe('createSubmission', () => { const req = { body: { - applicant: { - contactFirstName: 'Test', - contactLastName: 'User' - }, + applicant: {}, basic: { isDevelopedByCompanyOrOrg: true }, @@ -642,8 +444,6 @@ describe('createSubmission', () => { expect(createSubmissionSpy).toHaveBeenCalledTimes(1); expect(createSubmissionSpy).toHaveBeenCalledWith( expect.objectContaining({ - contactFirstName: req.body.applicant.contactFirstName, - contactLastName: req.body.applicant.contactLastName, isDevelopedByCompanyOrOrg: true, projectName: 'TheProject', projectLocation: 'Some place', @@ -666,20 +466,20 @@ describe('createSubmission', () => { { permitTypeId: 1, trackingId: '123', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now }, { permitTypeId: 3, trackingId: '456', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now } ], investigatePermits: [ { permitTypeId: 12, - needed: 'Under investigation', + needed: PermitNeeded.UNDER_INVESTIGATION, statusLastVerified: now } ] @@ -705,7 +505,7 @@ describe('createSubmission', () => { permitTypeId: 1, activityId: '00000000', trackingId: '123', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now }) ); @@ -715,7 +515,7 @@ describe('createSubmission', () => { permitTypeId: 3, activityId: '00000000', trackingId: '456', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now }) ); @@ -724,7 +524,7 @@ describe('createSubmission', () => { expect.objectContaining({ permitTypeId: 12, activityId: '00000000', - needed: 'Under investigation', + needed: PermitNeeded.UNDER_INVESTIGATION, statusLastVerified: now }) ); @@ -922,42 +722,17 @@ describe('getSubmissions', () => { }); }); -describe('updateDraft', () => { +describe('submitDraft', () => { // Mock service calls const createPermitSpy = jest.spyOn(permitService, 'createPermit'); - const updateSubmissionSpy = jest.spyOn(submissionService, 'updateSubmission'); + const createSubmissionSpy = jest.spyOn(submissionService, 'createSubmission'); const createActivitySpy = jest.spyOn(activityService, 'createActivity'); - const deletePermitsByActivitySpy = jest.spyOn(permitService, 'deletePermitsByActivity'); - - it('updates submission with the given activity ID', async () => { - const req = { - body: { activityId: '000000000', submissionId: '11111111' }, - currentContext: CURRENT_CONTEXT - }; - const next = jest.fn(); - - updateSubmissionSpy.mockResolvedValue({ activityId: '00000000', submissionId: '11111111' } as Submission); - deletePermitsByActivitySpy.mockResolvedValue(0); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.updateDraft(req as any, res as any, next); - - expect(createActivitySpy).toHaveBeenCalledTimes(0); - expect(updateSubmissionSpy).toHaveBeenCalledTimes(1); - expect(deletePermitsByActivitySpy).toHaveBeenCalledTimes(1); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ activityId: '00000000', submissionId: '11111111' }); - }); + const upsertContacts = jest.spyOn(contactService, 'upsertContacts'); it('populates data from body if it exists', async () => { const req = { body: { - activityId: '00000000', - submissionId: '11111111', - applicant: { - contactFirstName: 'Test', - contactLastName: 'User' - }, + contacts: [{ firstName: 'test', lastName: 'person' }], basic: { isDevelopedByCompanyOrOrg: true }, @@ -975,101 +750,53 @@ describe('updateDraft', () => { }; const next = jest.fn(); - updateSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); - deletePermitsByActivitySpy.mockResolvedValue(0); + createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + upsertContacts.mockResolvedValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.updateDraft(req as any, res as any, next); + await submissionController.submitDraft(req as any, res as any, next); - expect(createActivitySpy).toHaveBeenCalledTimes(0); - expect(updateSubmissionSpy).toHaveBeenCalledTimes(1); - expect(updateSubmissionSpy).toHaveBeenCalledWith( + expect(createActivitySpy).toHaveBeenCalledTimes(1); + expect(upsertContacts).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledWith( expect.objectContaining({ - contactFirstName: req.body.applicant.contactFirstName, - contactLastName: req.body.applicant.contactLastName, isDevelopedByCompanyOrOrg: true, projectName: 'TheProject', projectLocation: 'Some place', hasAppliedProvincialPermits: true, - submissionId: '11111111', + submissionId: expect.stringMatching(uuidv4Pattern), activityId: '00000000', submittedAt: expect.stringMatching(isoPattern), - intakeStatus: IntakeStatus.DRAFT, + intakeStatus: IntakeStatus.SUBMITTED, applicationStatus: ApplicationStatus.NEW }) ); - expect(deletePermitsByActivitySpy).toHaveBeenCalledTimes(1); }); - it('sets intake status to Submitted when submit flag given', async () => { + it('sets intake status to Submitted', async () => { const req = { - body: { - activityId: '00000000', - submissionId: '11111111', - submit: true - }, + body: {}, currentContext: CURRENT_CONTEXT }; const next = jest.fn(); - updateSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); - deletePermitsByActivitySpy.mockResolvedValue(0); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.updateDraft(req as any, res as any, next); - - expect(createActivitySpy).toHaveBeenCalledTimes(0); - expect(updateSubmissionSpy).toHaveBeenCalledTimes(1); - expect(updateSubmissionSpy).toHaveBeenCalledWith( - expect.objectContaining({ - intakeStatus: IntakeStatus.SUBMITTED - }) - ); - expect(deletePermitsByActivitySpy).toHaveBeenCalledTimes(1); - }); - - it('deletes all existing permits before creating new ones', async () => { - const now = new Date().toISOString(); - - const req = { - body: { - activityId: '00000000', - submissionId: '11111111', - appliedPermits: [ - { - permitTypeId: 1, - trackingId: '123', - status: 'Applied', - statusLastVerified: now - } - ] - }, - currentContext: CURRENT_CONTEXT - }; - const next = jest.fn(); + createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + upsertContacts.mockResolvedValue(); - updateSubmissionSpy.mockResolvedValue({ activityId: '00000000', submissionId: '11111111' } as Submission); - createPermitSpy.mockResolvedValue({} as Permit); - deletePermitsByActivitySpy.mockResolvedValue(0); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.updateDraft(req as any, res as any, next); + await submissionController.submitDraft(req as any, res as any, next); - const deleteOrder = deletePermitsByActivitySpy.mock.invocationCallOrder[0]; - const createOrder = createPermitSpy.mock.invocationCallOrder[0]; - - expect(createActivitySpy).toHaveBeenCalledTimes(0); - expect(updateSubmissionSpy).toHaveBeenCalledTimes(1); - expect(deletePermitsByActivitySpy).toHaveBeenCalledTimes(1); - expect(createPermitSpy).toHaveBeenCalledTimes(1); - expect(createPermitSpy).toHaveBeenCalledWith( + expect(createActivitySpy).toHaveBeenCalledTimes(1); + expect(upsertContacts).toHaveBeenCalledTimes(0); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledWith( expect.objectContaining({ - permitTypeId: 1, - activityId: '00000000', - trackingId: '123', - status: 'Applied', - statusLastVerified: now + intakeStatus: IntakeStatus.SUBMITTED }) ); - expect(deleteOrder).toBeLessThan(createOrder); }); it('creates permits if they exist', async () => { @@ -1077,26 +804,24 @@ describe('updateDraft', () => { const req = { body: { - activityId: '00000000', - submissionId: '11111111', appliedPermits: [ { permitTypeId: 1, trackingId: '123', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now }, { permitTypeId: 3, trackingId: '456', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now } ], investigatePermits: [ { permitTypeId: 12, - needed: 'Under investigation', + needed: PermitNeeded.UNDER_INVESTIGATION, statusLastVerified: now } ] @@ -1105,15 +830,17 @@ describe('updateDraft', () => { }; const next = jest.fn(); - updateSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); + createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); + createSubmissionSpy.mockResolvedValue({ activityId: '00000000' } as Submission); createPermitSpy.mockResolvedValue({} as Permit); - deletePermitsByActivitySpy.mockResolvedValue(0); + upsertContacts.mockResolvedValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await submissionController.updateDraft(req as any, res as any, next); + await submissionController.submitDraft(req as any, res as any, next); - expect(createActivitySpy).toHaveBeenCalledTimes(0); - expect(updateSubmissionSpy).toHaveBeenCalledTimes(1); - expect(deletePermitsByActivitySpy).toHaveBeenCalledTimes(1); + expect(createActivitySpy).toHaveBeenCalledTimes(1); + expect(upsertContacts).toHaveBeenCalledTimes(0); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); + expect(createSubmissionSpy).toHaveBeenCalledTimes(1); expect(createPermitSpy).toHaveBeenCalledTimes(3); expect(createPermitSpy).toHaveBeenNthCalledWith( 1, @@ -1121,7 +848,7 @@ describe('updateDraft', () => { permitTypeId: 1, activityId: '00000000', trackingId: '123', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now }) ); @@ -1131,7 +858,7 @@ describe('updateDraft', () => { permitTypeId: 3, activityId: '00000000', trackingId: '456', - status: 'Applied', + status: PermitStatus.APPLIED, statusLastVerified: now }) ); @@ -1140,13 +867,101 @@ describe('updateDraft', () => { expect.objectContaining({ permitTypeId: 12, activityId: '00000000', - needed: 'Under investigation', + needed: PermitNeeded.UNDER_INVESTIGATION, statusLastVerified: now }) ); }); }); +describe('updateDraft', () => { + // Mock service calls + const createDraftSpy = jest.spyOn(draftService, 'createDraft'); + const updateDraftSpy = jest.spyOn(draftService, 'updateDraft'); + const createActivitySpy = jest.spyOn(activityService, 'createActivity'); + + it('creates a new draft', async () => { + const req = { + body: { + contactFirstName: 'test', + contactLastName: 'person', + basic: { + isDevelopedByCompanyOrOrg: true + }, + housing: { + projectName: 'TheProject' + }, + location: { + projectLocation: 'Some place' + }, + permits: { + hasAppliedProvincialPermits: true + } + }, + currentContext: CURRENT_CONTEXT + }; + const next = jest.fn(); + + createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); + createDraftSpy.mockResolvedValue({ draftId: '11111111', activityId: '00000000' } as Draft); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.updateDraft(req as any, res as any, next); + + expect(createActivitySpy).toHaveBeenCalledTimes(1); + expect(createDraftSpy).toHaveBeenCalledTimes(1); + expect(createDraftSpy).toHaveBeenCalledWith( + expect.objectContaining({ + draftId: expect.stringMatching(uuidv4Pattern), + activityId: '00000000' + }) + ); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ draftId: '11111111', activityId: '00000000' }); + }); + + it('updates draft with the given draftId and activityId', async () => { + const req = { + body: { + draftId: '11111111', + activityId: '00000000', + contactFirstName: 'test', + contactLastName: 'person', + basic: { + isDevelopedByCompanyOrOrg: true + }, + housing: { + projectName: 'TheProject' + }, + location: { + projectLocation: 'Some place' + }, + permits: { + hasAppliedProvincialPermits: true + } + }, + currentContext: CURRENT_CONTEXT + }; + const next = jest.fn(); + + createActivitySpy.mockResolvedValue({ activityId: '00000000', initiativeId: Initiative.HOUSING, isDeleted: false }); + updateDraftSpy.mockResolvedValue({ draftId: '11111111', activityId: '00000000' } as Draft); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await submissionController.updateDraft(req as any, res as any, next); + + expect(createActivitySpy).toHaveBeenCalledTimes(0); + expect(updateDraftSpy).toHaveBeenCalledTimes(1); + expect(updateDraftSpy).toHaveBeenCalledWith( + expect.objectContaining({ + draftId: '11111111' + }) + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ draftId: '11111111', activityId: '00000000' }); + }); +}); + describe('updateSubmission', () => { const next = jest.fn(); diff --git a/app/tests/unit/validators/applicant.spec.ts b/app/tests/unit/validators/applicant.spec.ts deleted file mode 100644 index 5c20f1f4..00000000 --- a/app/tests/unit/validators/applicant.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { applicant as applicantSchema } from '../../../src/validators/applicant'; - -describe('applicantSchema', () => { - it('should only accept string values for each field', () => { - const applicant = { - contactPreference: 123, - contactEmail: 123, - contactFirstName: 123, - contactLastName: 123, - contactPhoneNumber: 123, - contactApplicantRelationship: 123 - }; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeDefined(); - }); - - it('should not exceed the 255 character limit for any fields', () => { - const applicant = { - contactPreference: 'a'.repeat(256), - contactEmail: 'a'.repeat(256), - contactFirstName: 'a'.repeat(256), - contactLastName: 'a'.repeat(256), - contactPhoneNumber: 'a'.repeat(256), - contactApplicantRelationship: 'a'.repeat(256) - }; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeDefined(); - }); - - it('should not be empty', () => { - const applicant = {}; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeDefined(); - }); - - it('should be a valid schema', () => { - const applicant = { - contactPreference: 'Email', - contactEmail: 'test@example.com', - contactFirstName: 'John', - contactLastName: 'Doe', - contactPhoneNumber: '1234567890', - contactApplicantRelationship: 'Employee' - }; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeUndefined(); - }); - - it('should not accept invalid email', () => { - const applicant = { - contactPreference: 'Email', - contactEmail: 'not-an-email', - contactFirstName: 'John', - contactLastName: 'Doe', - contactPhoneNumber: '1234567890', - contactApplicantRelationship: 'Employee' - }; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeDefined(); - }); - - it('should not accept invalid phone number', () => { - const applicant = { - contactPreference: 'Email', - contactEmail: 'test@example.com', - contactFirstName: 'John', - contactLastName: 'Doe', - contactPhoneNumber: '+1234567890', - contactApplicantRelationship: 'Employee' - }; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeDefined(); - }); - - it('should not accept invalid phone number', () => { - const applicant = { - contactPreference: 'Email', - contactEmail: 'test@example.com', - contactFirstName: 'John', - contactLastName: 'Doe', - contactPhoneNumber: '12345678901', - contactApplicantRelationship: 'Employee' - }; - const result = applicantSchema.validate(applicant); - expect(result.error).toBeDefined(); - }); -}); diff --git a/app/tests/unit/validators/basic.spec.ts b/app/tests/unit/validators/basic.spec.ts index 37d7ed87..3235bfa4 100644 --- a/app/tests/unit/validators/basic.spec.ts +++ b/app/tests/unit/validators/basic.spec.ts @@ -44,23 +44,12 @@ describe('basicIntakeSchema', () => { expect(result.error).toBeDefined(); }); - it('should not throw an error when isDevelopedInBC is NO and registeredName is not provided', () => { + it('should throw an error when isDevelopedInBC is NO and registeredName is not provided', () => { const data = { isDevelopedByCompanyOrOrg: BasicResponse.YES, isDevelopedInBC: BasicResponse.NO }; - const result = basicIntake.validate(data); - expect(result.error).toBeUndefined(); - }); - - it('should throw an error when isDevelopedInBC is NO but registeredName is provided', () => { - const data = { - isDevelopedByCompanyOrOrg: BasicResponse.YES, - isDevelopedInBC: BasicResponse.NO, - registeredName: 'My Company' - }; - const result = basicIntake.validate(data); expect(result.error).toBeDefined(); }); diff --git a/app/tests/unit/validators/contacts.spec.ts b/app/tests/unit/validators/contacts.spec.ts new file mode 100644 index 00000000..fa308493 --- /dev/null +++ b/app/tests/unit/validators/contacts.spec.ts @@ -0,0 +1,99 @@ +import { contacts as contactsSchema } from '../../../src/validators/contacts'; + +describe('contactsSchema', () => { + it('should only accept string values for each field', () => { + const contacts = [ + { + contactPreference: 123, + email: 123, + firstName: 123, + lastName: 123, + phoneNumber: 123, + contactApplicantRelationship: 123 + } + ]; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeDefined(); + }); + + it('should not exceed the 255 character limit for any fields', () => { + const contacts = [ + { + contactPreference: 'a'.repeat(256), + email: 'a'.repeat(256), + firstName: 'a'.repeat(256), + lastName: 'a'.repeat(256), + phoneNumber: 'a'.repeat(256), + contactApplicantRelationship: 'a'.repeat(256) + } + ]; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeDefined(); + }); + + it('should not be empty', () => { + const contacts = {}; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeDefined(); + }); + + it('should be a valid schema', () => { + const contacts = [ + { + contactPreference: 'Email', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '1234567890', + contactApplicantRelationship: 'Employee' + } + ]; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeUndefined(); + }); + + it('should not accept invalid email', () => { + const contacts = [ + { + contactPreference: 'Email', + email: 'not-an-email', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '1234567890', + contactApplicantRelationship: 'Employee' + } + ]; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeDefined(); + }); + + it('should not accept invalid phone number', () => { + const contacts = [ + { + contactPreference: 'Email', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '+1234567890', + contactApplicantRelationship: 'Employee' + } + ]; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeDefined(); + }); + + it('should not accept invalid phone number', () => { + const contacts = [ + { + contactPreference: 'Email', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '12345678901', + contactApplicantRelationship: 'Employee' + } + ]; + const result = contactsSchema.validate(contacts); + expect(result.error).toBeDefined(); + }); +}); diff --git a/charts/pcns/Chart.yaml b/charts/pcns/Chart.yaml index 3af1cb8f..5d67b137 100644 --- a/charts/pcns/Chart.yaml +++ b/charts/pcns/Chart.yaml @@ -3,7 +3,7 @@ name: nr-permitconnect-navigator-service # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.16 +version: 0.0.17 kubeVersion: ">= 1.13.0" description: PermitConnect Navigator Service # A chart can be either an 'application' or a 'library' chart. diff --git a/charts/pcns/README.md b/charts/pcns/README.md index 48501103..5280acbb 100644 --- a/charts/pcns/README.md +++ b/charts/pcns/README.md @@ -1,6 +1,6 @@ # nr-permitconnect-navigator-service -![Version: 0.0.16](https://img.shields.io/badge/Version-0.0.16-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.4.0](https://img.shields.io/badge/AppVersion-0.4.0-informational?style=flat-square) +![Version: 0.0.17](https://img.shields.io/badge/Version-0.0.17-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.4.0](https://img.shields.io/badge/AppVersion-0.4.0-informational?style=flat-square) PermitConnect Navigator Service @@ -35,7 +35,7 @@ Kubernetes: `>= 1.13.0` | autoscaling.targetCPUUtilizationPercentage | int | `80` | | | chesSecretOverride.password | string | `nil` | | | chesSecretOverride.username | string | `nil` | | -| config.configMap | object | `{"FRONTEND_APIPATH":"api/v1","FRONTEND_CHES_ROADMAP_BCC":null,"FRONTEND_CHES_SUBMISSION_CC":null,"FRONTEND_COMS_APIPATH":null,"FRONTEND_COMS_BUCKETID":null,"FRONTEND_GEOCODER_APIPATH":null,"FRONTEND_OIDC_AUTHORITY":null,"FRONTEND_OIDC_CLIENTID":null,"FRONTEND_OPENSTREETMAP_APIPATH":null,"FRONTEND_ORGBOOK_APIPATH":null,"SERVER_APIPATH":"/api/v1","SERVER_BODYLIMIT":"30mb","SERVER_CHEFS_APIPATH":null,"SERVER_CHES_APIPATH":null,"SERVER_CHES_TOKENURL":null,"SERVER_DB_HOST":null,"SERVER_DB_POOL_MAX":"10","SERVER_DB_POOL_MIN":"2","SERVER_DB_PORT":"5432","SERVER_ENV":null,"SERVER_LOGLEVEL":"http","SERVER_OBJECTSTORAGE_BUCKET":null,"SERVER_OBJECTSTORAGE_ENDPOINT":null,"SERVER_OBJECTSTORAGE_KEY":null,"SERVER_OIDC_AUTHORITY":null,"SERVER_OIDC_IDENTITYKEY":null,"SERVER_OIDC_PUBLICKEY":null,"SERVER_PORT":"8080","SERVER_SSO_APIPATH":null,"SERVER_SSO_INTEGRATION":null,"SERVER_SSO_TOKENURL":null}` | These values will be wholesale added to the configmap as is; refer to the pcns documentation for what each of these values mean and whether you need them defined. Ensure that all values are represented explicitly as strings, as non-string values will not translate over as expected into container environment variables. For configuration keys named `*_ENABLED`, either leave them commented/undefined, or set them to string value "true". | +| config.configMap | object | `{"FRONTEND_APIPATH":"api/v1","FRONTEND_CHES_ROADMAP_BCC":null,"FRONTEND_CHES_SUBMISSION_CC":null,"FRONTEND_COMS_APIPATH":null,"FRONTEND_COMS_BUCKETID":null,"FRONTEND_GEOCODER_APIPATH":null,"FRONTEND_OIDC_AUTHORITY":null,"FRONTEND_OIDC_CLIENTID":null,"FRONTEND_OPENSTREETMAP_APIPATH":null,"FRONTEND_ORGBOOK_APIPATH":null,"SERVER_APIPATH":"/api/v1","SERVER_ATS_APIPATH":null,"SERVER_ATS_TOKENURL":null,"SERVER_BODYLIMIT":"30mb","SERVER_CHEFS_APIPATH":null,"SERVER_CHES_APIPATH":null,"SERVER_CHES_TOKENURL":null,"SERVER_DB_HOST":null,"SERVER_DB_POOL_MAX":"10","SERVER_DB_POOL_MIN":"2","SERVER_DB_PORT":"5432","SERVER_ENV":null,"SERVER_LOGLEVEL":"http","SERVER_OBJECTSTORAGE_BUCKET":null,"SERVER_OBJECTSTORAGE_ENDPOINT":null,"SERVER_OBJECTSTORAGE_KEY":null,"SERVER_OIDC_AUTHORITY":null,"SERVER_OIDC_IDENTITYKEY":null,"SERVER_OIDC_PUBLICKEY":null,"SERVER_PORT":"8080","SERVER_SSO_APIPATH":null,"SERVER_SSO_INTEGRATION":null,"SERVER_SSO_TOKENURL":null}` | These values will be wholesale added to the configmap as is; refer to the pcns documentation for what each of these values mean and whether you need them defined. Ensure that all values are represented explicitly as strings, as non-string values will not translate over as expected into container environment variables. For configuration keys named `*_ENABLED`, either leave them commented/undefined, or set them to string value "true". | | config.enabled | bool | `false` | Set to true if you want to let Helm manage and overwrite your configmaps. | | config.releaseScoped | bool | `false` | This should be set to true if and only if you require configmaps and secrets to be release scoped. In the event you want all instances in the same namespace to share a similar configuration, this should be set to false | | dbSecretOverride.password | string | `nil` | | @@ -81,4 +81,4 @@ Kubernetes: `>= 1.13.0` | ssoSecretOverride.username | string | `nil` | | ---------------------------------------------- -Autogenerated from chart metadata using [helm-docs v1.11.3](https://github.com/norwoodj/helm-docs/releases/v1.11.3) +Autogenerated from chart metadata using [helm-docs v1.13.1](https://github.com/norwoodj/helm-docs/releases/v1.13.1) diff --git a/charts/pcns/templates/deploymentconfig.yaml b/charts/pcns/templates/deploymentconfig.yaml index 54b62e92..90c2db6c 100644 --- a/charts/pcns/templates/deploymentconfig.yaml +++ b/charts/pcns/templates/deploymentconfig.yaml @@ -180,6 +180,16 @@ spec: secretKeyRef: key: password name: {{ include "pcns.configname" . }}-sso + - name: SERVER_ATS_CLIENTID + valueFrom: + secretKeyRef: + key: username + name: {{ include "pcns.configname" . }}-ats + - name: SERVER_ATS_CLIENTSECRET + valueFrom: + secretKeyRef: + key: password + name: {{ include "pcns.configname" . }}-ats envFrom: - configMapRef: name: {{ include "pcns.configname" . }}-config diff --git a/charts/pcns/templates/secret.yaml b/charts/pcns/templates/secret.yaml index 654a526f..f7fe5b84 100644 --- a/charts/pcns/templates/secret.yaml +++ b/charts/pcns/templates/secret.yaml @@ -12,6 +12,8 @@ {{- $osUsername := (randAlphaNum 32) }} {{- $ssoPassword := (randAlphaNum 32) }} {{- $ssoUsername := (randAlphaNum 32) }} +{{- $atsPassword := (randAlphaNum 32) }} +{{- $atsUsername := (randAlphaNum 32) }} {{- $dbSecretName := printf "%s-%s" (include "pcns.configname" .) "passphrase" }} {{- $dbSecret := (lookup "v1" "Secret" .Release.Namespace $dbSecretName ) }} @@ -27,6 +29,8 @@ {{- $osSecret := (lookup "v1" "Secret" .Release.Namespace $osSecretName ) }} {{- $ssoSecretName := printf "%s-%s" (include "pcns.configname" .) "sso" }} {{- $ssoSecret := (lookup "v1" "Secret" .Release.Namespace $ssoSecretName ) }} +{{- $atsSecretName := printf "%s-%s" (include "pcns.configname" .) "ats" }} +{{- $atsSecret := (lookup "v1" "Secret" .Release.Namespace $atsSecretName ) }} {{- if and (not $dbSecret) (not .Values.patroni.enabled) }} --- @@ -142,3 +146,19 @@ data: password: {{ .Values.ssoSecretOverride.password | default $ssoPassword | b64enc | quote }} username: {{ .Values.ssoSecretOverride.username | default $ssoUsername | b64enc | quote }} {{- end }} +{{- if not $atsSecret }} +--- +apiVersion: v1 +kind: Secret +metadata: + {{- if not .Values.config.releaseScoped }} + annotations: + "helm.sh/resource-policy": keep + {{- end }} + name: {{ $atsSecretName }} + labels: {{ include "pcns.labels" . | nindent 4 }} +type: kubernetes.io/basic-auth +data: + password: {{ .Values.atsSecretOverride.password | default $atsPassword | b64enc | quote }} + username: {{ .Values.atsSecretOverride.username | default $atsUsername | b64enc | quote }} +{{- end }} diff --git a/charts/pcns/values.yaml b/charts/pcns/values.yaml index de0123b1..4e8fc750 100644 --- a/charts/pcns/values.yaml +++ b/charts/pcns/values.yaml @@ -174,6 +174,9 @@ config: SERVER_SSO_TOKENURL: ~ SERVER_SSO_INTEGRATION: ~ + SERVER_ATS_APIPATH: ~ + SERVER_ATS_TOKENURL: ~ + # Modify the following variables if you need to acquire secret values from a custom-named resource dbSecretOverride: username: ~ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 564ce430..74b68913 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "uuid": "^10.0.0", "vee-validate": "^4.13.2", "vue": "^3.5.4", + "vue-i18n": "^10.0.4", "vue-router": "^4.4.4", "yup": "^1.4.0" }, @@ -1243,6 +1244,50 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@intlify/core-base": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.4.tgz", + "integrity": "sha512-GG428DkrrWCMhxRMRQZjuS7zmSUzarYcaHJqG9VB8dXAxw4iQDoKVQ7ChJRB6ZtsCsX3Jse1PEUlHrJiyQrOTg==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "10.0.4", + "@intlify/shared": "10.0.4" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.4.tgz", + "integrity": "sha512-AFbhEo10DP095/45EauinQJ5hJ3rJUmuuqltGguvc3WsvezZN+g8qNHLGWKu60FHQVizMrQY7VJ+zVlBXlQQkQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "10.0.4", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.4.tgz", + "integrity": "sha512-ukFn0I01HsSgr3VYhYcvkTCLS7rGa0gw4A4AMpcy/A9xx/zRJy7PS2BElMXLwUazVFMAr5zuiTk3MQeoeGXaJg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -7170,6 +7215,26 @@ "dev": true, "license": "ISC" }, + "node_modules/vue-i18n": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.4.tgz", + "integrity": "sha512-1xkzVxqBLk2ZFOmeI+B5r1J7aD/WtNJ4j9k2mcFcQo5BnOmHBmD7z4/oZohh96AAaRZ4Q7mNQvxc9h+aT+Md3w==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "10.0.4", + "@intlify/shared": "10.0.4", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3986c5ee..72eae661 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,10 +1,10 @@ { "name": "nr-permitting-navigator-service-frontend", "version": "0.4.0", + "license": "Apache-2.0", "private": true, "description": "", "author": "NRM Permitting and Data Solutions ", - "license": "Apache-2.0", "scripts": { "build": "vite build", "build:dts": "vue-tsc --declaration --emitDeclarationOnly", @@ -51,6 +51,7 @@ "uuid": "^10.0.0", "vee-validate": "^4.13.2", "vue": "^3.5.4", + "vue-i18n": "^10.0.4", "vue-router": "^4.4.4", "yup": "^1.4.0" }, diff --git a/frontend/src/assets/variables.scss b/frontend/src/assets/variables.scss index a675a524..b5408f97 100644 --- a/frontend/src/assets/variables.scss +++ b/frontend/src/assets/variables.scss @@ -27,3 +27,22 @@ $app-proj-white-two: #fbfbfb; $app-proj-grey-one: #868585; $app-proj-grey-two: #999999; $app-proj-black: rgba(0, 0, 0, 0.03); + +// StatusPill hex colors +$app-pill-green: #42814a; +$app-pill-lightgreen: #f6fff8; + +$app-pill-grey: #f3f2f1; +$app-pill-lightgrey: #353433; + +$app-pill-red: #ce3e39; +$app-pill-lightred: #f4e1e2; + +$app-pill-yellow: #f8bb47; +$app-pill-lightyellow: #fef1d8; + +$app-pill-text: #2d2d2d; + + + + diff --git a/frontend/src/components/common/LocaleChanger.vue b/frontend/src/components/common/LocaleChanger.vue new file mode 100644 index 00000000..d6cbbd7d --- /dev/null +++ b/frontend/src/components/common/LocaleChanger.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/common/StatusPill.vue b/frontend/src/components/common/StatusPill.vue new file mode 100644 index 00000000..140f26cb --- /dev/null +++ b/frontend/src/components/common/StatusPill.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/components/file/AdvancedFileUpload.vue b/frontend/src/components/file/AdvancedFileUpload.vue index db3861de..e674b11a 100644 --- a/frontend/src/components/file/AdvancedFileUpload.vue +++ b/frontend/src/components/file/AdvancedFileUpload.vue @@ -15,12 +15,14 @@ const { activityId = undefined, accept = undefined, disabled = false, - reject = undefined + reject = undefined, + generateActivityId } = defineProps<{ activityId?: string; accept?: string[]; reject?: string[]; disabled?: boolean; + generateActivityId: () => Promise; }>(); // Store @@ -28,6 +30,7 @@ const { getConfig } = storeToRefs(useConfigStore()); const submissionStore = useSubmissionStore(); // State +// const curActivityId: Ref = ref(undefined); const fileInput: Ref = ref(null); const uploading: Ref = ref(false); @@ -54,13 +57,16 @@ const onFileUploadDragAndDrop = (event: FileUploadUploaderEvent) => { const onUpload = async (files: Array) => { uploading.value = true; + let currentActivityId: string | undefined = activityId; + + currentActivityId = activityId ? activityId : await generateActivityId(); await Promise.allSettled( files.map((file: File) => { return new Promise((resolve, reject) => { - if (activityId) { + if (currentActivityId) { documentService - .createDocument(file, activityId, getConfig.value.coms.bucketId) + .createDocument(file, currentActivityId, getConfig.value.coms.bucketId) .then((response) => { if (response?.data) { submissionStore.addDocument(response.data); @@ -80,10 +86,10 @@ const onUpload = async (files: Array) => { uploading.value = false; }; -// filter documents based on accept and reject props -// if accept and reject are not provided, all documents are shown -// if accept is provided, only documents with extensions in accept are shown -// if reject is provided, only documents with extensions not in reject are shown +// Filter documents based on accept and reject props +// If accept and reject are not provided, all documents are shown +// If accept is provided, only documents with extensions in accept are shown +// If reject is provided, only documents with extensions not in reject are shown const filteredDocuments = computed(() => { let documents = submissionStore.getDocuments; return documents.filter( diff --git a/frontend/src/components/file/DocumentCard.vue b/frontend/src/components/file/DocumentCard.vue index 85423ee4..27ae3676 100644 --- a/frontend/src/components/file/DocumentCard.vue +++ b/frontend/src/components/file/DocumentCard.vue @@ -13,7 +13,8 @@ import spreadsheet from '@/assets/images/spreadsheet.svg'; import DeleteDocument from '@/components/file/DeleteDocument.vue'; import { Card } from '@/lib/primevue'; -import { FileCategory } from '@/utils/enums/application'; +import { useAuthZStore } from '@/store'; +import { Action, FileCategory, Initiative, Resource } from '@/utils/enums/application'; import { formatDateLong } from '@/utils/formatters'; import { getFileCategory } from '@/utils/utils'; @@ -21,8 +22,13 @@ import type { Ref } from 'vue'; import type { Document } from '@/types'; // Props -const { selectable = false, selected = false } = defineProps<{ +const { + editable = true, + selectable = false, + selected = false +} = defineProps<{ document: Document; + editable?: boolean; selectable?: boolean; selected?: boolean; }>(); @@ -58,7 +64,7 @@ const displayIcon = (mimeType = '') => { }; function onClick() { - if (selectable) { + if (selectable && useAuthZStore().can(Initiative.HOUSING, Resource.DOCUMENT, Action.READ)) { isSelected.value = !isSelected.value; emit('document:clicked', { document: document, selected: isSelected.value }); } @@ -100,7 +106,10 @@ function onClick() {
{{ filesize(document.filesize) }}
- + diff --git a/frontend/src/components/file/FileUpload.vue b/frontend/src/components/file/FileUpload.vue index 9f31fe9f..5308e65f 100644 --- a/frontend/src/components/file/FileUpload.vue +++ b/frontend/src/components/file/FileUpload.vue @@ -5,6 +5,7 @@ import { ref } from 'vue'; import { Button, FileUpload, ProgressBar, useToast } from '@/lib/primevue'; import { documentService } from '@/services'; import { useConfigStore, useSubmissionStore } from '@/store'; +import { getFilenameAndExtension } from '@/utils/utils'; import type { FileUploadUploaderEvent } from 'primevue/fileupload'; import type { Ref } from 'vue'; @@ -52,6 +53,7 @@ const onUpload = async (files: Array) => { const response = (await documentService.createDocument(file, activityId, getConfig.value.coms.bucketId))?.data; if (response) { + response.extension = getFilenameAndExtension(response.filename).extension; submissionStore.addDocument(response); toast.success('Document uploaded'); } diff --git a/frontend/src/components/form/FormAutosave.vue b/frontend/src/components/form/FormAutosave.vue new file mode 100644 index 00000000..efb24a19 --- /dev/null +++ b/frontend/src/components/form/FormAutosave.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/form/InputText.vue b/frontend/src/components/form/InputText.vue index adf71ff5..3776fded 100644 --- a/frontend/src/components/form/InputText.vue +++ b/frontend/src/components/form/InputText.vue @@ -24,7 +24,7 @@ const { }>(); // Emits -const emit = defineEmits(['onChange']); +const emit = defineEmits(['onChange', 'onClick']); diff --git a/frontend/src/components/housing/submission/SubmissionAssistance.vue b/frontend/src/components/housing/submission/SubmissionAssistance.vue index d534d819..c5ea882a 100644 --- a/frontend/src/components/housing/submission/SubmissionAssistance.vue +++ b/frontend/src/components/housing/submission/SubmissionAssistance.vue @@ -12,6 +12,9 @@ const { formErrors, formValues } = defineProps<{ formValues: { [key: string]: string }; }>(); +// Emits +const emit = defineEmits(['onSubmitAssistance']); + // State const showTab: Ref = ref(true); @@ -23,7 +26,7 @@ const checkApplicantValuesValid = ( errors: Record ): boolean => { // Check applicant section is filled - let applicant = values?.[IntakeFormCategory.APPLICANT]; + let applicant = values?.[IntakeFormCategory.CONTACTS]; if (Object.values(applicant).some((x) => !x)) { return false; } @@ -33,7 +36,7 @@ const checkApplicantValuesValid = ( const errorList = Object.keys(errors); for (const error of errorList) { - if (error.includes(IntakeFormCategory.APPLICANT)) { + if (error.includes(IntakeFormCategory.CONTACTS)) { isValid = false; break; } @@ -43,7 +46,7 @@ const checkApplicantValuesValid = ( const confirmSubmit = () => { confirm.require({ - message: 'Are you sure you want to request assistance for this form? Please review this form before submitting.', + message: 'Are you sure you want to request assistance for this form?', header: 'Please confirm assistance', acceptLabel: 'Confirm', rejectLabel: 'Cancel', @@ -52,8 +55,6 @@ const confirmSubmit = () => { } }); }; - -const emit = defineEmits(['onSubmitAssistance']);