From 2b1deaa615939c906732d1413eff1033c3909150 Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Fri, 4 Sep 2026 19:02:42 -0700 Subject: [PATCH 1/2] :sparkles: Search the Active Directory for the ISA Manager contact. The ISA Manager (secondary contact) field now searches the Yukon Government directory instead of existing app users, so any employee can be named on the contract. The client submits the selected email; the controller resolves it to an internal user (creating one from the directory when missing) and stores its id. Group creation already excludes this contact, so the Manager never gains group admin or membership. See TK-66. See https://yg-hpw.atlassian.net/browse/TK-66 --- ...formation-sharing-agreements-controller.ts | 46 +++++++-- .../draft-state-policy.ts | 2 +- .../show-serializer.ts | 2 + .../create-service.ts | 1 + .../update-service.ts | 1 + .../ensure-from-directory-email-service.ts | 52 ++++++++++ api/src/services/users/index.ts | 1 + ...tion-sharing-agreements-controller.test.ts | 97 +++++++++++++++++++ ...nsure-from-directory-email-service.test.ts | 88 +++++++++++++++++ .../api/information-sharing-agreements-api.ts | 4 + ...aringAgreementBasicInformationEditCard.vue | 17 ++-- ...vernmentEmployeeSearchableAutocomplete.vue | 18 +++- ...aringAgreementEditBasicInformationPage.vue | 11 +-- .../InformationSharingAgreementNewPage.vue | 6 +- 14 files changed, 310 insertions(+), 36 deletions(-) create mode 100644 api/src/services/users/ensure-from-directory-email-service.ts create mode 100644 api/tests/controllers/information-sharing-agreements-controller.test.ts create mode 100644 api/tests/services/users/ensure-from-directory-email-service.test.ts diff --git a/api/src/controllers/information-sharing-agreements-controller.ts b/api/src/controllers/information-sharing-agreements-controller.ts index 6da92694..be270336 100644 --- a/api/src/controllers/information-sharing-agreements-controller.ts +++ b/api/src/controllers/information-sharing-agreements-controller.ts @@ -1,9 +1,11 @@ -import { isNil } from "lodash" +import { Attributes } from "@sequelize/core" +import { isEmpty, isNil } from "lodash" import logger from "@/utils/logger" import { InformationSharingAgreement } from "@/models" import { InformationSharingAgreementPolicy } from "@/policies" import { CreateService, UpdateService } from "@/services/information-sharing-agreements" +import { Users } from "@/services" import { IndexSerializer, ShowSerializer } from "@/serializers/information-sharing-agreements" import BaseController from "@/controllers/base-controller" @@ -84,10 +86,8 @@ export class InformationSharingAgreementsController extends BaseController> + ): Promise>> { + const { internalGroupSecondaryContactEmail, ...attributes } = permittedAttributes as Partial< + Attributes + > & { internalGroupSecondaryContactEmail?: string | null } + + if (internalGroupSecondaryContactEmail === undefined) { + return attributes + } + + if (isNil(internalGroupSecondaryContactEmail) || isEmpty(internalGroupSecondaryContactEmail)) { + return { ...attributes, internalGroupSecondaryContactId: null } + } + + const manager = await Users.EnsureFromDirectoryEmailService.perform( + internalGroupSecondaryContactEmail, + this.currentUser + ) + return { ...attributes, internalGroupSecondaryContactId: manager.id } + } + private async buildInformationSharingAgreement() { const informationSharingAgreement = InformationSharingAgreement.build(this.request.body) return informationSharingAgreement diff --git a/api/src/policies/information-sharing-agreements/draft-state-policy.ts b/api/src/policies/information-sharing-agreements/draft-state-policy.ts index 95e315b3..c80746a0 100644 --- a/api/src/policies/information-sharing-agreements/draft-state-policy.ts +++ b/api/src/policies/information-sharing-agreements/draft-state-policy.ts @@ -25,7 +25,7 @@ export class DraftStatePolicy extends GenericStatePolicy { return [ "externalGroupContactId", "internalGroupContactId", - "internalGroupSecondaryContactId", + "internalGroupSecondaryContactEmail", "identifier", "externalGroupInfo", "internalGroupInfo", diff --git a/api/src/serializers/information-sharing-agreements/show-serializer.ts b/api/src/serializers/information-sharing-agreements/show-serializer.ts index 8c840bd8..37e518af 100644 --- a/api/src/serializers/information-sharing-agreements/show-serializer.ts +++ b/api/src/serializers/information-sharing-agreements/show-serializer.ts @@ -52,6 +52,7 @@ export type InformationSharingAgreementAsShow = Pick< startDate: string | null endDate: string | null signedAt: string | null + internalGroupSecondaryContactEmail: string | null // Associations signedConfidentialityAcknowledgement: Attachments.AsReference | null signedConfidentialityReceipt: Attachments.AsReference | null @@ -127,6 +128,7 @@ export class ShowSerializer extends BaseSerializer startDate: formattedStartDate, endDate: formattedEndDate, signedAt: formattedSignedAt, + internalGroupSecondaryContactEmail: this.record.internalGroupSecondaryContact?.email ?? null, signedConfidentialityAcknowledgement: serializedSignedConfidentialityAcknowledgement, signedConfidentialityReceipt: serializedSignedConfidentialityReceipt, } diff --git a/api/src/services/information-sharing-agreements/create-service.ts b/api/src/services/information-sharing-agreements/create-service.ts index 85659291..90402171 100644 --- a/api/src/services/information-sharing-agreements/create-service.ts +++ b/api/src/services/information-sharing-agreements/create-service.ts @@ -34,6 +34,7 @@ export class CreateService extends BaseService { return informationSharingAgreement.reload({ include: [ "accessGrants", + "internalGroupSecondaryContact", "signedConfidentialityAcknowledgement", "signedConfidentialityReceipt", ], diff --git a/api/src/services/information-sharing-agreements/update-service.ts b/api/src/services/information-sharing-agreements/update-service.ts index f23b0b15..d11fe96e 100644 --- a/api/src/services/information-sharing-agreements/update-service.ts +++ b/api/src/services/information-sharing-agreements/update-service.ts @@ -64,6 +64,7 @@ export class UpdateService extends BaseService { return this.informationSharingAgreement.reload({ include: [ "accessGrants", + "internalGroupSecondaryContact", "signedConfidentialityAcknowledgement", "signedConfidentialityReceipt", ], diff --git a/api/src/services/users/ensure-from-directory-email-service.ts b/api/src/services/users/ensure-from-directory-email-service.ts new file mode 100644 index 00000000..54b7d22a --- /dev/null +++ b/api/src/services/users/ensure-from-directory-email-service.ts @@ -0,0 +1,52 @@ +import { isNil } from "lodash" + +import { User } from "@/models" +import { yukonGovernmentIntegration } from "@/integrations" +import BaseService from "@/services/base-service" +import { Users } from "@/services" + +/** + * Resolves a Yukon Government directory email to an internal User record, creating one + * from the directory when it does not exist yet. Used to record contract contacts (such + * as the ISA Manager) who are sourced from Active Directory. The returned user is not + * granted any group membership on its own. See TK-66. + */ +export class EnsureFromDirectoryEmailService extends BaseService { + constructor( + private email: string, + private currentUser: User + ) { + super() + } + + async perform(): Promise { + 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/information-sharing-agreements-controller.test.ts b/api/tests/controllers/information-sharing-agreements-controller.test.ts new file mode 100644 index 00000000..1951543c --- /dev/null +++ b/api/tests/controllers/information-sharing-agreements-controller.test.ts @@ -0,0 +1,97 @@ +import type * as Integrations from "@/integrations" + +import { User } from "@/models" +import { yukonGovernmentIntegration } from "@/integrations" + +import { 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/information-sharing-agreements-controller.ts", () => { + describe("InformationSharingAgreementsController", () => { + describe("#create", () => { + let currentUser: User + + beforeEach(async () => { + currentUser = await userFactory.create({ isExternal: false }) + mockCurrentUser(currentUser) + }) + + test("resolves the Manager email to an 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/information-sharing-agreements").send({ + title: "Test ISA", + internalGroupSecondaryContactEmail: "manager@example.com", + }) + + expect(response.status).toBe(201) + const { informationSharingAgreement } = response.body + expect(informationSharingAgreement.internalGroupSecondaryContactId).toBe(manager.id) + expect(informationSharingAgreement.internalGroupSecondaryContactEmail).toBe( + "manager@example.com" + ) + expect(mockedFetchEmployee).not.toHaveBeenCalled() + expect(await User.count()).toBe(userCountBefore) + }) + + test("creates an internal user from the directory when the Manager 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/information-sharing-agreements").send({ + title: "Test ISA 2", + internalGroupSecondaryContactEmail: "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.informationSharingAgreement.internalGroupSecondaryContactId).toBe( + createdManager?.id + ) + }) + }) + }) +}) 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/information-sharing-agreements-api.ts b/web/src/api/information-sharing-agreements-api.ts index b9d45aa4..1532e46b 100644 --- a/web/src/api/information-sharing-agreements-api.ts +++ b/web/src/api/information-sharing-agreements-api.ts @@ -43,6 +43,9 @@ export type InformationSharingAgreement = { internalGroupId: number | null internalGroupContactId: number | null internalGroupSecondaryContactId: number | null + // Write-side directory field: on read it is the selected Manager's email, on write it + // resolves to the internal user stored in internalGroupSecondaryContactId. See TK-66. + internalGroupSecondaryContactEmail: string | null status: InformationSharingAgreementStatuses identifier: string | null externalGroupInfo: string | null @@ -111,6 +114,7 @@ export type InformationSharingAgreementAsShow = Pick< | "internalGroupId" | "internalGroupContactId" | "internalGroupSecondaryContactId" + | "internalGroupSecondaryContactEmail" | "status" | "identifier" | "externalGroupInfo" 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..6d90af78 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" > - @@ -144,6 +143,7 @@ import useSnack from "@/use/use-snack" import UserSearchableAutocomplete, { type UserAsIndex, } from "@/components/users/UserSearchableAutocomplete.vue" +import YukonGovernmentEmployeeSearchableAutocomplete from "@/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue" const props = defineProps<{ informationSharingAgreementId: string @@ -162,9 +162,6 @@ const externalGroupContactWhere = computed(() => ({ const internalGroupContactWhere = computed(() => ({ isExternal: false, })) -const internalGroupSecondaryContactWhere = computed(() => ({ - isExternal: false, -})) function updateExternalGroupContactTitle(user: UserAsIndex | null) { if (isNil(informationSharingAgreement.value)) return diff --git a/web/src/pages/information-sharing-agreements/InformationSharingAgreementNewPage.vue b/web/src/pages/information-sharing-agreements/InformationSharingAgreementNewPage.vue index 88c08231..3ccf4b1b 100644 --- a/web/src/pages/information-sharing-agreements/InformationSharingAgreementNewPage.vue +++ b/web/src/pages/information-sharing-agreements/InformationSharingAgreementNewPage.vue @@ -20,8 +20,8 @@ v-model:internal-group-contact-title=" informationSharingAgreementAttributes.internalGroupContactTitle " - v-model:internal-group-secondary-contact-id=" - informationSharingAgreementAttributes.internalGroupSecondaryContactId + v-model:internal-group-secondary-contact-email=" + informationSharingAgreementAttributes.internalGroupSecondaryContactEmail " class="border" /> @@ -76,7 +76,7 @@ const informationSharingAgreementAttributes = ref Date: Fri, 4 Sep 2026 20:12:02 -0700 Subject: [PATCH 2/2] :recycle: Resolve the ISA Manager from the directory on the client. Why? The internalGroupSecondaryContactEmail write-side field spread directory resolution across the ISA controller, policy, and serializer for a value that is not a real column. The ISA endpoints accept internalGroupSecondaryContactId again. The client resolves the selected directory email to an internal user through the new POST /api/users/directory-users endpoint before saving, and prefills the edit form by looking the contact up by id. External users cannot reach the new endpoint, matching who may author an agreement. See https://yg-hpw.atlassian.net/browse/TK-66 --- ...formation-sharing-agreements-controller.ts | 42 +++------------- .../users/directory-users-controller.ts | 39 +++++++++++++++ api/src/controllers/users/index.ts | 1 + .../draft-state-policy.ts | 2 +- api/src/router.ts | 3 ++ .../show-serializer.ts | 2 - .../create-service.ts | 1 - .../update-service.ts | 1 - .../directory-users-controller.test.ts} | 50 +++++++++++-------- .../api/information-sharing-agreements-api.ts | 4 -- web/src/api/users-api.ts | 5 ++ ...aringAgreementEditBasicInformationPage.vue | 32 ++++++++++-- .../InformationSharingAgreementNewPage.vue | 20 +++++--- 13 files changed, 126 insertions(+), 76 deletions(-) create mode 100644 api/src/controllers/users/directory-users-controller.ts rename api/tests/controllers/{information-sharing-agreements-controller.test.ts => users/directory-users-controller.test.ts} (62%) diff --git a/api/src/controllers/information-sharing-agreements-controller.ts b/api/src/controllers/information-sharing-agreements-controller.ts index be270336..62c0d5dd 100644 --- a/api/src/controllers/information-sharing-agreements-controller.ts +++ b/api/src/controllers/information-sharing-agreements-controller.ts @@ -1,11 +1,9 @@ -import { Attributes } from "@sequelize/core" -import { isEmpty, isNil } from "lodash" +import { isNil } from "lodash" import logger from "@/utils/logger" import { InformationSharingAgreement } from "@/models" import { InformationSharingAgreementPolicy } from "@/policies" import { CreateService, UpdateService } from "@/services/information-sharing-agreements" -import { Users } from "@/services" import { IndexSerializer, ShowSerializer } from "@/serializers/information-sharing-agreements" import BaseController from "@/controllers/base-controller" @@ -86,8 +84,10 @@ export class InformationSharingAgreementsController extends BaseController> - ): Promise>> { - const { internalGroupSecondaryContactEmail, ...attributes } = permittedAttributes as Partial< - Attributes - > & { internalGroupSecondaryContactEmail?: string | null } - - if (internalGroupSecondaryContactEmail === undefined) { - return attributes - } - - if (isNil(internalGroupSecondaryContactEmail) || isEmpty(internalGroupSecondaryContactEmail)) { - return { ...attributes, internalGroupSecondaryContactId: null } - } - - const manager = await Users.EnsureFromDirectoryEmailService.perform( - internalGroupSecondaryContactEmail, - this.currentUser - ) - return { ...attributes, internalGroupSecondaryContactId: manager.id } - } - private async buildInformationSharingAgreement() { const informationSharingAgreement = InformationSharingAgreement.build(this.request.body) return informationSharingAgreement diff --git a/api/src/controllers/users/directory-users-controller.ts b/api/src/controllers/users/directory-users-controller.ts new file mode 100644 index 00000000..9f815100 --- /dev/null +++ b/api/src/controllers/users/directory-users-controller.ts @@ -0,0 +1,39 @@ +import { isEmpty, isNil } from "lodash" + +import logger from "@/utils/logger" +import { Users } from "@/services" +import { ReferenceSerializer } from "@/serializers/users" +import BaseController from "@/controllers/base-controller" + +export class DirectoryUsersController extends BaseController { + async create() { + try { + // Only internal (Yukon Government) staff name Manager contacts on agreements, matching + // who may author an ISA, so this endpoint is gated the same way. See TK-66. + if (this.currentUser.isExternal) { + return this.response.status(403).json({ + message: "You are not authorized to resolve directory users", + }) + } + + const { email } = this.request.body + if (isNil(email) || isEmpty(email)) { + return this.response.status(422).json({ + message: "email is required", + }) + } + + const user = await Users.EnsureFromDirectoryEmailService.perform(email, this.currentUser) + return this.response.status(201).json({ + user: ReferenceSerializer.perform(user), + }) + } catch (error) { + logger.error(`Error resolving directory user: ${error}`, { error }) + return this.response.status(422).json({ + message: `Error resolving directory user: ${error}`, + }) + } + } +} + +export default DirectoryUsersController diff --git a/api/src/controllers/users/index.ts b/api/src/controllers/users/index.ts index d5311c07..87c59ed8 100644 --- a/api/src/controllers/users/index.ts +++ b/api/src/controllers/users/index.ts @@ -1,2 +1,3 @@ export { DirectorySyncController } from "./directory-sync-controller" export { DeactivationController } from "./deactivation-controller" +export { DirectoryUsersController } from "./directory-users-controller" diff --git a/api/src/policies/information-sharing-agreements/draft-state-policy.ts b/api/src/policies/information-sharing-agreements/draft-state-policy.ts index c80746a0..95e315b3 100644 --- a/api/src/policies/information-sharing-agreements/draft-state-policy.ts +++ b/api/src/policies/information-sharing-agreements/draft-state-policy.ts @@ -25,7 +25,7 @@ export class DraftStatePolicy extends GenericStatePolicy { return [ "externalGroupContactId", "internalGroupContactId", - "internalGroupSecondaryContactEmail", + "internalGroupSecondaryContactId", "identifier", "externalGroupInfo", "internalGroupInfo", diff --git a/api/src/router.ts b/api/src/router.ts index e95d7839..0916d873 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -96,6 +96,9 @@ router .delete(Notifications.ReadController.destroy) router.route("/api/users").get(UsersController.index).post(UsersController.create) +router + .route("/api/users/directory-users") + .post(Users.DirectoryUsersController.create) router .route("/api/users/:id") .get(UsersController.show) diff --git a/api/src/serializers/information-sharing-agreements/show-serializer.ts b/api/src/serializers/information-sharing-agreements/show-serializer.ts index 37e518af..8c840bd8 100644 --- a/api/src/serializers/information-sharing-agreements/show-serializer.ts +++ b/api/src/serializers/information-sharing-agreements/show-serializer.ts @@ -52,7 +52,6 @@ export type InformationSharingAgreementAsShow = Pick< startDate: string | null endDate: string | null signedAt: string | null - internalGroupSecondaryContactEmail: string | null // Associations signedConfidentialityAcknowledgement: Attachments.AsReference | null signedConfidentialityReceipt: Attachments.AsReference | null @@ -128,7 +127,6 @@ export class ShowSerializer extends BaseSerializer startDate: formattedStartDate, endDate: formattedEndDate, signedAt: formattedSignedAt, - internalGroupSecondaryContactEmail: this.record.internalGroupSecondaryContact?.email ?? null, signedConfidentialityAcknowledgement: serializedSignedConfidentialityAcknowledgement, signedConfidentialityReceipt: serializedSignedConfidentialityReceipt, } diff --git a/api/src/services/information-sharing-agreements/create-service.ts b/api/src/services/information-sharing-agreements/create-service.ts index 90402171..85659291 100644 --- a/api/src/services/information-sharing-agreements/create-service.ts +++ b/api/src/services/information-sharing-agreements/create-service.ts @@ -34,7 +34,6 @@ export class CreateService extends BaseService { return informationSharingAgreement.reload({ include: [ "accessGrants", - "internalGroupSecondaryContact", "signedConfidentialityAcknowledgement", "signedConfidentialityReceipt", ], diff --git a/api/src/services/information-sharing-agreements/update-service.ts b/api/src/services/information-sharing-agreements/update-service.ts index d11fe96e..f23b0b15 100644 --- a/api/src/services/information-sharing-agreements/update-service.ts +++ b/api/src/services/information-sharing-agreements/update-service.ts @@ -64,7 +64,6 @@ export class UpdateService extends BaseService { return this.informationSharingAgreement.reload({ include: [ "accessGrants", - "internalGroupSecondaryContact", "signedConfidentialityAcknowledgement", "signedConfidentialityReceipt", ], diff --git a/api/tests/controllers/information-sharing-agreements-controller.test.ts b/api/tests/controllers/users/directory-users-controller.test.ts similarity index 62% rename from api/tests/controllers/information-sharing-agreements-controller.test.ts rename to api/tests/controllers/users/directory-users-controller.test.ts index 1951543c..bf8319b7 100644 --- a/api/tests/controllers/information-sharing-agreements-controller.test.ts +++ b/api/tests/controllers/users/directory-users-controller.test.ts @@ -3,7 +3,7 @@ import type * as Integrations from "@/integrations" import { User } from "@/models" import { yukonGovernmentIntegration } from "@/integrations" -import { userFactory } from "@/tests/factories" +import { externalOrganizationFactory, userFactory } from "@/tests/factories" import { mockCurrentUser, request } from "@/tests/support" vi.mock("@/integrations", async (importOriginal) => { @@ -19,8 +19,8 @@ vi.mock("@/integrations", async (importOriginal) => { const mockedFetchEmployee = vi.mocked(yukonGovernmentIntegration.fetchEmployee) -describe("api/src/controllers/information-sharing-agreements-controller.ts", () => { - describe("InformationSharingAgreementsController", () => { +describe("api/src/controllers/users/directory-users-controller.ts", () => { + describe("DirectoryUsersController", () => { describe("#create", () => { let currentUser: User @@ -29,29 +29,25 @@ describe("api/src/controllers/information-sharing-agreements-controller.ts", () mockCurrentUser(currentUser) }) - test("resolves the Manager email to an existing internal user without creating a duplicate", async () => { + 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/information-sharing-agreements").send({ - title: "Test ISA", - internalGroupSecondaryContactEmail: "manager@example.com", - }) + const response = await request() + .post("/api/users/directory-users") + .send({ email: "manager@example.com" }) expect(response.status).toBe(201) - const { informationSharingAgreement } = response.body - expect(informationSharingAgreement.internalGroupSecondaryContactId).toBe(manager.id) - expect(informationSharingAgreement.internalGroupSecondaryContactEmail).toBe( - "manager@example.com" - ) + 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 Manager email is new", async () => { + test("creates an internal user from the directory when the email is new", async () => { mockedFetchEmployee.mockResolvedValue({ full_name: "Jane Manager", first_name: "Jane", @@ -79,18 +75,30 @@ describe("api/src/controllers/information-sharing-agreements-controller.ts", () username: "jmanager", }) - const response = await request().post("/api/information-sharing-agreements").send({ - title: "Test ISA 2", - internalGroupSecondaryContactEmail: "jane.manager@yukon.ca", - }) + 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.informationSharingAgreement.internalGroupSecondaryContactId).toBe( - createdManager?.id - ) + 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/web/src/api/information-sharing-agreements-api.ts b/web/src/api/information-sharing-agreements-api.ts index 1532e46b..b9d45aa4 100644 --- a/web/src/api/information-sharing-agreements-api.ts +++ b/web/src/api/information-sharing-agreements-api.ts @@ -43,9 +43,6 @@ export type InformationSharingAgreement = { internalGroupId: number | null internalGroupContactId: number | null internalGroupSecondaryContactId: number | null - // Write-side directory field: on read it is the selected Manager's email, on write it - // resolves to the internal user stored in internalGroupSecondaryContactId. See TK-66. - internalGroupSecondaryContactEmail: string | null status: InformationSharingAgreementStatuses identifier: string | null externalGroupInfo: string | null @@ -114,7 +111,6 @@ export type InformationSharingAgreementAsShow = Pick< | "internalGroupId" | "internalGroupContactId" | "internalGroupSecondaryContactId" - | "internalGroupSecondaryContactEmail" | "status" | "identifier" | "externalGroupInfo" 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/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue b/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue index 6d90af78..fa4b8857 100644 --- a/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue +++ b/web/src/pages/information-sharing-agreements/InformationSharingAgreementEditBasicInformationPage.vue @@ -91,7 +91,7 @@ md="6" >