Repository: bcgov/SIMS
Pull request #4682: #4328 - Student Profile - Bridge/Legacy Matching Management - Student Link
Author: login: andrewsignori-aot
Labels: name: Ministry
description: Ministry Features
name: SIMS-Api
description: SIMS-Api
name: Web portal
Created: 2025-05-07T16:14:25Z | Merged: 2025-05-08T16:13:47Z

- Created a new role to allow the Ministry to link the student to the legacy profile: `student-link-legacy-profile`.
- New endpoints created to get the possible matches and also create the association (E2Es to be added in the next PR).
- Create the 'StudentProfileLegacyMatchesModal.vue' to simplify the student profile page.
- DataTable configured to display all records (` :items-per-page="-1"`) and hide the footer.

## Linked profile

![image](https://github.com/user-attachments/assets/54e536d0-79eb-47d0-bd94-b37f9a27ee47)

## No profile linked

![image](https://github.com/user-attachments/assets/b20ed2e7-4c10-46b2-877c-950e38ab6944)

## Available profiles to be linked

![image](https://github.com/user-attachments/assets/f77fef04-be4c-46ee-984c-edf5bbb9d66f)

## Modal to capture the notes and link the profile

![image](https://github.com/user-attachments/assets/765be79d-49ff-4d42-bf39-3d6781ef4d56)

## No possible matches available

![image](https://github.com/user-attachments/assets/825a0d70-ce95-4874-a962-c5488c263df2)








Diff:
diff --git a/sources/packages/backend/apps/api/src/auth/roles.enum.ts b/sources/packages/backend/apps/api/src/auth/roles.enum.ts
index 7176673ef7..189748b267 100644
--- a/sources/packages/backend/apps/api/src/auth/roles.enum.ts
+++ b/sources/packages/backend/apps/api/src/auth/roles.enum.ts
@@ -13,6 +13,7 @@ export enum Role {
   AESTCASExpenseAuthority = "aest-cas-expense-authority",
   AESTBypassStudentRestriction = "aest-bypass-student-restriction",
   AESTQueueDashboardAdmin = "aest-queue-dashboard-admin",
+  StudentLinkLegacyProfile = "student-link-legacy-profile",
   StudentAddRestriction = "student-add-restriction",
   StudentResolveRestriction = "student-resolve-restriction",
   StudentUploadFile = "student-upload-file",
diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/models/student.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/student/models/student.dto.ts
index ae09f616fd..22961514cb 100644
--- a/sources/packages/backend/apps/api/src/route-controllers/student/models/student.dto.ts
+++ b/sources/packages/backend/apps/api/src/route-controllers/student/models/student.dto.ts
@@ -9,6 +9,7 @@ import {
   IsEnum,
   IsNotEmpty,
   IsOptional,
+  IsPositive,
   Length,
   MaxLength,
   ValidateIf,
@@ -330,3 +331,23 @@ export class UpdateStudentDetailsAPIInDTO {
   @MaxLength(NOTE_DESCRIPTION_MAX_LENGTH)
   noteDescription: string;
 }
+
+export class LegacyStudentMatchAPIOutDTO {
+  individualId: number;
+  firstName?: string;
+  lastName: string;
+  birthDate: string;
+  sin: string;
+}
+
+export class LegacyStudentMatchesAPIOutDTO {
+  matches: LegacyStudentMatchAPIOutDTO[];
+}
+
+export class LegacyStudentMatchesAPIInDTO {
+  @IsPositive()
+  individualId: number;
+  @IsNotEmpty()
+  @MaxLength(NOTE_DESCRIPTION_MAX_LENGTH)
+  noteDescription: string;
+}
diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
index 9d2fba06a8..6bb2a9b287 100644
--- a/sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
+++ b/sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
@@ -51,6 +51,8 @@ import {
   UpdateDisabilityStatusAPIInDTO,
   UpdateStudentDetailsAPIInDTO,
   AESTStudentFileDetailsAPIOutDTO,
+  LegacyStudentMatchesAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
 } from "./models/student.dto";
 import { Response } from "express";
 import { FileInterceptor } from "@nestjs/platform-express";
@@ -77,6 +79,7 @@ import {
 } from "../../constants";
 import { Role } from "../../auth/roles.enum";
 import { EntityManager } from "typeorm";
+import { SFASIndividualService } from "@sims/services";
 
 /**
  * Student controller for AEST Client.
@@ -92,6 +95,7 @@ export class StudentAESTController extends BaseController {
     private readonly studentControllerService: StudentControllerService,
     private readonly notificationActionsService: NotificationActionsService,
     private readonly sinValidationService: SINValidationService,
+    private readonly sfasIndividualService: SFASIndividualService,
   ) {
     super();
   }
@@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.StudentLinkLegacyProfile)
+  @Get(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description: "Student already has a legacy profile associated.",
+  })
+  async getStudentLegacyMatches(
+    @Param("studentId", ParseIntPipe) studentId: number,
+  ): Promise<LegacyStudentMatchesAPIOutDTO> {
+    const possibleIndividualsForAssociation =
+      await this.studentControllerService.validateAndGetStudentLegacyMatches(
+        studentId,
+      );
+    return {
+      matches: possibleIndividualsForAssociation.map((sfasIndividual) => ({
+        individualId: sfasIndividual.id,
+        firstName: sfasIndividual.firstName,
+        lastName: sfasIndividual.lastName,
+        birthDate: sfasIndividual.birthDate,
+        sin: sfasIndividual.sin,
+      })),
+    };
+  }
+
+  /**
+   * Associates a student with a legacy profile.
+   * @param studentId student ID to be associated with the legacy profile.
+   * @param payload legacy profile to be associated.
+   */
+  @Roles(Role.StudentLinkLegacyProfile)
+  @Patch(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description:
+      "Student already has a legacy profile associated " +
+      "or provided individual is not listed as a potential profile match.",
+  })
+  async associateLegacyStudent(
+    @Param("studentId", ParseIntPipe) studentId: number,
+    @Body() payload: LegacyStudentMatchesAPIInDTO,
+    @UserToken() userToken: IUserToken,
+  ): Promise<void> {
+    const possibleIndividualsForAssociation =
+      await this.studentControllerService.validateAndGetStudentLegacyMatches(
+        studentId,
+      );
+    const hasPotentialIndividualMatch = possibleIndividualsForAssociation.some(
+      (possibleIndividual) => possibleIndividual.id === payload.individualId,
+    );
+    if (!hasPotentialIndividualMatch) {
+      throw new UnprocessableEntityException(
+        "Provided individual is not listed as a potential profile match.",
+      );
+    }
+    await this.sfasIndividualService.associateStudentProfile(
+      payload.individualId,
+      studentId,
+      payload.noteDescription,
+      userToken.userId,
+    );
+  }
 }
diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts
index 9a653f0870..52d5540e0c 100644
--- a/sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts
+++ b/sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts
@@ -3,6 +3,7 @@ import {
   Injectable,
   InternalServerErrorException,
   NotFoundException,
+  UnprocessableEntityException,
 } from "@nestjs/common";
 import { Response } from "express";
 import {
@@ -28,6 +29,7 @@ import {
 import {
   AddressInfo,
   Application,
+  SFASIndividual,
   SpecificIdentityProviders,
   Student,
   VirusScanStatus,
@@ -53,6 +55,7 @@ import {
   LoggerService,
   ProcessSummary,
 } from "@sims/utilities/logger";
+import { SFASIndividualService } from "@sims/services";
 
 @Injectable()
 export class StudentControllerService {
@@ -62,6 +65,7 @@ export class StudentControllerService {
     private readonly studentRestrictionService: StudentRestrictionService,
     private readonly applicationService: ApplicationService,
     private readonly objectStorageService: ObjectStorageService,
+    private readonly sfasIndividualService: SFASIndividualService,
   ) {}
 
   /**
@@ -415,6 +419,32 @@ export class StudentControllerService {
     }));
   }
 
+  /**
+   * Validate the conditions to allow the student to be associated with a legacy profile.
+   * @param studentId student ID to have a legacy profile associated with.
+   * @returns possible legacy profiles that matches the student data.
+   */
+  async validateAndGetStudentLegacyMatches(
+    studentId: number,
+  ): Promise<SFASIndividual[]> {
+    const student = await this.studentService.getStudentById(studentId, {
+      includeLegacy: true,
+    });
+    if (!student) {
+      throw new NotFoundException("Student not found.");
+    }
+    if (student.sfasIndividuals.length) {
+      throw new UnprocessableEntityException(
+        "Student already has a legacy profile associated.",
+      );
+    }
+    return this.sfasIndividualService.getIndividualStudentPartialMatchForAssociation(
+      student.user.lastName,
+      student.birthDate,
+      student.sinValidation.sin,
+    );
+  }
+
   @InjectLogger()
   logger: LoggerService;
 }
diff --git a/sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts b/sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
index 71bb6c95a6..11a5d27aef 100644
--- a/sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
+++ b/sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
@@ -1,8 +1,9 @@
 import { Injectable } from "@nestjs/common";
-import { Brackets, Repository } from "typeorm";
-import { SFASIndividual, Student } from "@sims/sims-db";
+import { Brackets, DataSource, IsNull, Repository } from "typeorm";
+import { NoteType, SFASIndividual, SFASRestriction } from "@sims/sims-db";
 import { SFASIndividualDataSummary } from "./sfas-individual.model";
 import { InjectRepository } from "@nestjs/typeorm";
+import { NoteSharedService } from "@sims/services";
 
 /**
  * Manages the data related to an individual/student in SFAS.
@@ -10,10 +11,10 @@ import { InjectRepository } from "@nestjs/typeorm";
 @Injectable()
 export class SFASIndividualService {
   constructor(
+    private readonly dataSource: DataSource,
     @InjectRepository(SFASIndividual)
     private readonly sfasIndividualRepo: Repository<SFASIndividual>,
-    @InjectRepository(Student)
-    private readonly studentRepo: Repository<Student>,
+    private readonly noteSharedService: NoteSharedService,
   ) {}
 
   /**
@@ -92,6 +93,84 @@ export class SFASIndividualService {
       .getOne();
   }
 
+  /**
+   * Get students that could potentially be associated with the given student.
+   * @param lastName student last name, must be an exact match.
+   * @param birthDate student date of birth.
+   * @param sin student social insurance number.
+   * @returns SFAS students that could be potentially associated to a SIMS student.
+   */
+  async getIndividualStudentPartialMatchForAssociation(
+    lastName: string,
+    birthDate: string,
+    sin: string,
+  ): Promise<SFASIndividual[]> {
+    return this.sfasIndividualRepo
+      .createQueryBuilder("individual")
+      .select([
+        "individual.id",
+        "individual.firstName",
+        "individual.lastName",
+        "individual.sin",
+        "individual.birthDate",
+      ])
+      .where("individual.student.id IS NULL")
+      .andWhere(
+        new Brackets((qb) =>
+          qb.where("individual.sin = :sin", { sin }).orWhere(
+            new Brackets((qb) => {
+              qb.where("lower(individual.lastName) = :lastName", {
+                lastName: lastName.toLowerCase(),
+              }).andWhere("individual.birthDate = :birthDate", {
+                birthDate,
+              });
+            }),
+          ),
+        ),
+      )
+      .getMany();
+  }
+
+  /**
+   * Associate a student to a legacy profile and reset the restrictions
+   * to allow them to be reprocessed and associated with the new student.
+   * @param individualId legacy individual to have the student associated.
+   * @param studentId student to be associated to the legacy profile.
+   * @param noteDescription note description to be added to the student notes.
+   * @param auditUserId user creating the profile link.
+   */
+  async associateStudentProfile(
+    individualId: number,
+    studentId: number,
+    noteDescription: string,
+    auditUserId: number,
+  ): Promise<void> {
+    await this.dataSource.transaction(async (entityManager) => {
+      const updateResult = await entityManager
+        .getRepository(SFASIndividual)
+        .update(
+          { id: individualId, student: IsNull() },
+          { student: { id: studentId } },
+        );
+      if (!updateResult.affected) {
+        throw new Error(
+          `Error while associating student ID ${studentId} to legacy individual ID ${individualId}. The individual was not found or a student ID is already associated with it.`,
+        );
+      }
+      const updateRestrictionsPromise = entityManager
+        .getRepository(SFASRestriction)
+        .update({ individualId, processed: true }, { processed: false });
+      const createNotePromise = this.noteSharedService.createStudentNote(
+        studentId,
+        NoteType.General,
+        noteDescription,
+        auditUserId,
+        entityManager,
+      );
+      await Promise.all([updateRestrictionsPromise, createNotePromise]);
+    });
+  }
+
   /**
    * Gets the total number of unsuccessful completion weeks for a student in SFAS.
    * @param studentId student id to retrieve the unsuccessful weeks.
diff --git a/sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue b/sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue
new file mode 100644
index 0000000000..5b0cc5e09f
--- /dev/null
+++ b/sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue
@@ -0,0 +1,84 @@
+<template>
+  <v-form ref="linkProfileForm">
+    <modal-dialog-base :showDialog="showDialog" title="Confirm profile link"
+      ><template #content>
+        <error-summary :errors="linkProfileForm.errors" />
+        <p>Add a note and confirm the legacy profile link.</p>
+        <strong>This action cannot be undone.</strong>
+        <v-textarea
+          v-model="noteDescription"
+          variant="outlined"
+          label="Note"
+          :rules="[checkNotesLengthRule]"
+          required
+          :min-width="isMobile ? undefined : 600"
+          class="mt-4"
+          hide-details="auto"
+        ></v-textarea>
+      </template>
+      <template #footer>
+        <footer-buttons
+          primaryLabel="Link profile"
+          @primaryClick="linkProfile"
+          @secondaryClick="cancel"
+          :processing="loading"
+        />
+      </template>
+    </modal-dialog-base>
+  </v-form>
+</template>
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { useModalDialog, useRules } from "@/composables";
+import { VForm } from "@/types";
+import { LegacyStudentMatchesAPIInDTO } from "@/services/http/dto";
+import { useDisplay } from "vuetify/lib/framework.mjs";
+
+export default defineComponent({
+  setup() {
+    const {
+      showDialog,
+      resolvePromise,
+      showModal,
+      loading,
+      hideModal,
+      showParameter,
+    } = useModalDialog<LegacyStudentMatchesAPIInDTO | false>();
+    const { mobile: isMobile } = useDisplay();
+    const { checkNotesLengthRule } = useRules();
+    const noteDescription = ref("");
+    const linkProfileForm = ref({} as VForm);
+
+    const linkProfile = async () => {
+      const validationResult = await linkProfileForm.value.validate();
+      if (!validationResult.valid) {
+        return;
+      }
+      const payload = {
+        individualId: showParameter.value,
+        noteDescription: noteDescription.value,
+      };
+      await resolvePromise(payload);
+      linkProfileForm.value.reset();
+    };
+
+    const cancel = () => {
+      linkProfileForm.value.reset();
+      resolvePromise(false);
+      hideModal();
+    };
+
+    return {
+      noteDescription,
+      linkProfileForm,
+      showDialog,
+      showModal,
+      loading,
+      checkNotesLengthRule,
+      linkProfile,
+      cancel,
+      isMobile,
+    };
+  },
+});
+</script>
diff --git a/sources/packages/web/src/components/common/students/StudentProfile.vue b/sources/packages/web/src/components/common/students/StudentProfile.vue
index e246aa2e9c..d43c932f37 100644
--- a/sources/packages/web/src/components/common/students/StudentProfile.vue
+++ b/sources/packages/web/src/components/common/students/StudentProfile.vue
@@ -109,53 +109,16 @@
         /></v-col>
       </v-row>
     </content-group>
-    <h3 class="category-header-medium mt-4">Legacy Match</h3>
-    <content-group v-if="studentDetail.legacyProfile">
-      <v-row>
-        <v-col
-          ><title-value
-            propertyTitle="Given names"
-            :propertyValue="
-              emptyStringFiller(studentDetail.legacyProfile.firstName)
-            "
-          />
-        </v-col>
-        <v-col
-          ><title-value
-            propertyTitle="Last name"
-            :propertyValue="studentDetail.legacyProfile.lastName"
-          />
-        </v-col>
-        <v-col>
-          <title-value
-            propertyTitle="Date of birth"
-            :propertyValue="
-              dateOnlyLongString(studentDetail.legacyProfile.dateOfBirth)
-            "
-        /></v-col>
-      </v-row>
-      <v-row>
-        <v-col
-          ><title-value
-            propertyTitle="SIN"
-            :propertyValue="sinDisplayFormat(studentDetail.legacyProfile.sin)"
-          />
-        </v-col>
-      </v-row>
-      <v-row v-if="studentDetail.legacyProfile.hasMultipleProfiles">
-        <v-col
-          ><banner
-            :type="BannerTypes.Warning"
-            summary="The student is associated with multiple legacy profiles; the most recent update is shown."
-          ></banner>
-        </v-col>
-      </v-row>
-    </content-group>
-    <banner
-      v-else
-      :type="BannerTypes.Info"
-      summary="The student is not currently associated with any legacy record."
-    ></banner>
+    <template v-if="showLegacyMatch">
+      <h3 class="category-header-medium mt-4">Legacy match</h3>
+      <content-group>
+        <student-profile-legacy-matches
+          :student-id="studentId"
+          :legacy-profile="studentDetail.legacyProfile"
+          @legacy-profile-linked="loadStudentProfile"
+        />
+      </content-group>
+    </template>
     <edit-student-profile-modal
       ref="studentEditProfile"
     ></edit-student-profile-modal>
@@ -174,6 +137,7 @@ import { IdentityProviders, Role, StudentProfile, BannerTypes } from "@/types";
 import DisabilityStatusUpdateTileValue from "@/components/aest/students/DisabilityStatusUpdateTileValue.vue";
 import EditStudentProfileModal from "@/components/aest/students/modals/EditStudentProfileModal.vue";
 import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatches from "@/components/common/students/StudentProfileLegacyMatches.vue";
 
 /**
  *  Used to combine institution and ministry DTOs and make SIN explicitly mandatory.
@@ -185,6 +149,7 @@ interface SharedStudentProfile extends Omit<StudentProfile, "sin"> {
 
 export default defineComponent({
   components: {
+    StudentProfileLegacyMatches,
     DisabilityStatusUpdateTileValue,
     EditStudentProfileModal,
     CheckPermissionRole,
@@ -199,6 +164,11 @@ export default defineComponent({
       required: false,
       default: false,
     },
+    showLegacyMatch: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
   setup(props) {
     const snackBar = useSnackBar();
@@ -261,6 +231,7 @@ export default defineComponent({
     });
 
     onMounted(loadStudentProfile);
+
     return {
       studentDetail,
       address,
diff --git a/sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue b/sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
new file mode 100644
index 0000000000..6e6f63face
--- /dev/null
+++ b/sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
@@ -0,0 +1,204 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <check-permission-role :role="Role.StudentLinkLegacyProfile">
+          <template #="{ notAllowed }">
+            <v-btn
+              color="primary"
+              @click="loadStudentLegacyMatches()"
+              :disabled="notAllowed"
+            >
+              Check for legacy matches
+              <v-tooltip activator="parent" location="start"
+                >Click to check for potential legacy matches.</v-tooltip
+              >
+            </v-btn>
+          </template>
+        </check-permission-role></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+        hide-default-footer
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <v-btn
+            color="primary"
+            @click="showLinkProfileModal(item.individualId)"
+          >
+            Link
+            <v-tooltip activator="parent" location="start"
+              >Click to link student with this legacy profile.</v-tooltip
+            >
+          </v-btn>
+        </template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, PropType } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as PropType<LegacyStudentProfileAPIOutDTO>,
+      required: false,
+    },
+  },
+  setup(props, { emit }) {
+    const snackBar = useSnackBar();
+    const { sinDisplayFormat, emptyStringFiller, dateOnlyLongString } =
+      useFormatters();
+    const legacyStudentMatches = ref<LegacyStudentMatchAPIOutDTO[]>([]);
+    const studentLegacyMatchesLoading = ref(false);
+    const showLegacyMatches = ref(false);
+    const legacyMatchesModal = ref(
+      {} as ModalDialog<LegacyStudentMatchesAPIInDTO>,
+    );
+
+    const loadStudentLegacyMatches = async (): Promise<void> => {
+      studentLegacyMatchesLoading.value = true;
+      showLegacyMatches.value = true;
+      try {
+        const legacyMatches =
+          await StudentService.shared.getStudentLegacyMatches(props.studentId);
+        legacyStudentMatches.value = legacyMatches.matches;
+      } catch {
+        snackBar.error(
+          "An unexpected error happened while loading the legacy matches.",
+        );
+      } finally {
+        studentLegacyMatchesLoading.value = false;
+      }
+    };
+
+    const showLinkProfileModal = async (
+      individualId: number,
+    ): Promise<void> => {
+      await legacyMatchesModal.value.showModal(individualId, linkLegacyProfile);
+    };
+
+    const linkLegacyProfile = async (
+      payload: LegacyStudentMatchesAPIInDTO,
+    ): Promise<boolean> => {
+      try {
+        await StudentService.shared.associateLegacyStudent(
+          props.studentId,
+          payload,
+        );
+        snackBar.success("Legacy profile linked successfully.");
+        emit("legacyProfileLinked");
+        return true;
+      } catch {
+        snackBar.error(
+          "An unexpected error happened while linking the legacy profile.",
+        );
+      }
+      return false;
+    };
+
+    return {
+      sinDisplayFormat,
+      dateOnlyLongString,
+      emptyStringFiller,
+      Role,
+      BannerTypes,
+      legacyStudentMatches,
+      loadStudentLegacyMatches,
+      studentLegacyMatchesLoading,
+      StudentProfileLegacyMatchHeaders,
+      showLegacyMatches,
+      legacyMatchesModal,
+      showLinkProfileModal,
+    };
+  },
+});
+</script>
diff --git a/sources/packages/web/src/services/StudentService.ts b/sources/packages/web/src/services/StudentService.ts
index 797802adc1..6b13af2a09 100644
--- a/sources/packages/web/src/services/StudentService.ts
+++ b/sources/packages/web/src/services/StudentService.ts
@@ -15,6 +15,8 @@ import {
   UpdateDisabilityStatusAPIInDTO,
   UpdateStudentDetailsAPIInDTO,
   AESTStudentFileDetailsAPIOutDTO,
+  LegacyStudentMatchesAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
 } from "@/services/http/dto";
 import { AxiosResponse } from "axios";
 
@@ -245,4 +247,27 @@ export class StudentService {
   ): Promise<void> {
     await ApiClient.Students.updateDisabilityStatus(studentId, payload);
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  async getStudentLegacyMatches(
+    studentId: number,
+  ): Promise<LegacyStudentMatchesAPIOutDTO> {
+    return ApiClient.Students.getStudentLegacyMatches(studentId);
+  }
+
+  /**
+   * Associates a student with a legacy profile.
+   * @param studentId student ID to be associated with the legacy profile.
+   * @param payload legacy profile to be associated.
+   */
+  async associateLegacyStudent(
+    studentId: number,
+    payload: LegacyStudentMatchesAPIInDTO,
+  ): Promise<void> {
+    await ApiClient.Students.associateLegacyStudent(studentId, payload);
+  }
 }
diff --git a/sources/packages/web/src/services/http/StudentApi.ts b/sources/packages/web/src/services/http/StudentApi.ts
index 62196e52b6..fd8b9af2d5 100644
--- a/sources/packages/web/src/services/http/StudentApi.ts
+++ b/sources/packages/web/src/services/http/StudentApi.ts
@@ -17,6 +17,8 @@ import {
   UpdateDisabilityStatusAPIInDTO,
   UpdateStudentDetailsAPIInDTO,
   AESTStudentFileDetailsAPIOutDTO,
+  LegacyStudentMatchesAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
 } from "@/services/http/dto";
 
 export class StudentApi extends HttpBaseClient {
@@ -226,4 +228,32 @@ export class StudentApi extends HttpBaseClient {
       payload,
     );
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  async getStudentLegacyMatches(
+    studentId: number,
+  ): Promise<LegacyStudentMatchesAPIOutDTO> {
+    return this.getCall(
+      this.addClientRoot(`student/${studentId}/legacy-match`),
+    );
+  }
+
+  /**
+   * Associates a student with a legacy profile.
+   * @param studentId student ID to be associated with the legacy profile.
+   * @param payload legacy profile to be associated.
+   */
+  async associateLegacyStudent(
+    studentId: number,
+    payload: LegacyStudentMatchesAPIInDTO,
+  ): Promise<void> {
+    await this.patchCall(
+      this.addClientRoot(`student/${studentId}/legacy-match`),
+      payload,
+    );
+  }
 }
diff --git a/sources/packages/web/src/services/http/dto/Student.dto.ts b/sources/packages/web/src/services/http/dto/Student.dto.ts
index 05e85d88ad..50d3f713ab 100644
--- a/sources/packages/web/src/services/http/dto/Student.dto.ts
+++ b/sources/packages/web/src/services/http/dto/Student.dto.ts
@@ -223,3 +223,20 @@ export interface UpdateDisabilityStatusAPIInDTO {
   disabilityStatus: DisabilityStatus;
   noteDescription: string;
 }
+
+export interface LegacyStudentMatchAPIOutDTO {
+  individualId: number;
+  firstName?: string;
+  lastName: string;
+  birthDate: string;
+  sin: string;
+}
+
+export interface LegacyStudentMatchesAPIOutDTO {
+  matches: LegacyStudentMatchAPIOutDTO[];
+}
+
+export interface LegacyStudentMatchesAPIInDTO {
+  individualId: number;
+  noteDescription: string;
+}
diff --git a/sources/packages/web/src/types/contracts/DataTableContract.ts b/sources/packages/web/src/types/contracts/DataTableContract.ts
index 0a104782d1..cf8c2ababc 100644
--- a/sources/packages/web/src/types/contracts/DataTableContract.ts
+++ b/sources/packages/web/src/types/contracts/DataTableContract.ts
@@ -512,3 +512,11 @@ export const PIRSummaryHeaders = [
   { title: "Status", key: "pirStatus", sortable: true },
   { title: "Actions", key: "actions", sortable: false },
 ];
+
+export const StudentProfileLegacyMatchHeaders = [
+  { title: "Given names", key: "firstName", sortable: false },
+  { title: "Last name", key: "lastName", sortable: false },
+  { title: "Date of birth", key: "birthDate", sortable: false },
+  { title: "SIN", key: "sin", sortable: false },
+  { title: "Actions", key: "actions", sortable: false },
+];
diff --git a/sources/packages/web/src/types/contracts/aest/roles.ts b/sources/packages/web/src/types/contracts/aest/roles.ts
index e54d844ff3..9a8c3e52ac 100644
--- a/sources/packages/web/src/types/contracts/aest/roles.ts
+++ b/sources/packages/web/src/types/contracts/aest/roles.ts
@@ -13,6 +13,7 @@ export enum Role {
   AESTCASExpenseAuthority = "aest-cas-expense-authority",
   AESTBypassStudentRestriction = "aest-bypass-student-restriction",
   AESTQueueDashboardAdmin = "aest-queue-dashboard-admin",
+  StudentLinkLegacyProfile = "student-link-legacy-profile",
   StudentAddRestriction = "student-add-restriction",
   StudentResolveRestriction = "student-resolve-restriction",
   StudentUploadFile = "student-upload-file",
diff --git a/sources/packages/web/src/views/aest/student/Profile.vue b/sources/packages/web/src/views/aest/student/Profile.vue
index f57878696e..41517c1282 100644
--- a/sources/packages/web/src/views/aest/student/Profile.vue
+++ b/sources/packages/web/src/views/aest/student/Profile.vue
@@ -3,6 +3,7 @@
     <student-profile
       :studentId="studentId"
       :allowDisabilityStatusUpdate="true"
+      :showLegacyMatch="true"
     />
   </tab-container>
 </template>


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR implements legacy profile linking functionality for student profiles through both frontend and backend changes.  
- Added new role "aest-student-link-legacy-profile".  
- Created new endpoints and service functions to query and associate legacy profiles.  
- Introduced new Vue components and modals to display possible legacy matches and capture linking notes.

### Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| sources/packages/web/src/views/aest/student/Profile.vue | Added prop to conditionally show legacy match component. |
| sources/packages/web/src/types/contracts/aest/roles.ts | Added new role for linking legacy profiles. |
| sources/packages/web/src/types/contracts/DataTableContract.ts | Added headers for legacy match data table. |
| sources/packages/web/src/services/http/dto/Student.dto.ts | Introduced DTOs for legacy student matching. |
| sources/packages/web/src/services/http/StudentApi.ts | New API endpoints for legacy matching and association. |
| sources/packages/web/src/services/StudentService.ts | New service functions for legacy matching and association. |
| sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue | New component for displaying legacy profile matches. |
| sources/packages/web/src/components/common/students/StudentProfile.vue | Integrated legacy match component into student profile. |
| sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue | Modal to capture notes and confirm linking. |
| sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts | New backend service functions for legacy match retrieval and association. |
| sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts | Added validation logic for legacy matches. |
| sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts | New endpoints for retrieving and associating legacy profiles. |
| sources/packages/backend/apps/api/src/route-controllers/student/models/student.dto.ts | New DTOs for legacy student matching. |
| sources/packages/backend/apps/api/src/auth/roles.enum.ts | Added new role to backend roles enum. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T17:31:08Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces functionality for linking a student to a legacy profile via new roles, endpoints, UI components, and backend logic. Key changes include:
- The creation of a new role ("aest-student-link-legacy-profile") and related endpoints in both the frontend and backend to support legacy profile linking.
- The implementation of a new modal and data table configuration for displaying possible legacy matches.
- Adjustments to the existing student profile UI and service layers to integrate legacy matching.

### Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| sources/packages/web/src/views/aest/student/Profile.vue | Added prop to display the legacy match section on the student profile page. |
| sources/packages/web/src/types/contracts/aest/roles.ts | Introduced new role for legacy profile linking. |
| sources/packages/web/src/types/contracts/DataTableContract.ts | Added headers for the student legacy match data table. |
| sources/packages/web/src/services/http/dto/Student.dto.ts | New DTOs for handling legacy match data. |
| sources/packages/web/src/services/http/StudentApi.ts | Added API calls for fetching and associating legacy matches. |
| sources/packages/web/src/services/StudentService.ts | Extended service methods to support legacy matching endpoints. |
| sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue | Created a new component to display legacy match details and actions. |
| sources/packages/web/src/components/common/students/StudentProfile.vue | Integrated the new legacy match component with conditional rendering. |
| sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue | Developed a modal for capturing notes and linking legacy profiles. |
| sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts | Added backend methods for retrieving potential legacy matches and associating students. |
| sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts | Implemented validation and retrieval of legacy match candidates. |
| sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts | Created new endpoints for legacy matching and association. |
| sources/packages/backend/apps/api/src/route-controllers/student/models/student.dto.ts | Added DTO validations for legacy match operations. |
| sources/packages/backend/apps/api/src/auth/roles.enum.ts | Added new role enum for legacy profile linking. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts:157**
* [nitpick] Consider enhancing the error handling by including additional context or using a custom exception class, which could improve troubleshooting and debugging in production.
```
throw new Error(`Error while associating student ID ${studentId} to legacy individual ID ${individualId}. The individual was not found or a student ID is already associated with it.`);
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:09:34Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:13:31Z
user: login: sh16011993
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:30:08Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:44:57Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:53:19Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:03:25Z
user: login: sh16011993
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:08:58Z
user: login: sh16011993
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:10:47Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:13:00Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:25:18Z
user: login: sh16011993
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:30:10Z
user: login: sh16011993
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:36:16Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:44:38Z
user: login: sh16011993
body: Great work @andrewsignori-aot 👍 Thanks for clarifying my questions.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:46:32Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:48:00Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:57:09Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T23:18:37Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T23:58:01Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:01:08Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:01:36Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:05:19Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:10:39Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:17:48Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:21:22Z
user: login: dheepak-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:26:15Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:29:00Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:29:16Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:30:06Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:35:51Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:38:20Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:39:37Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:41:30Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:43:35Z
user: login: andrewsignori-aot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T00:47:27Z
user: login: dheepak-aot
body: Thanks for making the changes and discussions. Looks good 👍
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-08T15:49:54Z

Review comments:
diff_hunk: @@ -512,3 +512,11 @@ export const PIRSummaryHeaders = [
   { title: "Status", key: "pirStatus", sortable: true },
   { title: "Actions", key: "actions", sortable: false },
 ];
+
+export const StudentProfileLegacyMatchHeaders = [
+  { title: "Given Names", key: "firstName", sortable: false },
+  { title: "Last Name", key: "lastName", sortable: false },
+  { title: "Date of Birth ", key: "birthDate", sortable: false },
path: sources/packages/web/src/types/contracts/DataTableContract.ts
user: login: Copilot
body: Remove the trailing space from the 'Date of Birth ' title to ensure consistency with other headers.
```suggestion
  { title: "Date of Birth", key: "birthDate", sortable: false },
```
created_at: 2025-05-07T17:31:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
+  @Get(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description: "Student already has a legacy profile associated.",
+  })
+  async getStudentLegacyMatches(
+    @Param("studentId", ParseIntPipe) studentId: number,
+  ): Promise<LegacyStudentMatchesAPIOutDTO> {
+    const possibleIndividualsForAssociation =
+      await this.studentControllerService.validateAndGetStudentLegacyMatches(
+        studentId,
+      );
+    return {
+      matches: possibleIndividualsForAssociation.map((sfasIndividual) => ({
+        individualId: sfasIndividual.id,
+        firstName: sfasIndividual.firstName,
+        lastName: sfasIndividual.lastName,
+        birthDate: sfasIndividual.birthDate,
+        sin: sfasIndividual.sin,
+      })),
+    };
+  }
+
+  /**
+   * Associates a student with a legacy profile.
+   * @param studentId student ID to be associated with the legacy profile.
+   * @param payload legacy profile to be associated.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
+  @Patch(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description:
+      "Student already has a legacy profile associated " +
+      "or provided individual is not listed as a potential profile match.",
+  })
+  async associateLegacyStudent(
+    @Param("studentId", ParseIntPipe) studentId: number,
+    @Body() payload: LegacyStudentMatchesAPIInDTO,
+    @UserToken() userToken: IUserToken,
+  ): Promise<void> {
+    const possibleIndividualsForAssociation =
+      await this.studentControllerService.validateAndGetStudentLegacyMatches(
+        studentId,
+      );
+    const hasPotentialIndividualMatch = possibleIndividualsForAssociation.some(
+      (possibleIndividual) => possibleIndividual.id === payload.individualId,
+    );
+    if (!hasPotentialIndividualMatch) {
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: Copilot
body: [nitpick] When no potential individual match is found, consider logging the payload or additional debugging information to help diagnose mismatches in the legacy profile association flow.
```suggestion
    if (!hasPotentialIndividualMatch) {
      console.error(
        `Legacy profile association mismatch: studentId=${studentId}, ` +
        `individualId=${payload.individualId}. No matching profile found.`,
      );
```
created_at: 2025-05-07T18:09:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,7 @@ export enum Role {
   AESTCASExpenseAuthority = "aest-cas-expense-authority",
   AESTBypassStudentRestriction = "aest-bypass-student-restriction",
   AESTQueueDashboardAdmin = "aest-queue-dashboard-admin",
+  AESTStudentLinkLegacyProfile = "aest-student-link-legacy-profile",
path: sources/packages/backend/apps/api/src/auth/roles.enum.ts
user: login: dheepak-aot
body: For an AEST role name with student action, we have been starting with with `student-`

![image](https://github.com/user-attachments/assets/ab481945-0eb0-4714-b497-93f78e2ae943)

created_at: 2025-05-07T18:13:31Z
author_association: COLLABORATOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
+  @Get(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description: "Student already has a legacy profile associated.",
+  })
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: sh16011993
body: 👍 
created_at: 2025-05-07T18:30:08Z
author_association: COLLABORATOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: dheepak-aot
body: Why did we not consider a separate controller for the legacy endpoints? I feel that the legacy data is a different resource and even if otherwise the legacy operations can have a separate controller.
created_at: 2025-05-07T18:44:57Z
author_association: COLLABORATOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
+  @Get(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description: "Student already has a legacy profile associated.",
+  })
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: dheepak-aot
body: Do we require this validation in the GET API. I believe it is not an API consideration for the GET API.
created_at: 2025-05-07T18:53:19Z
author_association: COLLABORATOR
diff_hunk: @@ -415,6 +419,32 @@ export class StudentControllerService {
     }));
   }
 
+  /**
+   * Validate the conditions to allow the student to be associated with a legacy profile.
+   * @param studentId student ID to have a legacy profile associated with.
+   * @returns possible legacy profile that matches the student data.
path: sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts
user: login: dheepak-aot
body: profiles ?
created_at: 2025-05-07T19:03:25Z
author_association: COLLABORATOR
diff_hunk: @@ -92,6 +93,84 @@ export class SFASIndividualService {
       .getOne();
   }
 
+  /**
+   * Get students that could potentially be associated with the given student.
+   * @param lastName student last name, must be an exact match.
+   * @param birthDate student date of birth.
+   * @param sin student social insurance number.
+   * @returns SFAS students that could be potentially associated to a SIMS student.
+   */
+  async getIndividualStudentPartialMatchForAssociation(
+    lastName: string,
+    birthDate: string,
+    sin: string,
+  ): Promise<SFASIndividual[]> {
+    return this.sfasIndividualRepo
+      .createQueryBuilder("individual")
+      .select([
+        "individual.id",
+        "individual.firstName",
+        "individual.lastName",
+        "individual.sin",
+        "individual.birthDate",
+      ])
+      .where("individual.student.id IS NULL")
+      .andWhere(
+        new Brackets((qb) =>
+          qb.where("individual.sin = :sin", { sin }).orWhere(
+            new Brackets((qb) => {
path: sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
user: login: sh16011993
body: 👍 
created_at: 2025-05-07T19:08:57Z
author_association: COLLABORATOR
diff_hunk: @@ -415,6 +419,32 @@ export class StudentControllerService {
     }));
   }
 
+  /**
+   * Validate the conditions to allow the student to be associated with a legacy profile.
+   * @param studentId student ID to have a legacy profile associated with.
+   * @returns possible legacy profile that matches the student data.
path: sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts
user: login: sh16011993
body: Minor correction: `possible legacy profiles that match the student data.`
created_at: 2025-05-07T19:10:47Z
author_association: COLLABORATOR
diff_hunk: @@ -92,6 +93,84 @@ export class SFASIndividualService {
       .getOne();
   }
 
+  /**
+   * Get students that could potentially be associated with the given student.
+   * @param lastName student last name, must be an exact match.
+   * @param birthDate student date of birth.
+   * @param sin student social insurance number.
+   * @returns SFAS students that could be potentially associated to a SIMS student.
+   */
+  async getIndividualStudentPartialMatchForAssociation(
+    lastName: string,
+    birthDate: string,
+    sin: string,
+  ): Promise<SFASIndividual[]> {
+    return this.sfasIndividualRepo
+      .createQueryBuilder("individual")
+      .select([
+        "individual.id",
+        "individual.firstName",
+        "individual.lastName",
+        "individual.sin",
+        "individual.birthDate",
+      ])
+      .where("individual.student.id IS NULL")
+      .andWhere(
+        new Brackets((qb) =>
+          qb.where("individual.sin = :sin", { sin }).orWhere(
+            new Brackets((qb) => {
+              qb.where("lower(individual.lastName) = :lastName", {
+                lastName: lastName.toLowerCase(),
+              }).andWhere("individual.birthDate = :birthDate", {
+                birthDate,
+              });
+            }),
+          ),
+        ),
+      )
+      .getMany();
+  }
+
+  /**
+   * Associate a student to a legacy profile and reset the restrictions
+   * to allow them to be reprocessed and associated with the new student.
+   * @param individualId legacy individual to have the student associated.
+   * @param studentId student to be associated to the legacy profile.
+   * @param noteDescription note description to be added to the student notes.
+   * @param auditUserId user crating the profile link.
+   */
+  async associateStudentProfile(
+    individualId: number,
+    studentId: number,
+    noteDescription: string,
+    auditUserId: number,
+  ): Promise<void> {
+    await this.dataSource.transaction(async (entityManager) => {
+      const updateResult = await entityManager
+        .getRepository(SFASIndividual)
+        .update(
+          { id: individualId, student: IsNull() },
path: sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
user: login: dheepak-aot
body: 👍
created_at: 2025-05-07T19:12:59Z
author_association: COLLABORATOR
diff_hunk: @@ -109,53 +109,16 @@
         /></v-col>
       </v-row>
     </content-group>
-    <h3 class="category-header-medium mt-4">Legacy Match</h3>
-    <content-group v-if="studentDetail.legacyProfile">
-      <v-row>
-        <v-col
-          ><title-value
-            propertyTitle="Given names"
-            :propertyValue="
-              emptyStringFiller(studentDetail.legacyProfile.firstName)
-            "
-          />
-        </v-col>
-        <v-col
-          ><title-value
-            propertyTitle="Last name"
-            :propertyValue="studentDetail.legacyProfile.lastName"
-          />
-        </v-col>
-        <v-col>
-          <title-value
-            propertyTitle="Date of birth"
-            :propertyValue="
-              dateOnlyLongString(studentDetail.legacyProfile.dateOfBirth)
-            "
-        /></v-col>
-      </v-row>
-      <v-row>
-        <v-col
-          ><title-value
-            propertyTitle="SIN"
-            :propertyValue="sinDisplayFormat(studentDetail.legacyProfile.sin)"
-          />
-        </v-col>
-      </v-row>
-      <v-row v-if="studentDetail.legacyProfile.hasMultipleProfiles">
-        <v-col
-          ><banner
-            :type="BannerTypes.Warning"
-            summary="The student is associated with multiple legacy profiles; the most recent update is shown."
-          ></banner>
-        </v-col>
-      </v-row>
-    </content-group>
-    <banner
-      v-else
-      :type="BannerTypes.Info"
-      summary="The student is not currently associated with any legacy record."
-    ></banner>
+    <template v-if="showLegacyMatch">
+      <h3 class="category-header-medium mt-4">Legacy match</h3>
+      <content-group>
+        <student-profile-legacy-matches
+          :studentId="studentId"
path: sources/packages/web/src/components/common/students/StudentProfile.vue
user: login: dheepak-aot
body: Please use the `-` separated format for vue attributes.
created_at: 2025-05-07T19:25:18Z
author_association: COLLABORATOR
diff_hunk: @@ -92,6 +93,84 @@ export class SFASIndividualService {
       .getOne();
   }
 
+  /**
+   * Get students that could potentially be associated with the given student.
+   * @param lastName student last name, must be an exact match.
+   * @param birthDate student date of birth.
+   * @param sin student social insurance number.
+   * @returns SFAS students that could be potentially associated to a SIMS student.
+   */
+  async getIndividualStudentPartialMatchForAssociation(
+    lastName: string,
+    birthDate: string,
+    sin: string,
+  ): Promise<SFASIndividual[]> {
+    return this.sfasIndividualRepo
+      .createQueryBuilder("individual")
+      .select([
+        "individual.id",
+        "individual.firstName",
+        "individual.lastName",
+        "individual.sin",
+        "individual.birthDate",
+      ])
+      .where("individual.student.id IS NULL")
+      .andWhere(
+        new Brackets((qb) =>
+          qb.where("individual.sin = :sin", { sin }).orWhere(
+            new Brackets((qb) => {
+              qb.where("lower(individual.lastName) = :lastName", {
+                lastName: lastName.toLowerCase(),
+              }).andWhere("individual.birthDate = :birthDate", {
+                birthDate,
+              });
+            }),
+          ),
+        ),
+      )
+      .getMany();
+  }
+
+  /**
+   * Associate a student to a legacy profile and reset the restrictions
+   * to allow them to be reprocessed and associated with the new student.
+   * @param individualId legacy individual to have the student associated.
+   * @param studentId student to be associated to the legacy profile.
+   * @param noteDescription note description to be added to the student notes.
+   * @param auditUserId user crating the profile link.
path: sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
user: login: sh16011993
body: spelling typo correction: `creating`
created_at: 2025-05-07T19:30:10Z
author_association: COLLABORATOR
diff_hunk: @@ -92,6 +93,84 @@ export class SFASIndividualService {
       .getOne();
   }
 
+  /**
+   * Get students that could potentially be associated with the given student.
+   * @param lastName student last name, must be an exact match.
+   * @param birthDate student date of birth.
+   * @param sin student social insurance number.
+   * @returns SFAS students that could be potentially associated to a SIMS student.
+   */
+  async getIndividualStudentPartialMatchForAssociation(
+    lastName: string,
+    birthDate: string,
+    sin: string,
+  ): Promise<SFASIndividual[]> {
+    return this.sfasIndividualRepo
+      .createQueryBuilder("individual")
+      .select([
+        "individual.id",
+        "individual.firstName",
+        "individual.lastName",
+        "individual.sin",
+        "individual.birthDate",
+      ])
+      .where("individual.student.id IS NULL")
+      .andWhere(
+        new Brackets((qb) =>
+          qb.where("individual.sin = :sin", { sin }).orWhere(
+            new Brackets((qb) => {
+              qb.where("lower(individual.lastName) = :lastName", {
+                lastName: lastName.toLowerCase(),
+              }).andWhere("individual.birthDate = :birthDate", {
+                birthDate,
+              });
+            }),
+          ),
+        ),
+      )
+      .getMany();
+  }
+
+  /**
+   * Associate a student to a legacy profile and reset the restrictions
+   * to allow them to be reprocessed and associated with the new student.
+   * @param individualId legacy individual to have the student associated.
+   * @param studentId student to be associated to the legacy profile.
+   * @param noteDescription note description to be added to the student notes.
+   * @param auditUserId user crating the profile link.
+   */
+  async associateStudentProfile(
+    individualId: number,
+    studentId: number,
+    noteDescription: string,
+    auditUserId: number,
+  ): Promise<void> {
+    await this.dataSource.transaction(async (entityManager) => {
+      const updateResult = await entityManager
+        .getRepository(SFASIndividual)
+        .update(
+          { id: individualId, student: IsNull() },
+          { student: { id: studentId } },
+        );
+      if (!updateResult.affected) {
+        throw new Error(
+          `Error while associating student ID ${studentId} to legacy individual ID ${individualId}. The individual was not found or a student ID is already associated with it.`,
+        );
+      }
+      const updateRestrictionsPromise = entityManager
+        .getRepository(SFASRestriction)
+        .update({ individualId, processed: true }, { processed: false });
path: sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
user: login: sh16011993
body: 👍 
created_at: 2025-05-07T19:36:16Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,7 @@ export enum Role {
   AESTCASExpenseAuthority = "aest-cas-expense-authority",
   AESTBypassStudentRestriction = "aest-bypass-student-restriction",
   AESTQueueDashboardAdmin = "aest-queue-dashboard-admin",
+  AESTStudentLinkLegacyProfile = "aest-student-link-legacy-profile",
path: sources/packages/backend/apps/api/src/auth/roles.enum.ts
user: login: andrewsignori-aot
body: I will change it.
created_at: 2025-05-07T19:44:38Z
author_association: COLLABORATOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
+  @Get(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description: "Student already has a legacy profile associated.",
+  })
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: andrewsignori-aot
body: The method right now should not be invoked for any other reason. If someday it will be reused, it can be easily adapted, assuming this day will come before we remove the SFAS-related code.
Please let me know if it is a blocker.
created_at: 2025-05-07T19:48:00Z
author_association: COLLABORATOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: andrewsignori-aot
body: There was a consideration, and I did not find a strong reason to create a new controller structure for these ones. For the following reasons.
- We also have SIN validation and documents that may be considered separate resources and yet they are in the same controller.
- I did not see SFAS as something other than temporary, and creating a new controller also will add more boilerplate classes around API and Web.
- I did not anticipate SFAS features being expanded in the API.
In summary, IMO, adding to the student would be an acceptable and simple way to achieve the goal for this effort.
created_at: 2025-05-07T19:57:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: dheepak-aot
body: This is currently visible for users without the appropriate AEST role. 
![image](https://github.com/user-attachments/assets/38703ef3-5e9a-471a-81ec-b20fa180bc4c)

created_at: 2025-05-07T23:18:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: dheepak-aot
body: Do we require this bottom template for any reason?
created_at: 2025-05-07T23:58:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: There is no requirement to hide it for users without the role. My understanding is that the action would be blocked only.
The AC states
> - [ ] Under the Student Profile tab visible to Ministry users only a new section for: Legacy Match
> - [ ] Access to the manual match should be a new role
created_at: 2025-05-08T00:01:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as () => LegacyStudentProfileAPIOutDTO,
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: dheepak-aot
body: In other places we have been defining the property type using. `type: Object as PropType<LegacyStudentProfileAPIOutDTO>` is there a reason to use this way?
created_at: 2025-05-08T00:01:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as () => LegacyStudentProfileAPIOutDTO,
+      required: false,
+    },
+  },
+  setup(props, { emit }) {
+    const snackBar = useSnackBar();
+    const { sinDisplayFormat, emptyStringFiller, dateOnlyLongString } =
+      useFormatters();
+    const legacyStudentMatches = ref<LegacyStudentMatchAPIOutDTO[]>();
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: dheepak-aot
body: Not a blocker. Influenced by one of the co-pilot comments can we initialize an array as empty this way ?

```ts
const legacyStudentMatches = ref([] as LegacyStudentMatchAPIOutDTO[]);

```
created_at: 2025-05-08T00:05:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as () => LegacyStudentProfileAPIOutDTO,
+      required: false,
+    },
+  },
+  setup(props, { emit }) {
+    const snackBar = useSnackBar();
+    const { sinDisplayFormat, emptyStringFiller, dateOnlyLongString } =
+      useFormatters();
+    const legacyStudentMatches = ref<LegacyStudentMatchAPIOutDTO[]>();
+    const studentLegacyMatchesLoading = ref(false);
+    const showLegacyMatches = ref(false);
+    const legacyMatchesModal = ref(
+      {} as ModalDialog<LegacyStudentMatchesAPIInDTO>,
+    );
+
+    const loadStudentLegacyMatches = async (): Promise<void> => {
+      studentLegacyMatchesLoading.value = true;
+      showLegacyMatches.value = true;
+      try {
+        const legacyMatches =
+          await StudentService.shared.getStudentLegacyMatches(props.studentId);
+        legacyStudentMatches.value = legacyMatches.matches;
+      } catch {
+        snackBar.error(
+          "An unexpected error happened while loading the legacy matches.",
+        );
+      } finally {
+        studentLegacyMatchesLoading.value = false;
+      }
+    };
+
+    const showLinkProfileModal = async (
+      individualId: number,
+    ): Promise<void> => {
+      await legacyMatchesModal.value.showModal(individualId, linkLegacyProfile);
+    };
+
+    const linkLegacyProfile = async (
+      payload: LegacyStudentMatchesAPIInDTO,
+    ): Promise<boolean> => {
+      try {
+        await StudentService.shared.associateLegacyStudent(props.studentId, {
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: dheepak-aot
body: We can pass the payload as it is. 

```ts
await StudentService.shared.associateLegacyStudent(
          props.studentId,
          payload,
        );
```
created_at: 2025-05-08T00:10:38Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+<template>
+  <v-form ref="linkProfileForm">
+    <modal-dialog-base :showDialog="showDialog" title="Confirm profile link"
path: sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue
user: login: dheepak-aot
body: There is something called error summary that is there in many forms but also missed in many forms as well. 
![image](https://github.com/user-attachments/assets/e0bc7361-2d04-468a-8f25-e0a9d71662f5)



created_at: 2025-05-08T00:17:48Z
author_association: COLLABORATOR
diff_hunk: @@ -464,4 +468,71 @@ export class StudentAESTController extends BaseController {
       );
     }
   }
+
+  /**
+   * Get possible matches for the student from the legacy system.
+   * @param studentId student ID to retrieve the data.
+   * @returns student legacy profile details.
+   */
+  @Roles(Role.AESTStudentLinkLegacyProfile)
+  @Get(":studentId/legacy-match")
+  @ApiNotFoundResponse({ description: "Student not found." })
+  @ApiUnprocessableEntityResponse({
+    description: "Student already has a legacy profile associated.",
+  })
path: sources/packages/backend/apps/api/src/route-controllers/student/student.aest.controller.ts
user: login: dheepak-aot
body: It is not a blocker.
created_at: 2025-05-08T00:21:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: dheepak-aot
body: Because, clicking on the button is resulting in forbidden error from API which is generally not a UI path. From our application standard I believe we must atleast make the banner button not clickable.
created_at: 2025-05-08T00:26:15Z
author_association: COLLABORATOR
diff_hunk: @@ -415,6 +419,32 @@ export class StudentControllerService {
     }));
   }
 
+  /**
+   * Validate the conditions to allow the student to be associated with a legacy profile.
+   * @param studentId student ID to have a legacy profile associated with.
+   * @returns possible legacy profile that matches the student data.
path: sources/packages/backend/apps/api/src/route-controllers/student/student.controller.service.ts
user: login: andrewsignori-aot
body: Fixed.
created_at: 2025-05-08T00:29:00Z
author_association: COLLABORATOR
diff_hunk: @@ -92,6 +93,84 @@ export class SFASIndividualService {
       .getOne();
   }
 
+  /**
+   * Get students that could potentially be associated with the given student.
+   * @param lastName student last name, must be an exact match.
+   * @param birthDate student date of birth.
+   * @param sin student social insurance number.
+   * @returns SFAS students that could be potentially associated to a SIMS student.
+   */
+  async getIndividualStudentPartialMatchForAssociation(
+    lastName: string,
+    birthDate: string,
+    sin: string,
+  ): Promise<SFASIndividual[]> {
+    return this.sfasIndividualRepo
+      .createQueryBuilder("individual")
+      .select([
+        "individual.id",
+        "individual.firstName",
+        "individual.lastName",
+        "individual.sin",
+        "individual.birthDate",
+      ])
+      .where("individual.student.id IS NULL")
+      .andWhere(
+        new Brackets((qb) =>
+          qb.where("individual.sin = :sin", { sin }).orWhere(
+            new Brackets((qb) => {
+              qb.where("lower(individual.lastName) = :lastName", {
+                lastName: lastName.toLowerCase(),
+              }).andWhere("individual.birthDate = :birthDate", {
+                birthDate,
+              });
+            }),
+          ),
+        ),
+      )
+      .getMany();
+  }
+
+  /**
+   * Associate a student to a legacy profile and reset the restrictions
+   * to allow them to be reprocessed and associated with the new student.
+   * @param individualId legacy individual to have the student associated.
+   * @param studentId student to be associated to the legacy profile.
+   * @param noteDescription note description to be added to the student notes.
+   * @param auditUserId user crating the profile link.
path: sources/packages/backend/libs/services/src/sfas/sfas-individual.service.ts
user: login: andrewsignori-aot
body: Fixed.
created_at: 2025-05-08T00:29:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: The button not being clickable is an error, I will check it.
created_at: 2025-05-08T00:30:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: The role is now applied to this button.

![image](https://github.com/user-attachments/assets/8a5dd6ae-9fae-41df-a02e-92fed024d0f1)

created_at: 2025-05-08T00:35:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: Change to have `hide-default-footer` instead of the default.

created_at: 2025-05-08T00:38:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as () => LegacyStudentProfileAPIOutDTO,
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: Added by mistake, adjusted as recommended.
created_at: 2025-05-08T00:39:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as () => LegacyStudentProfileAPIOutDTO,
+      required: false,
+    },
+  },
+  setup(props, { emit }) {
+    const snackBar = useSnackBar();
+    const { sinDisplayFormat, emptyStringFiller, dateOnlyLongString } =
+      useFormatters();
+    const legacyStudentMatches = ref<LegacyStudentMatchAPIOutDTO[]>();
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: I will change since the property will be updated also from an empty array and will never be undefined.
created_at: 2025-05-08T00:41:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,200 @@
+<template>
+  <template v-if="legacyProfile">
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="Given names"
+          :propertyValue="emptyStringFiller(legacyProfile.firstName)"
+        />
+      </v-col>
+      <v-col
+        ><title-value
+          propertyTitle="Last name"
+          :propertyValue="legacyProfile.lastName"
+        />
+      </v-col>
+      <v-col>
+        <title-value
+          propertyTitle="Date of birth"
+          :propertyValue="dateOnlyLongString(legacyProfile.dateOfBirth)"
+      /></v-col>
+    </v-row>
+    <v-row>
+      <v-col
+        ><title-value
+          propertyTitle="SIN"
+          :propertyValue="sinDisplayFormat(legacyProfile.sin)"
+        />
+      </v-col>
+    </v-row>
+    <v-row v-if="legacyProfile.hasMultipleProfiles">
+      <v-col
+        ><banner
+          :type="BannerTypes.Warning"
+          summary="The student is associated with multiple legacy profiles; the most recent update is shown."
+        ></banner>
+      </v-col>
+    </v-row>
+  </template>
+  <template v-else>
+    <banner
+      :type="BannerTypes.Info"
+      summary="The student is not currently associated with any legacy record. Please check the legacy match information to see if there are any potential matches."
+    >
+      <template #actions>
+        <v-btn color="primary" @click="loadStudentLegacyMatches()">
+          Check for legacy matches
+          <v-tooltip activator="parent" location="start"
+            >Click to check for potential legacy matches.</v-tooltip
+          >
+        </v-btn></template
+      >
+    </banner>
+    <toggle-content
+      v-if="showLegacyMatches"
+      :toggled="!legacyStudentMatches?.length && !studentLegacyMatchesLoading"
+      message="No potential legacy matches found."
+    >
+      <v-data-table
+        :headers="StudentProfileLegacyMatchHeaders"
+        :items="legacyStudentMatches"
+        :loading="studentLegacyMatchesLoading"
+        :items-per-page="-1"
+      >
+        <template v-slot:loading>
+          <v-skeleton-loader type="table-row@3"></v-skeleton-loader>
+        </template>
+        <template #[`item.firstName`]="{ item }">
+          {{ emptyStringFiller(item.firstName) }}
+        </template>
+        <template #[`item.lastName`]="{ item }">
+          {{ item.lastName }}
+        </template>
+        <template #[`item.birthDate`]="{ item }">
+          {{ dateOnlyLongString(item.birthDate) }}
+        </template>
+        <template #[`item.sin`]="{ item }">
+          {{ sinDisplayFormat(item.sin) }}
+        </template>
+        <template #[`item.actions`]="{ item }">
+          <check-permission-role :role="Role.StudentLinkLegacyProfile">
+            <template #="{ notAllowed }">
+              <v-btn
+                color="primary"
+                :disabled="notAllowed"
+                @click="showLinkProfileModal(item.individualId)"
+              >
+                Link
+                <v-tooltip activator="parent" location="start"
+                  >Click to link student with this legacy profile.</v-tooltip
+                >
+              </v-btn>
+            </template>
+          </check-permission-role>
+        </template>
+        <template #bottom></template>
+      </v-data-table>
+    </toggle-content>
+    <student-profile-legacy-matches-modal ref="legacyMatchesModal" />
+  </template>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent } from "vue";
+import { StudentService } from "@/services/StudentService";
+import { ModalDialog, useFormatters, useSnackBar } from "@/composables";
+import {
+  LegacyStudentMatchAPIOutDTO,
+  LegacyStudentMatchesAPIInDTO,
+  LegacyStudentProfileAPIOutDTO,
+} from "@/services/http/dto";
+import { Role, BannerTypes, StudentProfileLegacyMatchHeaders } from "@/types";
+import CheckPermissionRole from "@/components/generic/CheckPermissionRole.vue";
+import StudentProfileLegacyMatchesModal from "@/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue";
+
+export default defineComponent({
+  emits: {
+    legacyProfileLinked: null,
+  },
+  components: {
+    CheckPermissionRole,
+    StudentProfileLegacyMatchesModal,
+  },
+  props: {
+    studentId: {
+      type: Number,
+      required: true,
+    },
+    legacyProfile: {
+      type: Object as () => LegacyStudentProfileAPIOutDTO,
+      required: false,
+    },
+  },
+  setup(props, { emit }) {
+    const snackBar = useSnackBar();
+    const { sinDisplayFormat, emptyStringFiller, dateOnlyLongString } =
+      useFormatters();
+    const legacyStudentMatches = ref<LegacyStudentMatchAPIOutDTO[]>();
+    const studentLegacyMatchesLoading = ref(false);
+    const showLegacyMatches = ref(false);
+    const legacyMatchesModal = ref(
+      {} as ModalDialog<LegacyStudentMatchesAPIInDTO>,
+    );
+
+    const loadStudentLegacyMatches = async (): Promise<void> => {
+      studentLegacyMatchesLoading.value = true;
+      showLegacyMatches.value = true;
+      try {
+        const legacyMatches =
+          await StudentService.shared.getStudentLegacyMatches(props.studentId);
+        legacyStudentMatches.value = legacyMatches.matches;
+      } catch {
+        snackBar.error(
+          "An unexpected error happened while loading the legacy matches.",
+        );
+      } finally {
+        studentLegacyMatchesLoading.value = false;
+      }
+    };
+
+    const showLinkProfileModal = async (
+      individualId: number,
+    ): Promise<void> => {
+      await legacyMatchesModal.value.showModal(individualId, linkLegacyProfile);
+    };
+
+    const linkLegacyProfile = async (
+      payload: LegacyStudentMatchesAPIInDTO,
+    ): Promise<boolean> => {
+      try {
+        await StudentService.shared.associateLegacyStudent(props.studentId, {
path: sources/packages/web/src/components/common/students/StudentProfileLegacyMatches.vue
user: login: andrewsignori-aot
body: Changed.
created_at: 2025-05-08T00:43:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+<template>
+  <v-form ref="linkProfileForm">
+    <modal-dialog-base :showDialog="showDialog" title="Confirm profile link"
path: sources/packages/web/src/components/aest/students/modals/StudentProfileLegacyMatchesModal.vue
user: login: andrewsignori-aot
body: I am ok to try to enforce it. Changed as below.
![image](https://github.com/user-attachments/assets/f3df0ea9-d41b-4878-a550-0cf3d2e9def8)

created_at: 2025-05-08T00:47:27Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-08T00:54:27Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=bcgov_SIMS&pullRequest=4682) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=bcgov_SIMS&pullRequest=4682&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=bcgov_SIMS&pullRequest=4682&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=bcgov_SIMS&pullRequest=4682&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=bcgov_SIMS&pullRequest=4682&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.3% Duplication on New Code](https://sonarcloud.io/component_measures?id=bcgov_SIMS&pullRequest=4682&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=bcgov_SIMS&pullRequest=4682)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T00:54:56Z
body: <!-- coverage-monitor-action: Backend Unit Tests Coverage Report -->
## Backend Unit Tests Coverage Report

|  Totals | ![Coverage](https://img.shields.io/static/v1?label=coverage&message=25%&color=yellow) |
| :-- | :-- |
| Statements: | 21.86% ( 4030 / 18433 ) |
| Methods: | 9.87% ( 233 / 2360 ) |
| Lines: | 25.2% ( 3479 / 13805 ) |
| Branches: | 14.02% ( 318 / 2268 ) |

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T00:55:17Z
body: <!-- coverage-monitor-action: E2E Workflow Workers Coverage Report -->
## E2E Workflow Workers Coverage Report

|  Totals | ![Coverage](https://img.shields.io/static/v1?label=coverage&message=75%&color=green) |
| :-- | :-- |
| Statements: | 72.48% ( 677 / 934 ) |
| Methods: | 73.45% ( 83 / 113 ) |
| Lines: | 74.54% ( 530 / 711 ) |
| Branches: | 58.18% ( 64 / 110 ) |

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T00:57:45Z
body: <!-- coverage-monitor-action: E2E Queue Consumers Coverage Report -->
## E2E Queue Consumers Coverage Report

|  Totals | ![Coverage](https://img.shields.io/static/v1?label=coverage&message=88%&color=green) |
| :-- | :-- |
| Statements: | 85.75% ( 1511 / 1762 ) |
| Methods: | 83.41% ( 171 / 205 ) |
| Lines: | 88.12% ( 1246 / 1414 ) |
| Branches: | 65.73% ( 94 / 143 ) |

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T00:58:22Z
body: <!-- coverage-monitor-action: E2E SIMS API Coverage Report -->
## E2E SIMS API Coverage Report

|  Totals | ![Coverage](https://img.shields.io/static/v1?label=coverage&message=75%&color=green) |
| :-- | :-- |
| Statements: | 71.25% ( 6802 / 9546 ) |
| Methods: | 69.28% ( 841 / 1214 ) |
| Lines: | 74.72% ( 5277 / 7062 ) |
| Branches: | 53.86% ( 684 / 1270 ) |

author_association: NONE