Repository: bcgov/digital_marketplace
Pull request #504: Refactor/populate evaluations migration
Author: login: IanFonzie
Created: 2025-04-11T20:21:28Z | Merged: 2025-04-22T18:25:03Z

Schema changes to allow for backwards compatibility; relevant changes were made to: src/migrations/tasks/20240527213854_add-evaluation-committee-panel-tables.ts and src/migrations/tasks/20240718222006_swu-evaluation-tables.ts

NOTE: swuTeamQuestionResponses still have a score column; I'd prefer to not delete the column on the off chance that we need to migrate back, but was figuring we could rename it to denote that it's deprecated?


Diff:
diff --git a/src/back-end/lib/db/evaluations/sprint-with-us/team-questions.ts b/src/back-end/lib/db/evaluations/sprint-with-us/team-questions.ts
index a8ec43e68..8914cad9c 100644
--- a/src/back-end/lib/db/evaluations/sprint-with-us/team-questions.ts
+++ b/src/back-end/lib/db/evaluations/sprint-with-us/team-questions.ts
@@ -192,7 +192,10 @@ export const readManySWUTeamQuestionResponseEvaluations = tryDb<
   )
     .join("swuProposals", "swuProposals.id", "=", "evaluations.proposal")
     .where({
-      "evaluations.evaluationPanelMember": session.user.id,
+      // There are many evaluations, but only one consensus
+      ...(consensus
+        ? {}
+        : { "evaluations.evaluationPanelMember": session.user.id }),
       "swuProposals.opportunity": id
     });
 
diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts
index 237fab67e..54c2be630 100644
--- a/src/back-end/lib/permissions.ts
+++ b/src/back-end/lib/permissions.ts
@@ -901,13 +901,14 @@ export async function readOneSWUTeamQuestionResponseConsensus(
 ): Promise<boolean> {
   return (
     !!session &&
-    (isAdmin(session) || isGovernment(session)) &&
-    (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
-      evaluation.proposal.opportunity.status
-    ) ||
-      (evaluation.proposal.opportunity.status ===
-        SWUOpportunityStatus.EvaluationTeamQuestionsConsensus &&
-        evaluation.evaluationPanelMember.user.id === session.user.id))
+    (isAdmin(session) ||
+      (isGovernment(session) &&
+        (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
+          evaluation.proposal.opportunity.status
+        ) ||
+          (evaluation.proposal.opportunity.status ===
+            SWUOpportunityStatus.EvaluationTeamQuestionsConsensus &&
+            evaluation.evaluationPanelMember.user.id === session.user.id))))
   );
 }
 
@@ -938,20 +939,21 @@ export async function readManySWUTeamQuestionResponseConsensuses(
 ): Promise<boolean> {
   return (
     !!session &&
-    (isAdmin(session) || isGovernment(session)) &&
-    (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
-      opportunity.status
-    ) ||
-      (await isSWUOpportunityEvaluationPanelEvaluator(
-        connection,
-        session,
-        opportunity.id
-      )) ||
-      (await isSWUOpportunityEvaluationPanelChair(
-        connection,
-        session,
-        opportunity.id
-      )))
+    (isAdmin(session) ||
+      (isGovernment(session) &&
+        (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
+          opportunity.status
+        ) ||
+          (await isSWUOpportunityEvaluationPanelEvaluator(
+            connection,
+            session,
+            opportunity.id
+          )) ||
+          (await isSWUOpportunityEvaluationPanelChair(
+            connection,
+            session,
+            opportunity.id
+          )))))
   );
 }
 
@@ -962,22 +964,23 @@ export async function readManySWUTeamQuestionResponseEvaluationsForConsensus(
 ): Promise<boolean> {
   return (
     !!session &&
-    (isAdmin(session) || isGovernment(session)) &&
-    (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
-      proposal.opportunity.status
-    ) ||
-      (proposal.opportunity.status ===
-        SWUOpportunityStatus.EvaluationTeamQuestionsConsensus &&
-        ((await isSWUOpportunityEvaluationPanelEvaluator(
-          connection,
-          session,
-          proposal.opportunity.id
-        )) ||
-          (await isSWUOpportunityEvaluationPanelChair(
-            connection,
-            session,
-            proposal.opportunity.id
-          )))))
+    (isAdmin(session) ||
+      (isGovernment(session) &&
+        (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
+          proposal.opportunity.status
+        ) ||
+          (proposal.opportunity.status ===
+            SWUOpportunityStatus.EvaluationTeamQuestionsConsensus &&
+            ((await isSWUOpportunityEvaluationPanelEvaluator(
+              connection,
+              session,
+              proposal.opportunity.id
+            )) ||
+              (await isSWUOpportunityEvaluationPanelChair(
+                connection,
+                session,
+                proposal.opportunity.id
+              )))))))
   );
 }
 
diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/consensus.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/consensus.tsx
index 372be47df..d56cbf36e 100644
--- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/consensus.tsx
+++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/consensus.tsx
@@ -34,10 +34,16 @@ import {
   NUM_SCORE_DECIMALS,
   SWUProposalSlim
 } from "shared/lib/resources/proposal/sprint-with-us";
-import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/evaluations/sprint-with-us/team-questions";
+import {
+  SWUTeamQuestionResponseEvaluation,
+  SWUTeamQuestionResponseEvaluationStatus
+} from "shared/lib/resources/evaluations/sprint-with-us/team-questions";
 import { ADT, adt } from "shared/lib/types";
 import { isValid } from "shared/lib/validation";
 import { validateSWUTeamQuestionResponseEvaluationScores } from "shared/lib/validation/evaluations/sprint-with-us/team-questions";
+import { isAdmin } from "shared/lib/resources/user";
+
+type ModalId = "submit" | "finalize";
 
 export interface State extends Tab.Params {
   opportunity: SWUOpportunity | null;
@@ -47,6 +53,8 @@ export interface State extends Tab.Params {
   evaluations: SWUTeamQuestionResponseEvaluation[];
   proposals: SWUProposalSlim[];
   table: Immutable<Table.State>;
+  isChair: boolean;
+  showModal: ModalId | null;
 }
 
 export type InnerMsg =
@@ -56,7 +64,9 @@ export type InnerMsg =
   | ADT<
       "onSubmitResponse",
       api.ResponseValidation<SWUOpportunity, UpdateValidationErrors>
-    >;
+    >
+  | ADT<"showModal", ModalId>
+  | ADT<"hideModal">;
 
 export type Msg = component_.page.Msg<InnerMsg, Route>;
 
@@ -74,7 +84,9 @@ const init: component_.base.Init<Tab.Params, State, Msg> = (params) => {
       areEvaluationsValid: false,
       evaluations: [],
       proposals: [],
-      table: immutable(tableState)
+      table: immutable(tableState),
+      isChair: false,
+      showModal: null
     },
     component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg)
   ];
@@ -99,6 +111,9 @@ const update: component_.page.Update<State, InnerMsg, Route> = ({
           opportunity,
           SWUOpportunityStatus.EvaluationTeamQuestionsConsensus
         );
+      const isChair =
+        state.viewerUser.id ===
+        opportunity.evaluationPanel?.find(({ chair }) => chair)?.user.id;
       return [
         state
           .set("opportunity", opportunity)
@@ -108,17 +123,23 @@ const update: component_.page.Update<State, InnerMsg, Route> = ({
           // Determine whether the "Submit" button should be shown at all.
           // Can be submitted if...
           // - Opportunity has the appropriate status
-          // - All proposals have the appropriate status
           // - All questions have been evaluated.
           .set(
             "canEvaluationsBeSubmitted",
             opportunity.status ===
-              SWUOpportunityStatus.EvaluationTeamQuestionsConsensus
-          ),
+              SWUOpportunityStatus.EvaluationTeamQuestionsConsensus && isChair
+          )
+          .set("isChair", isChair),
         [component_.cmd.dispatch(component_.page.readyMsg())]
       ];
     }
 
+    case "showModal":
+      return [state.set("showModal", msg.value), []];
+
+    case "hideModal":
+      return [state.set("showModal", null), []];
+
     case "submit": {
       const opportunity = state.opportunity;
       if (!opportunity) return [state, []];
@@ -220,7 +241,8 @@ const ContextMenuCell: component_.base.View<{
   disabled: boolean;
   proposal: SWUProposalSlim;
   evaluation?: SWUTeamQuestionResponseEvaluation;
-}> = ({ disabled, proposal, evaluation }) => {
+  isChair: boolean;
+}> = ({ disabled, proposal, evaluation, isChair }) => {
   const proposalRouteParams = {
     proposalId: proposal.id,
     opportunityId: proposal.opportunity.id,
@@ -235,9 +257,9 @@ const ContextMenuCell: component_.base.View<{
           userId: evaluation.evaluationPanelMember.user.id
         })
       )}>
-      Edit
+      {isChair ? "Edit" : "View"}
     </Link>
-  ) : (
+  ) : isChair ? (
     <Link
       disabled={disabled}
       dest={routeDest(
@@ -245,7 +267,7 @@ const ContextMenuCell: component_.base.View<{
       )}>
       Start Evaluation
     </Link>
-  );
+  ) : null;
 };
 
 interface ProponentCellProps {
@@ -350,6 +372,7 @@ function evaluationTableBodyRows(state: Immutable<State>): Table.BodyRows {
             disabled={isLoading}
             proposal={p}
             evaluation={evaluation}
+            isChair={state.isChair}
           />
         )
       }
@@ -473,13 +496,75 @@ export const component: Tab.Component<State, Msg> = {
   update,
   view,
 
+  getModal: (state) => {
+    const opportunity = state.opportunity;
+    if (!opportunity) return component_.page.modal.hide();
+    switch (state.showModal) {
+      case "submit":
+        return component_.page.modal.show({
+          title: "Please Confirm",
+          onCloseMsg: adt("hideModal") as Msg,
+          actions: [
+            {
+              text: "Submit Scores for Consensus",
+              icon: "paper-plane",
+              color: "info",
+              button: true,
+              msg: adt("submit") as Msg
+            },
+            {
+              text: "Cancel",
+              color: "secondary",
+              msg: adt("hideModal")
+            }
+          ],
+          body: () =>
+            "By submitting this consensus, you, as the chair, along with the" +
+            "panelists, confirm your agreement with the consensus scores and" +
+            "comments."
+        });
+      case "finalize":
+        return component_.page.modal.show({
+          title: "Please Confirm",
+          onCloseMsg: adt("hideModal") as Msg,
+          actions: [
+            {
+              text: "Submit Final Consensus Scores",
+              icon: "paper-plane",
+              color: "info",
+              button: true,
+              msg: adt("hideModal") as Msg
+            },
+            {
+              text: "Cancel",
+              color: "secondary",
+              msg: adt("hideModal")
+            }
+          ],
+          body: () => (
+            <>
+              <p className="mb-4">
+                By submitting final consensus scores, you are about to lock in
+                all proponent scores and move on to short-listing stage
+              </p>
+              <p className="mb-0">
+                Are you sure you want to submit final consensus scores?
+              </p>
+            </>
+          )
+        });
+      case null:
+        return component_.page.modal.hide();
+    }
+  },
+
   onInitResponse(response) {
     return adt("onInitResponse", response);
   },
 
   getActions: ({ state, dispatch }) => {
     const opportunity = state.opportunity;
-    if (!opportunity || !state.canEvaluationsBeSubmitted) {
+    if (!opportunity) {
       return component_.page.actions.none();
     }
     const isLoading = state.submitLoading;
@@ -496,18 +581,39 @@ export const component: Tab.Component<State, Msg> = {
           ),
         true as boolean
       );
-    return component_.page.actions.links([
-      {
-        children: "Submit Scores for Consensus",
-        symbol_: leftPlacement(iconLinkSymbol("paper-plane")),
-        color: "primary",
-        button: true,
-        loading: isLoading,
-        disabled: (() => {
-          return isLoading || !areEvaluationsValid;
-        })(),
-        onClick: () => dispatch(adt("submit") as Msg)
-      }
-    ]);
+    const canEvaluationsBeFinalized =
+      state.evaluations.every(
+        ({ status }) =>
+          status === SWUTeamQuestionResponseEvaluationStatus.Submitted
+      ) && isAdmin(state.viewerUser);
+    return canEvaluationsBeFinalized
+      ? component_.page.actions.links([
+          {
+            children: "Submit Final Consensus Scores",
+            symbol_: leftPlacement(iconLinkSymbol("paper-plane")),
+            color: "primary",
+            button: true,
+            loading: isLoading,
+            disabled: (() => {
+              return isLoading;
+            })(),
+            onClick: () => dispatch(adt("showModal", "finalize") as Msg)
+          }
+        ])
+      : state.canEvaluationsBeSubmitted
+      ? component_.page.actions.links([
+          {
+            children: "Submit Scores for Consensus",
+            symbol_: leftPlacement(iconLinkSymbol("paper-plane")),
+            color: "primary",
+            button: true,
+            loading: isLoading,
+            disabled: (() => {
+              return isLoading || !areEvaluationsValid;
+            })(),
+            onClick: () => dispatch(adt("submit") as Msg)
+          }
+        ])
+      : component_.page.actions.none();
   }
 };
diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts
index f12433bc2..f7566e340 100644
--- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts
+++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts
@@ -16,8 +16,9 @@ import * as InstructionsTab from "front-end/lib/pages/opportunity/sprint-with-us
 import { routeDest } from "front-end/lib/views/link";
 import {
   canAddAddendumToSWUOpportunity,
-  canViewSWUEvaluationConsensus,
-  SWUOpportunity
+  doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations,
+  SWUOpportunity,
+  SWUOpportunityStatus
 } from "shared/lib/resources/opportunity/sprint-with-us";
 import { User } from "shared/lib/resources/user";
 import { adt, Id } from "shared/lib/types";
@@ -189,7 +190,16 @@ export function canGovUserViewTab(
     case "overview":
       return isEvaluator;
     case "consensus":
-      return isChair || canViewSWUEvaluationConsensus(opportunity.status);
+      return (
+        isChair ||
+        isOpportunityOwnerOrAdmin ||
+        (isEvaluator &&
+          opportunity.status ===
+            SWUOpportunityStatus.EvaluationTeamQuestionsConsensus) ||
+        doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations(
+          opportunity.status
+        )
+      );
   }
 }
 
diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx
index e3a69f7c9..fc778611b 100644
--- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx
+++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx
@@ -69,6 +69,7 @@ export interface State extends Tab.Params {
   openAccordions: Set<number>;
   evaluationScores: EvaluationScore[];
   isEditing: boolean;
+  isAuthor: boolean;
 }
 
 export type InnerMsg =
@@ -233,7 +234,10 @@ export const init: component_.base.Init<Tab.Params, State, Msg> = (params) => {
         params.proposal.teamQuestionResponses.map((p, i) => i)
       ),
       evaluationScores: evaluationScoreStates,
-      isEditing: !params.questionEvaluation
+      isEditing: !params.questionEvaluation,
+      isAuthor:
+        params.questionEvaluation?.evaluationPanelMember.user.id ===
+        params.viewerUser.id
     },
     evaluationScoreCmds
   ];
@@ -1395,7 +1399,11 @@ const TeamQuestionResponsesChairEvalView: component_.base.View<{
                     response={r}
                     score={state.evaluationScores[i]}
                     dispatch={dispatch}
-                    disabled={!state.isEditing || isLoading}
+                    disabled={
+                      !state.isEditing ||
+                      isLoading ||
+                      Boolean(state.questionEvaluation && !state.isAuthor)
+                    }
                     panelEvaluationScores={state.panelQuestionEvaluations}
                   />
                 ))}
@@ -1540,17 +1548,19 @@ export const component: Tab.Component<State, Msg> = {
         return component_.page.actions.links(
           state.evaluating
             ? state.questionEvaluation
-              ? [
-                  {
-                    children: "Edit",
-                    onClick: () => dispatch(adt("startEditingConsensus")),
-                    button: true,
-                    loading: isStartEditingLoading,
-                    disabled: isLoading,
-                    symbol_: leftPlacement(iconLinkSymbol("edit")),
-                    color: "primary"
-                  }
-                ]
+              ? state.isAuthor
+                ? [
+                    {
+                      children: "Edit",
+                      onClick: () => dispatch(adt("startEditingConsensus")),
+                      button: true,
+                      loading: isStartEditingLoading,
+                      disabled: isLoading,
+                      symbol_: leftPlacement(iconLinkSymbol("edit")),
+                      color: "primary"
+                    }
+                  ]
+                : []
               : [
                   {
                     children: "Save Draft",
diff --git a/src/migrations/tasks/20240527213854_add-evaluation-committee-panel-tables.ts b/src/migrations/tasks/20240527213854_add-evaluation-committee-panel-tables.ts
index c35193f95..97894b9db 100644
--- a/src/migrations/tasks/20240527213854_add-evaluation-committee-panel-tables.ts
+++ b/src/migrations/tasks/20240527213854_add-evaluation-committee-panel-tables.ts
@@ -1,10 +1,40 @@
+import { generateUuid } from "back-end/lib";
 import { makeDomainLogger } from "back-end/lib/logger";
 import { console as consoleAdapter } from "back-end/lib/logger/adapters";
 import { Knex } from "knex";
+import { UserStatus, UserType } from "shared/lib/resources/user";
 
 const logger = makeDomainLogger(consoleAdapter, "migrations");
 
+const MIGRATION_IDP_USERNAME = "migration_evaluation_panel_member";
+
 export async function up(connection: Knex): Promise<void> {
+  const now = new Date();
+  const id = generateUuid();
+  await connection("users").insert(
+    {
+      id,
+      createdAt: now,
+      updatedAt: now,
+      type: UserType.Government,
+      status: UserStatus.Active,
+      name: MIGRATION_IDP_USERNAME.toUpperCase(),
+      email: `${MIGRATION_IDP_USERNAME}@gov.bc.ca`,
+      idpUsername: MIGRATION_IDP_USERNAME,
+      idpId: MIGRATION_IDP_USERNAME
+    },
+    [
+      "id",
+      "createdAt",
+      "updatedAt",
+      "type",
+      "status",
+      "name",
+      "idpUsername",
+      "idpId"
+    ]
+  );
+
   await connection.schema.createTable("swuEvaluationPanelMembers", (table) => {
     table
       .uuid("opportunityVersion")
@@ -30,9 +60,73 @@ export async function up(connection: Knex): Promise<void> {
       "evaluator" = true OR "chair" = true
     )
   `);
-
   logger.info("Created swuEvaluationPanelMembers table.");
 
+  const swuOpportunitiesQuery = connection("swuOpportunities as opportunities")
+    .join(
+      connection.raw(
+        "(??) as versions",
+        connection("swuOpportunityVersions")
+          .select("opportunity", "id")
+          .rowNumber("rn", function () {
+            this.orderBy("createdAt", "desc").partitionBy("opportunity");
+          })
+      ),
+      function () {
+        this.on("opportunities.id", "=", "versions.opportunity");
+      }
+    )
+    .where({
+      "versions.rn": 1
+    });
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??)", [
+        "swuEvaluationPanelMembers",
+        "opportunityVersion",
+        "user",
+        "chair",
+        "evaluator",
+        "order"
+      ])
+    )
+    .insert(
+      swuOpportunitiesQuery.select(
+        "versions.id as opportunityVersion",
+        "opportunities.createdBy as user",
+        connection.raw("true as chair"),
+        connection.raw("true as evaluator"),
+        connection.raw("0 as order")
+      )
+    );
+  logger.info("Ported opportunity creator to SWU evaluation panel chair");
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??)", [
+        "swuEvaluationPanelMembers",
+        "opportunityVersion",
+        "user",
+        "chair",
+        "evaluator",
+        "order"
+      ])
+    )
+    .insert(
+      swuOpportunitiesQuery
+        .clearSelect()
+        .select(
+          "versions.id as opportunityVersion",
+          connection.raw("? as user", [id]),
+          connection.raw("false as chair"),
+          connection.raw("true as evaluator"),
+          connection.raw("1 as order")
+        )
+    );
+
+  logger.info("Added migration user as SWU evaluation panel evaluator");
+
   await connection.schema.createTable("twuEvaluationPanelMembers", (table) => {
     table
       .uuid("opportunityVersion")
@@ -58,11 +152,78 @@ export async function up(connection: Knex): Promise<void> {
       "evaluator" = true OR "chair" = true
     )
   `);
-
   logger.info("Created twuEvaluationPanelMembers table.");
+
+  const twuOpportunitiesQuery = connection("twuOpportunities as opportunities")
+    .join(
+      connection.raw(
+        "(??) as versions",
+        connection("twuOpportunityVersions")
+          .select("opportunity", "id")
+          .rowNumber("rn", function () {
+            this.orderBy("createdAt", "desc").partitionBy("opportunity");
+          })
+      ),
+      function () {
+        this.on("opportunities.id", "=", "versions.opportunity");
+      }
+    )
+    .where({
+      "versions.rn": 1
+    });
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??)", [
+        "twuEvaluationPanelMembers",
+        "opportunityVersion",
+        "user",
+        "chair",
+        "evaluator",
+        "order"
+      ])
+    )
+    .insert(
+      twuOpportunitiesQuery.select(
+        "versions.id as opportunityVersion",
+        "opportunities.createdBy as user",
+        connection.raw("true as chair"),
+        connection.raw("true as evaluator"),
+        connection.raw("0 as order")
+      )
+    );
+  logger.info("Ported opportunity creator to TWU evaluation panel chair");
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??)", [
+        "twuEvaluationPanelMembers",
+        "opportunityVersion",
+        "user",
+        "chair",
+        "evaluator",
+        "order"
+      ])
+    )
+    .insert(
+      twuOpportunitiesQuery
+        .clearSelect()
+        .select(
+          "versions.id as opportunityVersion",
+          connection.raw("? as user", [id]),
+          connection.raw("false as chair"),
+          connection.raw("true as evaluator"),
+          connection.raw("1 as order")
+        )
+    );
+  logger.info("Added migration user as TWU evaluation panel evaluator");
 }
 
 export async function down(connection: Knex): Promise<void> {
+  await connection("users")
+    .where({ idpUsername: MIGRATION_IDP_USERNAME })
+    .del();
+
   await connection.schema.dropTable("swuEvaluationPanelMembers");
   logger.info("Dropped table swuEvaluationPanelMembers");
 
diff --git a/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts b/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts
index 0279160ce..34edc163e 100644
--- a/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts
+++ b/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts
@@ -1,3 +1,4 @@
+import { generateUuid } from "back-end/lib";
 import { makeDomainLogger } from "back-end/lib/logger";
 import { console as consoleAdapter } from "back-end/lib/logger/adapters";
 import { Knex } from "knex";
@@ -10,7 +11,6 @@ enum SWUOpportunityStatus {
   Published = "PUBLISHED",
   EvaluationTeamQuestionsIndividual = "EVAL_QUESTIONS_INDIVIDUAL",
   EvaluationTeamQuestionsConsensus = "EVAL_QUESTIONS_CONSENSUS",
-  EvaluationTeamQuestions = "EVAL_QUESTIONS", // TODO: Remove
   EvaluationCodeChallenge = "EVAL_CC",
   EvaluationTeamScenario = "EVAL_SCENARIO",
   Awarded = "AWARDED",
@@ -34,7 +34,7 @@ enum SWUProposalStatus {
   Draft = "DRAFT",
   Submitted = "SUBMITTED",
   UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS",
-  EvaluatedTeamQuestions = "EVALUATED_QUESTIONS",
+  DeprecatedEvaluatedTeamQuestions = "DEPRECATED_EVALUATED_QUESTIONS",
   UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE",
   EvaluatedCodeChallenge = "EVALUATED_CODE_CHALLENGE",
   UnderReviewTeamScenario = "UNDER_REVIEW_TEAM_SCENARIO",
@@ -73,6 +73,11 @@ export async function up(connection: Knex): Promise<void> {
     '
   );
 
+  // Unused status; deprecate
+  await connection("swuProposalStatuses")
+    .update({ status: SWUProposalStatus.DeprecatedEvaluatedTeamQuestions })
+    .where({ status: PreviousSWUProposalStatus.EvaluatedTeamQuestions });
+
   await connection.schema.raw(` \
     ALTER TABLE "swuProposalStatuses" \
     ADD CONSTRAINT "swuProposalStatuses_status_check" \
@@ -87,6 +92,35 @@ export async function up(connection: Knex): Promise<void> {
     '
   );
 
+  await connection("swuOpportunityStatuses")
+    .update({ status: SWUOpportunityStatus.EvaluationTeamQuestionsIndividual })
+    .where({ status: PreviousSWUOpportunityStatus.EvaluationTeamQuestions });
+
+  // Would've done this with an INSERT SELECT but had trouble generating a UUID without
+  // creating the uuid-ossp extension.
+  const codeChallengeStatuses = await connection(
+    "swuOpportunityStatuses as statuses"
+  ).where({
+    "statuses.status": SWUOpportunityStatus.EvaluationCodeChallenge
+  });
+
+  await connection("swuOpportunityStatuses").insert(
+    codeChallengeStatuses.map((status) => {
+      const createdAt = new Date(status.createdAt);
+      // Seems unlikely to conflict with existing records.
+      createdAt.setMilliseconds(createdAt.getMilliseconds() - 1);
+      return {
+        id: generateUuid(),
+        createdAt,
+        createdBy: status.createdBy,
+        opportunity: status.opportunity,
+        status: SWUOpportunityStatus.EvaluationTeamQuestionsConsensus,
+        event: null,
+        note: null
+      };
+    })
+  );
+
   await connection.schema.raw(` \
     ALTER TABLE "swuOpportunityStatuses" \
     ADD CONSTRAINT "swuOpportunityStatuses_status_check" \
@@ -110,7 +144,7 @@ export async function up(connection: Knex): Promise<void> {
       .onDelete("CASCADE");
     table.timestamp("createdAt").notNullable();
     table.timestamp("updatedAt").notNullable();
-    table.float("score").notNullable();
+    table.float("score");
     table.text("notes").notNullable();
     table.primary(["proposal", "evaluationPanelMember", "questionOrder"]);
   }
@@ -127,6 +161,86 @@ export async function up(connection: Knex): Promise<void> {
   );
   logger.info("Created swuTeamQuestionResponseChairEvaluations table.");
 
+  const now = new Date();
+  const migrationNotes = "MIGRATION_NOTES";
+  const swuTeamQuestionResponsesWithEvaluationPanelMembersQuery = connection(
+    "swuTeamQuestionResponses as responses"
+  )
+    .join("swuProposals as proposals", function () {
+      this.on("responses.proposal", "=", "proposals.id");
+    })
+    .join(
+      connection.raw(
+        "(??) as versions",
+        connection("swuOpportunityVersions")
+          .select("opportunity", "id")
+          .rowNumber("rn", function () {
+            this.orderBy("createdAt", "desc").partitionBy("opportunity");
+          })
+      ),
+      function () {
+        this.on("proposals.opportunity", "=", "versions.opportunity");
+      }
+    )
+    .join("swuEvaluationPanelMembers as members", function () {
+      this.on("versions.id", "=", "members.opportunityVersion");
+    })
+    .whereNotNull("responses.score")
+    .andWhere({
+      "versions.rn": 1
+    })
+    .select(
+      "proposals.id as proposal",
+      "responses.order as questionOrder",
+      "members.user as evaluationPanelMember",
+      connection.raw("? as createdAt", [now]),
+      connection.raw(" ? as updatedAt", [now]),
+      "responses.score as score",
+      connection.raw("? as notes", [migrationNotes])
+    );
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??, ??, ??)", [
+        "swuTeamQuestionResponseEvaluatorEvaluations",
+        "proposal",
+        "questionOrder",
+        "evaluationPanelMember",
+        "createdAt",
+        "updatedAt",
+        "score",
+        "notes"
+      ])
+    )
+    .insert(swuTeamQuestionResponsesWithEvaluationPanelMembersQuery);
+  logger.info(
+    "Ported SWU team question responses to evaluator team question response" +
+      "evaluations."
+  );
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??, ??, ??)", [
+        "swuTeamQuestionResponseChairEvaluations",
+        "proposal",
+        "questionOrder",
+        "evaluationPanelMember",
+        "createdAt",
+        "updatedAt",
+        "score",
+        "notes"
+      ])
+    )
+    .insert(
+      swuTeamQuestionResponsesWithEvaluationPanelMembersQuery.andWhere({
+        "members.chair": true
+      })
+    );
+  logger.info(
+    "Ported SWU team question responses to chair team question response " +
+      "evaluations."
+  );
+
   function evaluationStatusesColumns(table: Knex.TableBuilder) {
     table
       .uuid("proposal")
@@ -145,8 +259,7 @@ export async function up(connection: Knex): Promise<void> {
       .notNullable();
     table.string("note");
     table.timestamp("createdAt").notNullable();
-    table.uuid("createdBy").references("id").inTable("users");
-    table.primary(["proposal", "evaluationPanelMember", "createdAt"]);
+    table.primary(["proposal", "evaluationPanelMember", "createdAt", "status"]);
   }
 
   await connection.schema.createTable(
@@ -162,6 +275,73 @@ export async function up(connection: Knex): Promise<void> {
     evaluationStatusesColumns
   );
   logger.info("Created swuTeamQuestionResponseChairEvaluationStatuses table.");
+
+  const swuTeamQuestionResponsesEvaluationsWithStatusesQuery = (
+    tableName: string
+  ) =>
+    connection(`${tableName} as evaluations`)
+      .distinctOn([
+        "evaluations.proposal",
+        "evaluations.evaluationPanelMember",
+        "statuses.status"
+      ])
+      .crossJoin(
+        connection.raw("(SELECT ? as status", [
+          SWUTeamQuestionResponseEvaluationStatus.Draft
+        ])
+      )
+      .unionAll(
+        connection.raw("SELECT ?) as statuses", [
+          SWUTeamQuestionResponseEvaluationStatus.Submitted
+        ])
+      )
+      .select([
+        "evaluations.proposal as proposal",
+        "evaluations.evaluationPanelMember as evaluationPanelMember",
+        "statuses.status as status",
+        connection.raw("NULL as note"),
+        "evaluations.createdAt as createdAt"
+      ]);
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??)", [
+        "swuTeamQuestionResponseEvaluatorEvaluationStatuses",
+        "proposal",
+        "evaluationPanelMember",
+        "status",
+        "note",
+        "createdAt"
+      ])
+    )
+    .insert(
+      swuTeamQuestionResponsesEvaluationsWithStatusesQuery(
+        "swuTeamQuestionResponseEvaluatorEvaluations"
+      )
+    );
+  logger.info(
+    "Added draft and submitted statuses to evaluator evaluation statuses."
+  );
+
+  await connection
+    .from(
+      connection.raw("?? (??, ??, ??, ??, ??)", [
+        "swuTeamQuestionResponseChairEvaluationStatuses",
+        "proposal",
+        "evaluationPanelMember",
+        "status",
+        "note",
+        "createdAt"
+      ])
+    )
+    .insert(
+      swuTeamQuestionResponsesEvaluationsWithStatusesQuery(
+        "swuTeamQuestionResponseChairEvaluations"
+      )
+    );
+  logger.info(
+    "Added draft and submitted statuses to chair evaluation statuses."
+  );
 }
 
 export async function down(connection: Knex): Promise<void> {
@@ -173,9 +353,8 @@ export async function down(connection: Knex): Promise<void> {
   );
 
   await connection("swuProposalStatuses")
-    .where({ status: "EVALUATION_QUESTIONS_INDIVIDUAL" })
-    .orWhere({ status: "EVALUATION_QUESTIONS_CONSENSUS" })
-    .del();
+    .where({ status: SWUProposalStatus.DeprecatedEvaluatedTeamQuestions })
+    .update({ status: PreviousSWUProposalStatus.EvaluatedTeamQuestions });
 
   await connection.schema.raw(` \
     ALTER TABLE "swuProposalStatuses" \
@@ -194,12 +373,14 @@ export async function down(connection: Knex): Promise<void> {
   );
 
   await connection("swuOpportunityStatuses")
-    .where({ status: "EVAL_QUESTIONS_INDIVIDUAL" })
-    .orWhere({ status: "EVAL_QUESTIONS_CONSENSUS" })
-    .orWhere({ status: "EVAL_QUESTIONS_REVIEW" })
-    .orWhere({ status: "EVAL_QUESTIONS_CHAIR_SUBMISSION" })
-    .orWhere({ status: "EVAL_QUESTIONS_ADMIN_REVIEW" })
-    .del();
+    .where({ status: SWUOpportunityStatus.EvaluationTeamQuestionsConsensus })
+    .delete();
+
+  await connection("swuOpportunityStatuses")
+    .where({
+      status: SWUOpportunityStatus.EvaluationTeamQuestionsIndividual
+    })
+    .update({ status: PreviousSWUOpportunityStatus.EvaluationTeamQuestions });
 
   await connection.schema.raw(` \
     ALTER TABLE "swuOpportunityStatuses" \
diff --git a/src/shared/lib/resources/opportunity/sprint-with-us.ts b/src/shared/lib/resources/opportunity/sprint-with-us.ts
index c13894ed8..f9b728708 100644
--- a/src/shared/lib/resources/opportunity/sprint-with-us.ts
+++ b/src/shared/lib/resources/opportunity/sprint-with-us.ts
@@ -780,7 +780,6 @@ export function doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvalua
   s: SWUOpportunityStatus
 ): boolean {
   switch (s) {
-    case SWUOpportunityStatus.EvaluationTeamQuestions:
     case SWUOpportunityStatus.EvaluationCodeChallenge:
     case SWUOpportunityStatus.EvaluationTeamScenario:
     case SWUOpportunityStatus.Awarded:
@@ -789,13 +788,3 @@ export function doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvalua
       return false;
   }
 }
-
-export function canViewSWUEvaluationConsensus(
-  s: SWUOpportunityStatus
-): boolean {
-  switch (s) {
-    // TODO: Add statuses
-    default:
-      return false;
-  }
-}


Reviews:
user: login: IanFonzie
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T21:45:17Z
user: login: IanFonzie
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T23:35:09Z
user: login: dhruvio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T17:58:41Z
user: login: dhruvio
body: I think it looks good to me overall. Honestly, complex SQL queries and migrations are not my forté. Given our release schedule, I am in favour of testing this on the production dataset in a sandbox environment to ensure it works as intended and scaffolds the necessary stub data. In practice, I only see issues presenting themselves to procurement operations if we have a lot of active opportunities in the old "team question evaluation" phase. However, it looks like that won't be the case when we go live, so I believe the migration as implemented does the job it intends to do, subject to a final QA pass and testing. Thanks, @IanFonzie !
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T18:11:19Z

Review comments:
diff_hunk: @@ -92,6 +92,46 @@ export async function up(connection: Knex): Promise<void> {
     '
   );
 
+  const opportunitiesWithStatusAndStatusId = await connection(
+    "swuOpportunities as opportunities"
+  )
+    .join(
+      connection.raw(
+        "(??) as statuses",
+        connection("swuOpportunityStatuses")
+          .select("opportunity", "id", "status")
+          .rowNumber("rn", function () {
+            this.orderBy("createdAt", "desc").partitionBy("opportunity");
+          })
+      ),
+      function () {
+        this.on("opportunities.id", "=", "statuses.opportunity").andOnNotNull(
+          "statuses.status"
+        );
+      }
+    )
+    .where({
+      "statuses.rn": 1
+    })
+    .select("opportunities.id", "statuses.id as statusId", "statuses.status");
+
+  const inProgressStatuses = opportunitiesWithStatusAndStatusId.reduce(
+    (inProgressStatuses, { statusId, status }) => {
+      return status === PreviousSWUOpportunityStatus.EvaluationTeamQuestions
+        ? [...inProgressStatuses, statusId]
+        : inProgressStatuses;
+    },
+    []
+  );
+  await connection("swuOpportunityStatuses")
+    .update({ status: SWUOpportunityStatus.EvaluationTeamQuestionsIndividual })
+    .where("id", "in", inProgressStatuses);
+
+  // Maintain deprecated status for historical purposes
+  await connection("swuOpportunityStatuses")
+    .update({ status: SWUOpportunityStatus.DeprecatedEvaluationTeamQuestions })
path: src/migrations/tasks/20240718222006_swu-evaluation-tables.ts
user: login: IanFonzie
body: @dhruvio I think we might need to change all statuses to SWUOpportunityStatus.EvaluationTeamQuestionsIndividual as well as adding a SWUOpportunityStatus.EvaluationTeamQuestionsConsensus, because this [check](https://github.com/bcgov/digital_marketplace/pull/504/files#diff-3e3de5abd29971dad9a6471f8a13824bda480401b562ac5014ed6d3f63fb237eL102) requires the opportunity to have the status in its history to view them.
created_at: 2025-04-11T21:45:16Z
author_association: COLLABORATOR
diff_hunk: @@ -92,6 +92,46 @@ export async function up(connection: Knex): Promise<void> {
     '
   );
 
+  const opportunitiesWithStatusAndStatusId = await connection(
+    "swuOpportunities as opportunities"
+  )
+    .join(
+      connection.raw(
+        "(??) as statuses",
+        connection("swuOpportunityStatuses")
+          .select("opportunity", "id", "status")
+          .rowNumber("rn", function () {
+            this.orderBy("createdAt", "desc").partitionBy("opportunity");
+          })
+      ),
+      function () {
+        this.on("opportunities.id", "=", "statuses.opportunity").andOnNotNull(
+          "statuses.status"
+        );
+      }
+    )
+    .where({
+      "statuses.rn": 1
+    })
+    .select("opportunities.id", "statuses.id as statusId", "statuses.status");
+
+  const inProgressStatuses = opportunitiesWithStatusAndStatusId.reduce(
+    (inProgressStatuses, { statusId, status }) => {
+      return status === PreviousSWUOpportunityStatus.EvaluationTeamQuestions
+        ? [...inProgressStatuses, statusId]
+        : inProgressStatuses;
+    },
+    []
+  );
+  await connection("swuOpportunityStatuses")
+    .update({ status: SWUOpportunityStatus.EvaluationTeamQuestionsIndividual })
+    .where("id", "in", inProgressStatuses);
+
+  // Maintain deprecated status for historical purposes
+  await connection("swuOpportunityStatuses")
+    .update({ status: SWUOpportunityStatus.DeprecatedEvaluationTeamQuestions })
path: src/migrations/tasks/20240718222006_swu-evaluation-tables.ts
user: login: IanFonzie
body: Took a stab at addressing this in https://github.com/bcgov/digital_marketplace/pull/504/commits/1dc501c56feaa5e9a789818037294c43f7d14314; we can revert if necessary.
created_at: 2025-04-11T23:35:09Z
author_association: COLLABORATOR
diff_hunk: @@ -73,6 +73,11 @@ export async function up(connection: Knex): Promise<void> {
     '
   );
 
+  // Unused status; deprecate
+  await connection("swuProposalStatuses")
+    .update({ status: SWUProposalStatus.DeprecatedEvaluatedTeamQuestions })
+    .where({ status: PreviousSWUProposalStatus.EvaluatedTeamQuestions });
+
path: src/migrations/tasks/20240718222006_swu-evaluation-tables.ts
user: login: dhruvio
body: Looks good
created_at: 2025-04-21T17:58:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-15T22:02:02Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=bcgov_digital_marketplace&pullRequest=504) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=bcgov_digital_marketplace&pullRequest=504&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=bcgov_digital_marketplace&pullRequest=504)


author_association: NONE