diff --git a/api/src/controllers/information-sharing-agreements-controller.ts b/api/src/controllers/information-sharing-agreements-controller.ts index 6da92694..62c0d5dd 100644 --- a/api/src/controllers/information-sharing-agreements-controller.ts +++ b/api/src/controllers/information-sharing-agreements-controller.ts @@ -120,11 +120,7 @@ export class InformationSharingAgreementsController extends BaseController { + const existingUser = await User.findOne({ + where: { email: this.email }, + }) + if (!isNil(existingUser)) { + return existingUser + } + + const employee = await yukonGovernmentIntegration.fetchEmployee(this.email) + if (isNil(employee)) { + throw new Error(`No Yukon Government directory record found for email: ${this.email}`) + } + + return Users.CreateInternalService.perform( + { + email: employee.email, + firstName: employee.first_name, + lastName: employee.last_name, + displayName: employee.full_name, + department: employee.department, + division: employee.division, + branch: employee.branch, + unit: employee.unit, + title: employee.title, + }, + this.currentUser + ) + } +} + +export default EnsureFromDirectoryEmailService diff --git a/api/src/services/users/index.ts b/api/src/services/users/index.ts index 2bec3874..2c42b92f 100644 --- a/api/src/services/users/index.ts +++ b/api/src/services/users/index.ts @@ -9,5 +9,6 @@ export { DeactivateService } from "./deactivate-service" // Special Services export { DirectorySyncService } from "./directory-sync-service" export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service" +export { EnsureFromDirectoryEmailService } from "./ensure-from-directory-email-service" export { CreateExternalService } from "./create-external-service" export { CreateInternalService } from "./create-internal-service" diff --git a/api/tests/controllers/users/directory-users-controller.test.ts b/api/tests/controllers/users/directory-users-controller.test.ts new file mode 100644 index 00000000..bf8319b7 --- /dev/null +++ b/api/tests/controllers/users/directory-users-controller.test.ts @@ -0,0 +1,105 @@ +import type * as Integrations from "@/integrations" + +import { User } from "@/models" +import { yukonGovernmentIntegration } from "@/integrations" + +import { externalOrganizationFactory, userFactory } from "@/tests/factories" +import { mockCurrentUser, request } from "@/tests/support" + +vi.mock("@/integrations", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + yukonGovernmentIntegration: { + ...actual.yukonGovernmentIntegration, + fetchEmployee: vi.fn(), + }, + } +}) + +const mockedFetchEmployee = vi.mocked(yukonGovernmentIntegration.fetchEmployee) + +describe("api/src/controllers/users/directory-users-controller.ts", () => { + describe("DirectoryUsersController", () => { + describe("#create", () => { + let currentUser: User + + beforeEach(async () => { + currentUser = await userFactory.create({ isExternal: false }) + mockCurrentUser(currentUser) + }) + + test("returns the existing internal user without creating a duplicate", async () => { + const manager = await userFactory.create({ + email: "manager@example.com", + isExternal: false, + }) + const userCountBefore = await User.count() + + const response = await request() + .post("/api/users/directory-users") + .send({ email: "manager@example.com" }) + + expect(response.status).toBe(201) + expect(response.body.user.id).toBe(manager.id) + expect(response.body.user.email).toBe("manager@example.com") + expect(mockedFetchEmployee).not.toHaveBeenCalled() + expect(await User.count()).toBe(userCountBefore) + }) + + test("creates an internal user from the directory when the email is new", async () => { + mockedFetchEmployee.mockResolvedValue({ + full_name: "Jane Manager", + first_name: "Jane", + last_name: "Manager", + organization: null, + department: "HPW", + division: null, + branch: null, + unit: null, + title: "Director", + email: "jane.manager@yukon.ca", + suite: "", + phone_office: "", + fax_office: "", + mobile: "", + office: "", + address: "", + po_box: "", + community: "", + postal_code: "", + latitude: null, + longitude: null, + mailcode: "", + manager: "", + username: "jmanager", + }) + + const response = await request() + .post("/api/users/directory-users") + .send({ email: "jane.manager@yukon.ca" }) + + expect(response.status).toBe(201) + const createdManager = await User.findOne({ where: { email: "jane.manager@yukon.ca" } }) + expect(createdManager).not.toBeNull() + expect(createdManager?.isExternal).toBe(false) + expect(response.body.user.id).toBe(createdManager?.id) + }) + + test("returns 403 when the current user is external", async () => { + const externalOrganization = await externalOrganizationFactory.create() + const externalUser = await userFactory.create({ + isExternal: true, + externalOrganizationId: externalOrganization.id, + }) + mockCurrentUser(externalUser) + + const response = await request() + .post("/api/users/directory-users") + .send({ email: "manager@example.com" }) + + expect(response.status).toBe(403) + }) + }) + }) +}) diff --git a/api/tests/services/users/ensure-from-directory-email-service.test.ts b/api/tests/services/users/ensure-from-directory-email-service.test.ts new file mode 100644 index 00000000..7b0d19e7 --- /dev/null +++ b/api/tests/services/users/ensure-from-directory-email-service.test.ts @@ -0,0 +1,88 @@ +import { yukonGovernmentIntegration } from "@/integrations" +import { User } from "@/models" + +import { userFactory } from "@/tests/factories" + +import EnsureFromDirectoryEmailService from "@/services/users/ensure-from-directory-email-service" + +vi.mock("@/integrations", () => ({ + yukonGovernmentIntegration: { + fetchEmployee: vi.fn(), + }, +})) + +const mockedYukonGovernmentIntegration = vi.mocked(yukonGovernmentIntegration) + +function buildDirectoryEmployee(overrides: Partial> = {}) { + return { + full_name: "Jane Manager", + first_name: "Jane", + last_name: "Manager", + organization: null, + department: "HPW", + division: "Digital Services", + branch: "Applications", + unit: "Platform", + title: "Director", + email: "jane.manager@yukon.ca", + suite: "", + phone_office: "", + fax_office: "", + mobile: "", + office: "", + address: "", + po_box: "", + community: "", + postal_code: "", + latitude: null, + longitude: null, + mailcode: "", + manager: "", + username: "jmanager", + ...overrides, + } +} + +describe("api/src/services/users/ensure-from-directory-email-service.ts", () => { + describe("EnsureFromDirectoryEmailService", () => { + describe("#perform", () => { + test("when a user with the email already exists, returns it without touching the directory", async () => { + const currentUser = await userFactory.create() + const existingUser = await userFactory.create({ email: "manager@example.com" }) + + const result = await EnsureFromDirectoryEmailService.perform( + "manager@example.com", + currentUser + ) + + expect(result.id).toEqual(existingUser.id) + expect(mockedYukonGovernmentIntegration.fetchEmployee).not.toHaveBeenCalled() + }) + + test("when no user exists, creates an internal user from the directory record", async () => { + const currentUser = await userFactory.create() + mockedYukonGovernmentIntegration.fetchEmployee.mockResolvedValue(buildDirectoryEmployee()) + + const result = await EnsureFromDirectoryEmailService.perform( + "jane.manager@yukon.ca", + currentUser + ) + + expect(result).toBeInstanceOf(User) + expect(result.email).toEqual("jane.manager@yukon.ca") + expect(result.isExternal).toEqual(false) + expect(result.department).toEqual("HPW") + expect(result.title).toEqual("Director") + }) + + test("when no user exists and the directory has no matching record, throws", async () => { + const currentUser = await userFactory.create() + mockedYukonGovernmentIntegration.fetchEmployee.mockResolvedValue(null) + + await expect( + EnsureFromDirectoryEmailService.perform("missing@yukon.ca", currentUser) + ).rejects.toThrow("No Yukon Government directory record found") + }) + }) + }) +}) diff --git a/web/src/api/users-api.ts b/web/src/api/users-api.ts index a0c143d9..a1daff18 100644 --- a/web/src/api/users-api.ts +++ b/web/src/api/users-api.ts @@ -225,6 +225,11 @@ export const usersApi = { const { data } = await http.post(`/api/users/${userId}/directory-sync`) return data }, + + async ensureFromDirectory(email: string): Promise<{ user: UserAsReference }> { + const { data } = await http.post("/api/users/directory-users", { email }) + return data + }, } export default usersApi diff --git a/web/src/components/information-sharing-agreements/InformationSharingAgreementBasicInformationEditCard.vue b/web/src/components/information-sharing-agreements/InformationSharingAgreementBasicInformationEditCard.vue index a1a01d09..f219bfe4 100644 --- a/web/src/components/information-sharing-agreements/InformationSharingAgreementBasicInformationEditCard.vue +++ b/web/src/components/information-sharing-agreements/InformationSharingAgreementBasicInformationEditCard.vue @@ -115,14 +115,13 @@ cols="12" md="6" > - @@ -140,6 +139,7 @@ import useUser from "@/use/use-user" import UserSearchableAutocomplete, { type UserAsIndex, } from "@/components/users/UserSearchableAutocomplete.vue" +import YukonGovernmentEmployeeSearchableAutocomplete from "@/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue" const props = defineProps<{ title: string | null | undefined @@ -148,7 +148,7 @@ const props = defineProps<{ externalGroupContactTitle: string | null | undefined internalGroupContactId: number | null | undefined internalGroupContactTitle: string | null | undefined - internalGroupSecondaryContactId: number | null | undefined + internalGroupSecondaryContactEmail: string | null | undefined }>() const emit = defineEmits<{ @@ -158,7 +158,7 @@ const emit = defineEmits<{ "update:externalGroupContactTitle": [value: string | null | undefined] "update:internalGroupContactId": [value: number | null | undefined] "update:internalGroupContactTitle": [value: string | null | undefined] - "update:internalGroupSecondaryContactId": [value: number | null | undefined] + "update:internalGroupSecondaryContactEmail": [value: string | null | undefined] }>() const { externalGroupContactId } = toRefs(props) @@ -183,9 +183,6 @@ const externalGroupContactWhere = computed(() => ({ const internalGroupContactWhere = computed(() => ({ isExternal: false, })) -const internalGroupSecondaryContactWhere = computed(() => ({ - isExternal: false, -})) function updateExternalGroupContactTitle(user: UserAsIndex | null) { if (isNil(user)) { diff --git a/web/src/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue b/web/src/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue index a14dfe5c..b59dad34 100644 --- a/web/src/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue +++ b/web/src/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue @@ -3,9 +3,9 @@ :model-value="modelValue" :loading="isLoading" :items="allItems" - label="Search Active Directory" + :label="label" placeholder="Start typing name or email..." - hint="Pre-populates user information directly from the directory." + :hint="hint" item-value="email" item-title="email" prepend-inner-icon="mdi-magnify" @@ -55,9 +55,17 @@ import useYukonGovernmentEmployees, { type YukonGovernmentEmployeeQueryOptions, } from "@/use/yukon-government-directory/use-yukon-government-employees" -const props = defineProps<{ - modelValue: string | null | undefined -}>() +const props = withDefaults( + defineProps<{ + modelValue: string | null | undefined + label?: string + hint?: string + }>(), + { + label: "Search Active Directory", + hint: "Pre-populates user information directly from the directory.", + } +) const emit = defineEmits<{ "update:modelValue": [email: string | null | undefined] diff --git a/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue b/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue index aa54e362..fa4b8857 100644 --- a/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue +++ b/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue @@ -90,11 +90,10 @@ cols="12" md="6" > - @@ -131,19 +130,21 @@