diff --git a/app/src/controllers/contact.ts b/app/src/controllers/contact.ts index 3eff4a13..d02ef6aa 100644 --- a/app/src/controllers/contact.ts +++ b/app/src/controllers/contact.ts @@ -15,6 +15,11 @@ const controller = { let userIds = mixedQueryToArray(req.query.userId); if (!userIds && req.currentContext.userId) userIds = [req.currentContext.userId]; + const identityIds = mixedQueryToArray(req.query.identityId); + + console.log('\n\n\nidentityIds>>>>>' + identityIds); + console.log('\n\n\ncontactIds>>>>>' + contactIds); + console.log('\n\n\nuserIds>>>>>' + userIds); const response = await contactService.searchContacts({ userId: userIds ? userIds.map((id) => addDashesToUuid(id)) : userIds, @@ -30,6 +35,7 @@ const controller = { next(e); } }, + updateContact: async (req: Request, res: Response, next: NextFunction) => { try { const response = await contactService.upsertContacts([req.body], req.currentContext); diff --git a/app/src/db/migrations/20250107000000_018-contact-management.ts b/app/src/db/migrations/20250107000000_019-contact-management.ts similarity index 99% rename from app/src/db/migrations/20250107000000_018-contact-management.ts rename to app/src/db/migrations/20250107000000_019-contact-management.ts index 301da504..4cd53202 100644 --- a/app/src/db/migrations/20250107000000_018-contact-management.ts +++ b/app/src/db/migrations/20250107000000_019-contact-management.ts @@ -208,17 +208,14 @@ export async function up(knex: Knex): Promise { // 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); }); diff --git a/app/src/types/ContactSearchParameters.ts b/app/src/types/ContactSearchParameters.ts index c59f27ce..edfa60d9 100644 --- a/app/src/types/ContactSearchParameters.ts +++ b/app/src/types/ContactSearchParameters.ts @@ -4,6 +4,7 @@ export type ContactSearchParameters = { contactId?: string[]; email?: string; firstName?: string; + identityId?: string; lastName?: string; phoneNumber?: string; userId?: string[]; diff --git a/app/src/validators/contact.ts b/app/src/validators/contact.ts index 679f6994..19a5bf35 100644 --- a/app/src/validators/contact.ts +++ b/app/src/validators/contact.ts @@ -24,6 +24,7 @@ export const contacts = Joi.array() const schema = { searchContacts: { query: Joi.object({ + identityId: uuidv4.allow(null), userId: Joi.array().items(uuidv4).allow(null), contactId: Joi.array().items(uuidv4).allow(null), email: Joi.string().max(255).allow(null), @@ -45,7 +46,7 @@ const schema = { email: Joi.string().max(255).required(), firstName: Joi.string().max(255).required(), lastName: Joi.string().max(255).required(), - phoneNumber: Joi.number().max(255).required(), + phoneNumber: phoneNumber.required(), contactApplicantRelationship: Joi.string() .required() .valid(...PROJECT_RELATIONSHIP_LIST), 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/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..86ece0f9 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -63,6 +63,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..0f9f1cab --- /dev/null +++ b/frontend/src/services/contactService.ts @@ -0,0 +1,25 @@ +import { appAxios } from './interceptors'; + +import type { AxiosResponse } from 'axios'; +import type { ContactSearchParameters } from '@/types'; + +const PATH = 'contact'; + +export default { + /** + * @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}`, { 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/types/ContactSearchParameters.ts b/frontend/src/types/ContactSearchParameters.ts new file mode 100644 index 00000000..edfa60d9 --- /dev/null +++ b/frontend/src/types/ContactSearchParameters.ts @@ -0,0 +1,11 @@ +export type ContactSearchParameters = { + contactApplicantRelationship?: string; + contactPreference?: string; + contactId?: string[]; + email?: string; + firstName?: string; + identityId?: 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..f11d8901 --- /dev/null +++ b/frontend/src/views/contact/ContactProfileView.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/tests/unit/service/contactService.spec.ts b/frontend/tests/unit/service/contactService.spec.ts new file mode 100644 index 00000000..2de28ea2 --- /dev/null +++ b/frontend/tests/unit/service/contactService.spec.ts @@ -0,0 +1,69 @@ +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 searchContacts with correct data', () => { + contactService.searchContacts(sampleContactSearchParameters); + + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith('contact', { 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..8512c454 --- /dev/null +++ b/frontend/tests/unit/views/contact/ContactProfileView.spec.ts @@ -0,0 +1,58 @@ +import type { AxiosResponse } from 'axios'; +import { shallowMount } from '@vue/test-utils'; + +import ContactProfileView from '@/views/contact/ContactProfileView.vue'; +import { contactService } from '@/services'; +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: [() => PrimeVue, ToastService], + stubs: ['font-awesome-icon'] + } +}); + +describe('ContactProfileView.vue', () => { + it('renders', () => { + const wrapper = shallowMount(ContactProfileView, wrapperSettings()); + + expect(wrapper).toBeTruthy(); + }); +});