diff --git a/app/src/controllers/contact.ts b/app/src/controllers/contact.ts new file mode 100644 index 00000000..c26365c3 --- /dev/null +++ b/app/src/controllers/contact.ts @@ -0,0 +1,52 @@ +import { contactService } from '../services'; +import { addDashesToUuid, mixedQueryToArray } from '../utils/utils'; + +import type { NextFunction, Request, Response } from 'express'; +import type { Contact, ContactSearchParameters } from '../types'; + +const controller = { + // Get current user's contact information + getCurrentUserContact: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await contactService.searchContacts({ + userId: [req.currentContext.userId as string] + }); + res.status(200).json(response[0]); + } catch (e: unknown) { + next(e); + } + }, + searchContacts: async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const contactIds = mixedQueryToArray(req.query.contactId); + const userIds = mixedQueryToArray(req.query.userId); + const response = await contactService.searchContacts({ + userId: userIds ? userIds.map((id) => addDashesToUuid(id)) : userIds, + contactId: contactIds ? contactIds.map((id) => addDashesToUuid(id)) : contactIds, + email: req.query.email, + firstName: req.query.firstName, + lastName: req.query.lastName, + contactApplicantRelationship: req.query.contactApplicantRelationship, + phoneNumber: req.query.phoneNumber + }); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, + + updateContact: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await contactService.upsertContacts([req.body], req.currentContext); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/enquiry.ts b/app/src/controllers/enquiry.ts index a9fb4105..66a1f136 100644 --- a/app/src/controllers/enquiry.ts +++ b/app/src/controllers/enquiry.ts @@ -70,7 +70,7 @@ const controller = { const enquiry = await controller.generateEnquiryData(req, IntakeStatus.SUBMITTED); // Create or update contacts - await contactService.upsertContacts(enquiry.activityId, req.body.contacts, req.currentContext); + await contactService.upsertContacts(req.body.contacts, req.currentContext, enquiry.activityId); // Create new enquiry const result = await enquiryService.createEnquiry({ @@ -139,7 +139,7 @@ const controller = { updateEnquiry: async (req: Request, res: Response, next: NextFunction) => { try { - await contactService.upsertContacts(req.body.activityId, req.body.contacts, req.currentContext); + await contactService.upsertContacts(req.body.contacts, req.currentContext, req.body.activityId); const result = await enquiryService.updateEnquiry({ ...req.body, diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index c5f11819..6fa38f1a 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,5 +1,6 @@ export { default as accessRequestController } from './accessRequest'; export { default as atsController } from './ats'; +export { default as contactController } from './contact'; export { default as documentController } from './document'; export { default as enquiryController } from './enquiry'; export { default as noteController } from './note'; diff --git a/app/src/controllers/submission.ts b/app/src/controllers/submission.ts index f491f87c..67490487 100644 --- a/app/src/controllers/submission.ts +++ b/app/src/controllers/submission.ts @@ -220,7 +220,7 @@ const controller = { // Create contacts if (req.body.contacts) - await contactService.upsertContacts(submission.activityId, req.body.contacts, req.currentContext); + await contactService.upsertContacts(req.body.contacts, req.currentContext, submission.activityId); // Create new submission const result = await submissionService.createSubmission({ @@ -369,7 +369,7 @@ const controller = { // Create contacts if (req.body.contacts) - await contactService.upsertContacts(submission.activityId, req.body.contacts, req.currentContext); + await contactService.upsertContacts(req.body.contacts, req.currentContext, submission.activityId); // Create new submission const result = await submissionService.createSubmission({ @@ -447,7 +447,7 @@ const controller = { updateSubmission: async (req: Request, res: Response, next: NextFunction) => { try { - await contactService.upsertContacts(req.body.activityId, req.body.contacts, req.currentContext); + await contactService.upsertContacts(req.body.contacts, req.currentContext, req.body.activityId); const response = await submissionService.updateSubmission({ ...req.body, diff --git a/app/src/db/migrations/20250107000000_019-contact-management.ts b/app/src/db/migrations/20250107000000_019-contact-management.ts new file mode 100644 index 00000000..4cd53202 --- /dev/null +++ b/app/src/db/migrations/20250107000000_019-contact-management.ts @@ -0,0 +1,226 @@ +/* eslint-disable max-len */ +import type { Knex } from 'knex'; + +import { Action, GroupName, Initiative, Resource } from '../../utils/enums/application'; + +const resources = [ + { + name: Resource.CONTACT + } +]; + +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.CONTACT); + + 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 proponent_group_id = await knex('yars.group') + .where({ initiative_id: housing_id, name: GroupName.PROPONENT }) + .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.CONTACT, [Action.READ, Action.UPDATE]); + + // Add all navigator read only role mappings + await addResourceRoles(navigator_read_group_id[0].group_id, Resource.CONTACT, [Action.READ, Action.UPDATE]); + + // Add all supervisor role mappings + await addResourceRoles(superviser_group_id[0].group_id, Resource.CONTACT, [Action.READ, Action.UPDATE]); + + // Add all admin role mappings + await addResourceRoles(admin_group_id[0].group_id, Resource.CONTACT, [Action.READ, Action.UPDATE]); + + // Add all proponent role mappings + await addResourceRoles(proponent_group_id[0].group_id, Resource.CONTACT, [Action.READ, Action.UPDATE]); + } + return knex('yars.group_role').insert(items); + }); +} + +export async function down(): Promise { + return Promise.resolve(); +} diff --git a/app/src/routes/v1/contact.ts b/app/src/routes/v1/contact.ts new file mode 100644 index 00000000..0b5b4b3d --- /dev/null +++ b/app/src/routes/v1/contact.ts @@ -0,0 +1,45 @@ +import express from 'express'; + +import { contactController } 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 { contactValidator } from '../../validators'; + +import type { NextFunction, Request, Response } from 'express'; +import type { Contact, ContactSearchParameters } from '../../types'; + +const router = express.Router(); +router.use(requireSomeAuth); +router.use(requireSomeGroup); + +// Get current user's contact information endpoint +router.get( + '/', + hasAuthorization(Resource.CONTACT, Action.READ), + (req: Request, res: Response, next: NextFunction): void => { + contactController.getCurrentUserContact(req, res, next); + } +); + +// Search users endpoint +router.get( + '/search', + hasAuthorization(Resource.CONTACT, Action.READ), + contactValidator.searchContacts, + (req: Request, res: Response, next: NextFunction): void => { + contactController.searchContacts(req, res, next); + } +); + +router.put( + '/:contactId', + hasAuthorization(Resource.CONTACT, Action.UPDATE), + contactValidator.updateContact, + (req: Request, res: Response, next: NextFunction): void => { + contactController.updateContact(req, res, next); + } +); + +export default router; diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index fe02c3b7..c90843d1 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -4,6 +4,7 @@ import { currentContext } from '../../middleware/authentication'; import accessRequest from './accessRequest'; import ats from './ats'; +import contact from './contact'; import docs from './docs'; import document from './document'; import enquiry from './enquiry'; @@ -26,6 +27,7 @@ router.get('/', (_req, res) => { endpoints: [ '/accessRequest', '/ats', + '/contact', '/docs', '/document', '/enquiry', @@ -43,6 +45,7 @@ router.get('/', (_req, res) => { router.use('/accessRequest', accessRequest); router.use('/docs', docs); router.use('/ats', ats); +router.use('/contact', contact); router.use('/document', document); router.use('/enquiry', enquiry); router.use('/note', note); diff --git a/app/src/services/contact.ts b/app/src/services/contact.ts index d974ff53..2fd247a8 100644 --- a/app/src/services/contact.ts +++ b/app/src/services/contact.ts @@ -3,7 +3,7 @@ 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'; +import { Contact, ContactSearchParameters, CurrentContext } from '../types'; const service = { /** @@ -12,7 +12,7 @@ const service = { * Generates IDs and timestamps automatically * @returns {Promise} The result of running the transaction */ - upsertContacts: async (activityId: string, data: Array, currentContext: CurrentContext) => { + upsertContacts: async (data: Array, currentContext: CurrentContext, activityId?: string) => { return await prisma.$transaction(async (trx) => { await Promise.all( data.map(async (x: Contact) => { @@ -24,13 +24,13 @@ const service = { ...generateCreateStamps(currentContext) }) }); - - await trx.activity_contact.create({ - data: { - activity_id: activityId, - contact_id: response.contact_id - } - }); + if (activityId) + 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) }), @@ -42,6 +42,54 @@ const service = { }) ); }); + }, + + /** + * @function searchContacts + * Search and filter for specific users + * @param {string[]} [params.contactId] Optional array of uuids representing the contact subject + * @param {string[]} [params.userId] Optional array of uuids representing the user subject + * @param {string} [params.email] Optional email string to match on + * @param {string} [params.phoneNumber] Optional phoneNumber string to match on + * @param {string} [params.firstName] Optional firstName string to match on + * @param {string} [params.contactApplicantRelationship] Optional contactApplicantRelationship string to match on + * @param {string} [params.lastName] Optional lastName string to match on + * @param {boolean} [params.contactPreference] Optional contactPreference string to match on + * @returns {Promise} The result of running the findMany operation + */ + searchContacts: async (params: ContactSearchParameters) => { + const response = await prisma.contact.findMany({ + where: { + AND: [ + { + contact_id: { in: params.contactId } + }, + { + user_id: { in: params.userId } + }, + { + contact_applicant_relationship: { contains: params.contactApplicantRelationship, mode: 'insensitive' } + }, + { + contact_preference: { contains: params.contactPreference, mode: 'insensitive' } + }, + { + email: { contains: params.email, mode: 'insensitive' } + }, + { + first_name: { contains: params.firstName, mode: 'insensitive' } + }, + { + last_name: { contains: params.lastName, mode: 'insensitive' } + }, + { + phone_number: { contains: params.phoneNumber, mode: 'insensitive' } + } + ] + } + }); + + return response ? response.map((x) => contact.fromPrismaModel(x)) : []; } }; diff --git a/app/src/services/user.ts b/app/src/services/user.ts index eb6116ed..5a0540fb 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -4,9 +4,10 @@ import { v4 as uuidv4, NIL } from 'uuid'; import prisma from '../db/dataConnection'; import { identity_provider, user } from '../db/models'; +import { contactService } from '../services'; import { parseIdentityKeyClaims } from '../utils/utils'; -import type { User, UserSearchParameters } from '../types'; +import type { Contact, CurrentContext, User, UserSearchParameters } from '../types'; const trxWrapper = (etrx: Prisma.TransactionClient | undefined = undefined) => (etrx ? etrx : prisma); @@ -170,12 +171,29 @@ const service = { idp: newUser.idp } }); - + let currentContext: CurrentContext; if (!oldUser) { response = await service.createUser(newUser, trx); + currentContext = { userId: response?.userId }; } else { response = await service.updateUser(oldUser.user_id, newUser, trx); + currentContext = { userId: oldUser?.user_id }; } + + const oldContacts: Array = await contactService.searchContacts({ + userId: [currentContext.userId ?? NIL] + }); + const newContact: Contact = { + contactId: oldContacts[0]?.contactId ?? undefined, + userId: currentContext.userId ?? NIL, + firstName: newUser.firstName, + lastName: newUser.lastName, + email: newUser.email, + phoneNumber: oldContacts[0]?.phoneNumber, + contactPreference: oldContacts[0]?.contactPreference, + contactApplicantRelationship: oldContacts[0]?.contactApplicantRelationship + }; + contactService.upsertContacts([newContact], currentContext); }); return response; diff --git a/app/src/types/ContactSearchParameters.ts b/app/src/types/ContactSearchParameters.ts new file mode 100644 index 00000000..c59f27ce --- /dev/null +++ b/app/src/types/ContactSearchParameters.ts @@ -0,0 +1,10 @@ +export type ContactSearchParameters = { + contactApplicantRelationship?: string; + contactPreference?: string; + contactId?: string[]; + email?: string; + firstName?: string; + lastName?: string; + phoneNumber?: string; + userId?: string[]; +}; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 7bcfc6b4..ff85e100 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -7,6 +7,7 @@ export type { BringForward } from './BringForward'; export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionExport } from './ChefsSubmissionExport'; export type { Contact } from './Contact'; +export type { ContactSearchParameters } from './ContactSearchParameters'; export type { CurrentAuthorization } from './CurrentAuthorization'; export type { CurrentContext } from './CurrentContext'; export type { Document } from './Document'; diff --git a/app/src/utils/enums/application.ts b/app/src/utils/enums/application.ts index a53a1352..83f92506 100644 --- a/app/src/utils/enums/application.ts +++ b/app/src/utils/enums/application.ts @@ -48,6 +48,7 @@ export enum Regex { export enum Resource { ACCESS_REQUEST = 'ACCESS_REQUEST', ATS = 'ATS', + CONTACT = 'CONTACT', DOCUMENT = 'DOCUMENT', ENQUIRY = 'ENQUIRY', NOTE = 'NOTE', diff --git a/app/src/validators/contact.ts b/app/src/validators/contact.ts new file mode 100644 index 00000000..0b803eac --- /dev/null +++ b/app/src/validators/contact.ts @@ -0,0 +1,65 @@ +import Joi from 'joi'; + +import { email, phoneNumber, uuidv4 } from './common'; +import { validate } from '../middleware/validation'; +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); + +const schema = { + searchContacts: { + query: Joi.object({ + userId: Joi.array().items(uuidv4).allow(null), + contactId: Joi.array().items(uuidv4).allow(null), + email: Joi.string().max(255).allow(null), + firstName: Joi.string().max(255).allow(null), + lastName: Joi.string().max(255).allow(null), + phoneNumber: Joi.number().max(255).allow(null), + contactApplicantRelationship: Joi.string() + .allow(null) + .valid(...PROJECT_RELATIONSHIP_LIST), + contactPreference: Joi.string() + .valid(...CONTACT_PREFERENCE_LIST) + .allow(null) + }) + }, + updateContact: { + body: Joi.object({ + userId: uuidv4.required(), + contactId: uuidv4.required(), + email: Joi.string().max(255).required(), + firstName: Joi.string().max(255).required(), + lastName: Joi.string().max(255).required(), + phoneNumber: phoneNumber.required(), + contactApplicantRelationship: Joi.string() + .required() + .valid(...PROJECT_RELATIONSHIP_LIST), + contactPreference: Joi.string() + .valid(...CONTACT_PREFERENCE_LIST) + .required() + }), + params: Joi.object({ + contactId: uuidv4.required() + }) + } +}; + +export default { + searchContacts: validate(schema.searchContacts), + updateContact: validate(schema.updateContact) +}; diff --git a/app/src/validators/contacts.ts b/app/src/validators/contacts.ts deleted file mode 100644 index 51d90c4c..00000000 --- a/app/src/validators/contacts.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 5b784de8..f3106c24 100644 --- a/app/src/validators/enquiry.ts +++ b/app/src/validators/enquiry.ts @@ -2,7 +2,7 @@ import Joi from 'joi'; import { basicEnquiry } from './basic'; import { uuidv4 } from './common'; -import { contacts } from './contacts'; +import { contacts } from './contact'; import { validate } from '../middleware/validation'; import { YES_NO_LIST } from '../utils/constants/application'; import { APPLICATION_STATUS_LIST, INTAKE_STATUS_LIST } from '../utils/constants/housing'; diff --git a/app/src/validators/index.ts b/app/src/validators/index.ts index e6ab99e2..d49bf118 100644 --- a/app/src/validators/index.ts +++ b/app/src/validators/index.ts @@ -1,5 +1,6 @@ export { default as accessRequestValidator } from './accessRequest'; export { default as atsValidator } from './ats'; +export { default as contactValidator } from './contact'; export { default as documentValidator } from './document'; export { default as enquiryValidator } from './enquiry'; export { default as noteValidator } from './note'; diff --git a/app/src/validators/submission.ts b/app/src/validators/submission.ts index a73d9fd2..d6c1e302 100644 --- a/app/src/validators/submission.ts +++ b/app/src/validators/submission.ts @@ -3,7 +3,7 @@ import Joi from 'joi'; import { appliedPermit } from './appliedPermit'; import { basicIntake } from './basic'; import { activityId, email, uuidv4 } from './common'; -import { contacts } from './contacts'; +import { contacts } from './contact'; import { housing } from './housing'; import { permits } from './permits'; diff --git a/app/tests/unit/controllers/contact.spec.ts b/app/tests/unit/controllers/contact.spec.ts new file mode 100644 index 00000000..b683ac9c --- /dev/null +++ b/app/tests/unit/controllers/contact.spec.ts @@ -0,0 +1,219 @@ +import { contactService } from '../../../src/services'; +import contactController from '../../../src/controllers/contact'; +import { Request, Response } from 'express'; + +// 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 }; + +describe('contactController', () => { + const next = jest.fn(); + + describe('searchContacts', () => { + const searchContactsSpy = jest.spyOn(contactService, 'searchContacts'); + + it('should return 200 if all good', async () => { + const req = { + query: { userId: '5e3f0c19-8664-4a43-ac9e-210da336e923' }, + currentContext: CURRENT_CONTEXT + } as unknown as Request; + + const contacts = [ + { + contactId: 'contact123', + userId: 'user123', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '123-456-7890', + email: 'john.doe@example.com', + contactPreference: 'email', + contactApplicantRelationship: 'applicant' + } + ]; + + searchContactsSpy.mockResolvedValue(contacts); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contactController.searchContacts(req as any, res as unknown as Response, next); + + expect(searchContactsSpy).toHaveBeenCalledTimes(1); + expect(searchContactsSpy).toHaveBeenCalledWith({ + userId: ['5e3f0c19-8664-4a43-ac9e-210da336e923'], + contactId: undefined, + email: undefined, + firstName: undefined, + lastName: undefined, + contactApplicantRelationship: undefined, + phoneNumber: undefined + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(contacts); + }); + + it('adds dashes to user IDs', async () => { + const req = { + query: { userId: '5e3f0c1986644a43ac9e210da336e923,8b9dedd279d442c6b82f52844a8e2757' }, + currentContext: CURRENT_CONTEXT + } as unknown as Request; + + const contacts = [ + { + contactId: 'contact123', + userId: 'user123', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '123-456-7890', + email: 'john.doe@example.com', + contactPreference: 'email', + contactApplicantRelationship: 'applicant' + } + ]; + + searchContactsSpy.mockResolvedValue(contacts); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contactController.searchContacts(req as any, res as unknown as Response, next); + + expect(searchContactsSpy).toHaveBeenCalledTimes(1); + expect(searchContactsSpy).toHaveBeenCalledWith({ + userId: ['5e3f0c19-8664-4a43-ac9e-210da336e923', '8b9dedd2-79d4-42c6-b82f-52844a8e2757'], + contactId: undefined, + email: undefined, + firstName: undefined, + lastName: undefined, + contactApplicantRelationship: undefined, + phoneNumber: undefined + }); + }); + + it('adds dashes to contact IDs', async () => { + const req = { + query: { contactId: '5e3f0c1986644a43ac9e210da336e923,8b9dedd279d442c6b82f52844a8e2757' }, + currentContext: CURRENT_CONTEXT + } as unknown as Request; + + const contacts = [ + { + contactId: 'contact123', + userId: 'user123', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '123-456-7890', + email: 'john.doe@example.com', + contactPreference: 'email', + contactApplicantRelationship: 'applicant' + } + ]; + + searchContactsSpy.mockResolvedValue(contacts); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contactController.searchContacts(req as any, res as unknown as Response, next); + + expect(searchContactsSpy).toHaveBeenCalledTimes(1); + expect(searchContactsSpy).toHaveBeenCalledWith({ + contactId: ['5e3f0c19-8664-4a43-ac9e-210da336e923', '8b9dedd2-79d4-42c6-b82f-52844a8e2757'], + userId: undefined, + email: undefined, + firstName: undefined, + lastName: undefined, + contactApplicantRelationship: undefined, + phoneNumber: undefined + }); + }); + + it('calls next if the contact service fails to list contacts', async () => { + const req = { + query: { userId: '5e3f0c19-8664-4a43-ac9e-210da336e923' }, + currentContext: CURRENT_CONTEXT + } as unknown as Request; + + searchContactsSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contactController.searchContacts(req as any, res as unknown as Response, next); + + expect(searchContactsSpy).toHaveBeenCalledTimes(1); + expect(searchContactsSpy).toHaveBeenCalledWith({ + userId: ['5e3f0c19-8664-4a43-ac9e-210da336e923'], + contactId: undefined, + email: undefined, + firstName: undefined, + lastName: undefined, + contactApplicantRelationship: undefined, + phoneNumber: undefined + }); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateContact', () => { + const upsertContactsSpy = jest.spyOn(contactService, 'upsertContacts'); + + it('should return 200 if contact is updated successfully', async () => { + const req = { + body: { + userId: '5e3f0c19-8664-4a43-ac9e-210da336e923', + email: 'first.last@gov.bc.ca', + firstName: 'First', + lastName: 'Last' + }, + currentContext: CURRENT_CONTEXT + } as unknown as Request; + + upsertContactsSpy.mockResolvedValue(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contactController.updateContact(req as any, res as unknown as Response, next); + + expect(upsertContactsSpy).toHaveBeenCalledTimes(1); + expect(upsertContactsSpy).toHaveBeenCalledWith([req.body], req.currentContext); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('calls next if the contact service fails to update contact', async () => { + const req = { + body: { + userId: '5e3f0c19-8664-4a43-ac9e-210da336e923', + email: 'first.last@gov.bc.ca', + firstName: 'First', + lastName: 'Last' + }, + currentContext: CURRENT_CONTEXT + } as unknown as Request; + + upsertContactsSpy.mockImplementationOnce(() => { + throw new Error(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contactController.updateContact(req as any, res as unknown as Response, next); + + expect(upsertContactsSpy).toHaveBeenCalledTimes(1); + expect(upsertContactsSpy).toHaveBeenCalledWith([req.body], req.currentContext); + expect(res.status).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/tests/unit/validators/contacts.spec.ts b/app/tests/unit/validators/contact.spec.ts similarity index 99% rename from app/tests/unit/validators/contacts.spec.ts rename to app/tests/unit/validators/contact.spec.ts index 880f03bf..3a63a482 100644 --- a/app/tests/unit/validators/contacts.spec.ts +++ b/app/tests/unit/validators/contact.spec.ts @@ -1,4 +1,4 @@ -import { contacts as contactsSchema } from '../../../src/validators/contacts'; +import { contacts as contactsSchema } from '../../../src/validators/contact'; describe('contactsSchema', () => { it('should only accept string values for each field', () => { diff --git a/frontend/src/components/common/HeaderMenu.vue b/frontend/src/components/common/HeaderMenu.vue index b23bdd3f..10e7721c 100644 --- a/frontend/src/components/common/HeaderMenu.vue +++ b/frontend/src/components/common/HeaderMenu.vue @@ -16,6 +16,13 @@ const { getIsAuthenticated, getProfile } = storeToRefs(useAuthNStore()); // State const { t } = useI18n(); const items = ref([ + { + label: t('headerMenu.contactProfile'), + icon: 'pi pi-user', + command: () => { + router.push({ name: RouteName.CONTACT_PROFILE }); + } + }, { label: t('headerMenu.logout'), icon: 'pi pi-sign-out', diff --git a/frontend/src/locales/en-CA.json b/frontend/src/locales/en-CA.json index 87dcd0a5..3df35399 100644 --- a/frontend/src/locales/en-CA.json +++ b/frontend/src/locales/en-CA.json @@ -2,6 +2,22 @@ "collectionDisclaimer": { "disclaimer": "This information is being collected under the legal authority of section 26 (c)(e) and 27 (1)(a)(i) of the Freedom of Information and Protection of Privacy Act (the Act) and is being used for the purpose of creating a client relationship between you or your organization and Government of British Columbia. It may also be shared when strictly necessary with partner agencies that are also subject to the provisions of the Act. Personal information may be used by the Permitting Solutions Branch for survey purposes. If you have any questions regarding the use of this personal information, please contact Housing Authorizations at" }, + "contactProfileView": { + "cancel": "Cancel", + "contactProfile": "Contact profile", + "edit": "Edit", + "email": "Email", + "failedToSaveTheForm": "Failed to save the form", + "fillOutProfile": "Please fill out your contact profile", + "firstName": "First name", + "formSaved": "Form saved", + "getStarted": "Get started", + "lastName": "Last name", + "phone": "Phone", + "preferredContact": "Preferred contact method", + "relationshipToProject": "Relationship to project", + "save": "Save" + }, "deleteDocument": { "deleteTooltip": "Delete document" }, @@ -71,6 +87,7 @@ "name": "Permit Connect Services" }, "headerMenu": { + "contactProfile": "Contact profile", "logout": "Log out" }, "housing": { @@ -84,7 +101,7 @@ "onThisPage": "On this page", "of": "of", "projectState": "Project state", - "projectsEmpty": "Submit a new project to the Navigator Service to found out what permits are required", + "projectsEmpty": "Submit a new project to the Navigator Service to find out what permits are required", "projectsTooltip": "Submit a housing project to the Navigator service to find out what permits are required for your projects, track permit applications, and submit related enquiries.", "submitNewEnquiry": "Submit a new enquiry", "submitNewProject": "Submit a new project" diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 31425679..e0d744b5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,13 +1,13 @@ import { storeToRefs } from 'pinia'; import { createRouter, createWebHistory, useRouter } from 'vue-router'; -import { AuthService, yarsService } from '@/services'; -import { useAppStore, useAuthNStore, useAuthZStore } from '@/store'; +import { AuthService, contactService, yarsService } from '@/services'; +import { useAppStore, useAuthNStore, useAuthZStore, useContactStore } from '@/store'; import { NavigationPermission } from '@/store/authzStore'; import { RouteName, StorageKey } from '@/utils/enums/application'; import type { RouteLocationNormalizedGeneric, RouteRecordRaw } from 'vue-router'; - +import type { Contact } from '@/types'; /** * @function accessHandler * Checks for user access to the requested route and redirect if necessary @@ -33,12 +33,15 @@ function accessHandler(to: RouteLocationNormalizedGeneric) { async function bootstrap() { const authnStore = useAuthNStore(); const authzStore = useAuthZStore(); + const contactStore = useContactStore(); const { getIsAuthenticated } = storeToRefs(authnStore); if (getIsAuthenticated.value && !authzStore.getGroups.length) { const permissions = await yarsService.getPermissions(); authzStore.setPermissions(permissions.data); + const contact: Contact = (await contactService.getCurrentUserContact())?.data; + contactStore.setContact(contact); } } @@ -63,6 +66,13 @@ const routes: Array = [ name: RouteName.HOME, component: () => import('@/views/HomeView.vue') }, + { + path: '/contact/profile', + name: RouteName.CONTACT_PROFILE, + component: () => import('@/views/contact/ContactProfileView.vue'), + beforeEnter: accessHandler, + meta: { requiresAuth: true, access: [NavigationPermission.HOUSING_CONTACT_MANAGEMENT] } + }, { path: '/developer', name: RouteName.DEVELOPER, diff --git a/frontend/src/services/contactService.ts b/frontend/src/services/contactService.ts new file mode 100644 index 00000000..ff66d0f0 --- /dev/null +++ b/frontend/src/services/contactService.ts @@ -0,0 +1,33 @@ +import { appAxios } from './interceptors'; + +import type { AxiosResponse } from 'axios'; +import type { ContactSearchParameters } from '@/types'; + +const PATH = 'contact'; + +export default { + /** + * @function getCurrentUserContact + * Returns current user's contact details + * @returns {Promise} An axios response or empty object + */ + getCurrentUserContact(): Promise { + return appAxios().get(`${PATH}/`); + }, + /** + * @function searchContacts + * Returns a list of users based on the provided filtering parameters + * @param {SearchUsersOptions} params SearchUsersOptions object containing the data to filter against + * @returns {Promise} An axios response or empty array + */ + searchContacts(params: ContactSearchParameters): Promise { + return appAxios().get(`${PATH}/search`, { params: params }); + }, + /** + * @function updateEnquiry + * @returns {Promise} An axios response + */ + updateContact(data?: any) { + return appAxios().put(`${PATH}/${data.contactId}`, data); + } +}; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index f3d19b55..8b65cde6 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -3,6 +3,7 @@ export { default as atsService } from './atsService'; export { default as AuthService } from './authService'; export { default as ConfigService } from './configService'; export { default as comsService } from './comsService'; +export { default as contactService } from './contactService'; export { default as documentService } from './documentService'; export { default as enquiryService } from './enquiryService'; export { default as externalApiService } from './externalApiService'; diff --git a/frontend/src/store/authzStore.ts b/frontend/src/store/authzStore.ts index 55201ebc..d2e92686 100644 --- a/frontend/src/store/authzStore.ts +++ b/frontend/src/store/authzStore.ts @@ -8,6 +8,7 @@ import type { Permission } from '@/types'; export enum NavigationPermission { HOUSING = 'housing', + HOUSING_CONTACT_MANAGEMENT = 'housing.contactmanagement', HOUSING_DROPDOWN = 'housing.dropdown', HOUSING_ENQUIRY = 'housing.enquiry', HOUSING_ENQUIRY_INTAKE = 'housing.enquiry.intake', @@ -57,6 +58,7 @@ const NavigationAuthorizationMap = [ group: GroupName.PROPONENT, permissions: [ NavigationPermission.HOUSING, + NavigationPermission.HOUSING_CONTACT_MANAGEMENT, NavigationPermission.HOUSING_DROPDOWN, NavigationPermission.HOUSING_ENQUIRY_INTAKE, NavigationPermission.HOUSING_SUBMISSION_INTAKE, diff --git a/frontend/src/store/contactStore.ts b/frontend/src/store/contactStore.ts new file mode 100644 index 00000000..63d4b7ca --- /dev/null +++ b/frontend/src/store/contactStore.ts @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia'; +import { computed, readonly, ref } from 'vue'; + +import type { Ref } from 'vue'; +import type { Contact } from '@/types'; + +export type ContactStoreState = { + contact: Ref; +}; + +export const useContactStore = defineStore('contact', () => { + // State + const state: ContactStoreState = { + contact: ref(undefined) + }; + + // Getters + const getters = { + getContact: computed(() => state.contact.value) + }; + + // Actions + function setContact(data: Contact) { + state.contact.value = data; + } + + return { + // State + state: readonly(state), + + // Getters + ...getters, + + // Actions + setContact + }; +}); + +export default useContactStore; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index e850a1d1..1307e71d 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -2,6 +2,7 @@ export { default as useAppStore } from './appStore'; export { default as useAuthNStore } from './authnStore'; export { default as useAuthZStore } from './authzStore'; export { default as useConfigStore } from './configStore'; +export { default as useContactStore } from './contactStore'; export { default as useEnquiryStore } from './enquiryStore'; export { default as useSubmissionStore } from './submissionStore'; export { default as useTypeStore } from './typeStore'; diff --git a/frontend/src/types/ContactSearchParameters.ts b/frontend/src/types/ContactSearchParameters.ts new file mode 100644 index 00000000..c59f27ce --- /dev/null +++ b/frontend/src/types/ContactSearchParameters.ts @@ -0,0 +1,10 @@ +export type ContactSearchParameters = { + contactApplicantRelationship?: string; + contactPreference?: string; + contactId?: string[]; + email?: string; + firstName?: string; + lastName?: string; + phoneNumber?: string; + userId?: string[]; +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 59ea0f50..577bf4f4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -4,6 +4,7 @@ export type { BasicBCeIDAttribute } from './BasicBCeIDAttribute'; export type { BringForward } from './BringForward'; export type { BusinessBCeIDAttribute } from './BusinessBCeIDAttribute'; export type { Contact } from './Contact'; +export type { ContactSearchParameters } from './ContactSearchParameters'; export type { Document } from './Document'; export type { Draft } from './Draft'; export type { Email } from './Email'; diff --git a/frontend/src/utils/enums/application.ts b/frontend/src/utils/enums/application.ts index ee592021..9d03b02a 100644 --- a/frontend/src/utils/enums/application.ts +++ b/frontend/src/utils/enums/application.ts @@ -55,6 +55,7 @@ export enum Regex { } export enum RouteName { + CONTACT_PROFILE = 'contact_profile', DEVELOPER = 'developer', FORBIDDEN = 'forbidden', HOME = 'home', diff --git a/frontend/src/views/contact/ContactProfileView.vue b/frontend/src/views/contact/ContactProfileView.vue new file mode 100644 index 00000000..2f6bdd7f --- /dev/null +++ b/frontend/src/views/contact/ContactProfileView.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/frontend/src/views/oidc/OidcCallbackView.vue b/frontend/src/views/oidc/OidcCallbackView.vue index 2e38e328..4035bff8 100644 --- a/frontend/src/views/oidc/OidcCallbackView.vue +++ b/frontend/src/views/oidc/OidcCallbackView.vue @@ -3,12 +3,15 @@ import { onMounted } from 'vue'; import { useRouter } from 'vue-router'; import { Spinner } from '@/components/layout'; -import { useAuthNStore, useAuthZStore } from '@/store'; +import { useAuthNStore, useAuthZStore, useContactStore } from '@/store'; import { StorageKey } from '@/utils/enums/application'; import { storeToRefs } from 'pinia'; -import { yarsService } from '@/services'; +import { contactService, yarsService } from '@/services'; + +import type { Contact } from '@/types'; const authnStore = useAuthNStore(); +const contactStore = useContactStore(); const router = useRouter(); const { getIsAuthenticated } = storeToRefs(useAuthNStore()); @@ -19,6 +22,8 @@ onMounted(async () => { if (getIsAuthenticated.value) { const permissions = await yarsService.getPermissions(); useAuthZStore().setPermissions(permissions.data); + const contact: Contact = (await contactService.getCurrentUserContact())?.data; + contactStore.setContact(contact); } // Return user back to original login entrypoint if specified diff --git a/frontend/tests/unit/service/contactService.spec.ts b/frontend/tests/unit/service/contactService.spec.ts new file mode 100644 index 00000000..03ed4b4b --- /dev/null +++ b/frontend/tests/unit/service/contactService.spec.ts @@ -0,0 +1,75 @@ +import { contactService } from '@/services'; +import { appAxios } from '@/services/interceptors'; + +import type { Contact, ContactSearchParameters } from '@/types'; + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn() + }) +})); + +const sampleContact: Contact = { + contactId: 'contact123', + userId: 'user123', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '123-456-7890', + email: 'john.doe@example.com', + contactPreference: 'email', + contactApplicantRelationship: 'applicant', + createdBy: 'testCreatedBy', + createdAt: new Date().toISOString(), + updatedBy: 'testUpdatedAt', + updatedAt: new Date().toISOString() +}; +const sampleContactSearchParameters: ContactSearchParameters = { + contactApplicantRelationship: 'applicant', + contactPreference: 'email', + contactId: ['contact123', 'contact456'], + email: 'john.doe@example.com', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '123-456-7890', + userId: ['user123', 'user456'] +}; + +const getSpy = vi.fn(); +const deleteSpy = vi.fn(); +const patchSpy = vi.fn(); +const putSpy = vi.fn(); + +vi.mock('@/services/interceptors'); +vi.mocked(appAxios).mockReturnValue({ + get: getSpy, + delete: deleteSpy, + patch: patchSpy, + put: putSpy +} as any); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('contactService', () => { + it('calls getCurrentUserContact', () => { + contactService.getCurrentUserContact(); + + expect(getSpy).toHaveBeenCalledTimes(1); + }); + + it('calls searchContacts with correct data', () => { + contactService.searchContacts(sampleContactSearchParameters); + + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith('contact/search', { params: sampleContactSearchParameters }); + }); + + it('calls updateContact with correct data', () => { + contactService.updateContact(sampleContact); + + expect(putSpy).toHaveBeenCalledTimes(1); + expect(putSpy).toHaveBeenCalledWith(`contact/${sampleContact.contactId}`, sampleContact); + }); +}); diff --git a/frontend/tests/unit/views/contact/ContactProfileView.spec.ts b/frontend/tests/unit/views/contact/ContactProfileView.spec.ts new file mode 100644 index 00000000..09b1c1be --- /dev/null +++ b/frontend/tests/unit/views/contact/ContactProfileView.spec.ts @@ -0,0 +1,70 @@ +import type { AxiosResponse } from 'axios'; +import { shallowMount } from '@vue/test-utils'; + +import ContactProfileView from '@/views/contact/ContactProfileView.vue'; +import { contactService } from '@/services'; +import { createTestingPinia } from '@pinia/testing'; +import PrimeVue from 'primevue/config'; +import ToastService from 'primevue/toastservice'; + +import type { Contact } from '@/types'; + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: vi.fn() + }) +})); + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: vi.fn() + }) +})); + +const testContact: Contact = { + contactId: 'contact123', + userId: 'user123', + firstName: 'John', + lastName: 'Doe', + phoneNumber: '123-456-7890', + email: 'john.doe@example.com', + contactPreference: 'email', + contactApplicantRelationship: 'applicant' +}; + +const useContactService = vi.spyOn(contactService, 'searchContacts'); +useContactService.mockResolvedValue({ data: [testContact] } as AxiosResponse); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + sessionStorage.clear(); +}); + +const wrapperSettings = () => ({ + global: { + plugins: [ + () => + createTestingPinia({ + initialState: { + auth: { + user: {} + } + } + }), + PrimeVue, + ToastService + ], + stubs: ['font-awesome-icon'] + } +}); + +describe('ContactProfileView.vue', () => { + it('renders', () => { + const wrapper = shallowMount(ContactProfileView, wrapperSettings()); + + expect(wrapper).toBeTruthy(); + }); +});