{
const newPrompts = grantsToMultiValue(selectedGrants, { ...prompts });
@@ -172,6 +174,7 @@ export default function GoalForm({
setSource(grantsToMultiValue(selectedGoalGrants, goal.source, ''));
setCreatedVia(goal.createdVia || '');
setGoalCollaborators(goal.collaborators || []);
+ setIsReopenedGoal(goal.isReopenedGoal || false);
// this is a lot of work to avoid two loops through the goal.objectives
// but I'm sure you'll agree its totally worth it
@@ -1099,6 +1102,7 @@ export default function GoalForm({
createdVia={createdVia}
collaborators={goalCollaborators}
goalTemplateId={goalTemplateId}
+ isReopenedGoal={isReopenedGoal}
/>
)}
diff --git a/frontend/src/components/ReopenReasonModal.js b/frontend/src/components/ReopenReasonModal.js
new file mode 100644
index 0000000000..459dd7c2f4
--- /dev/null
+++ b/frontend/src/components/ReopenReasonModal.js
@@ -0,0 +1,113 @@
+/* eslint-disable react/jsx-props-no-spreading */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { REOPEN_REASONS, GOAL_STATUS } from '@ttahub/common';
+import {
+ Form, FormGroup, ErrorMessage, Label, Fieldset, Radio, Textarea,
+} from '@trussworks/react-uswds';
+import Modal from './Modal';
+
+const ReopenReasonModal = ({
+ modalRef, goalId, onSubmit, resetValues,
+}) => {
+ const [reopenReason, setReopenReason] = useState('');
+ const [reopenContext, setReopenContext] = useState('');
+ const [showValidationError, setShowValidationError] = useState(false);
+
+ useEffect(() => {
+ // Every time we show the modal reset the form.
+ setReopenReason('');
+ setReopenContext('');
+ setShowValidationError(false);
+ }, [resetValues]);
+
+ const reasonChanged = (e) => {
+ setReopenReason(e.target.value);
+ setShowValidationError(false);
+ };
+
+ const reasonRadioOptions = Object.values(REOPEN_REASONS[GOAL_STATUS.CLOSED]);
+
+ const generateReasonRadioButtons = () => reasonRadioOptions.map((r) => (
+
+ ));
+ const contextChanged = (e) => {
+ setReopenContext(e.target.value);
+ };
+
+ const validateSubmit = () => {
+ if (!reopenReason) {
+ setShowValidationError(true);
+ } else {
+ onSubmit(goalId, reopenReason, reopenContext);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+ReopenReasonModal.propTypes = {
+ modalRef: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape(),
+ ]).isRequired,
+ goalId: PropTypes.number.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ resetValues: PropTypes.bool.isRequired,
+};
+
+export default ReopenReasonModal;
diff --git a/frontend/src/components/__tests__/ReopenReasonModal.js b/frontend/src/components/__tests__/ReopenReasonModal.js
new file mode 100644
index 0000000000..1e7c5cdcc9
--- /dev/null
+++ b/frontend/src/components/__tests__/ReopenReasonModal.js
@@ -0,0 +1,180 @@
+/* eslint-disable react/prop-types */
+import '@testing-library/jest-dom';
+import React, { useRef } from 'react';
+import {
+ render, screen, fireEvent,
+} from '@testing-library/react';
+import { ModalToggleButton } from '@trussworks/react-uswds';
+import userEvent from '@testing-library/user-event';
+import { GOAL_STATUS, REOPEN_REASONS } from '@ttahub/common';
+import ReopenReasonModal from '../ReopenReasonModal';
+
+describe('Reopen Goal Reason', () => {
+ const ModalComponent = (
+ {
+ goalId = 1,
+ onSubmit = () => { },
+ resetValues = false,
+ },
+ ) => {
+ const modalRef = useRef();
+
+ return (
+
+
Test Reopen Reason Modal
+
Open
+
Close
+
+
+ );
+ };
+
+ it('correctly hides and shows', async () => {
+ render();
+
+ // Defaults modal to hidden.
+ let modalElement = document.querySelector('.usa-modal-wrapper');
+ expect(modalElement).toHaveClass('is-hidden');
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ // Check modal is visible.
+ modalElement = document.querySelector('.usa-modal-wrapper');
+ expect(modalElement).toHaveClass('is-visible');
+ });
+
+ it('does not exit when escape key is pressed (because forceAction is true)', async () => {
+ render();
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ // Modal is visible.
+ let modalElement = document.querySelector('.usa-modal-wrapper');
+ expect(modalElement).toHaveClass('is-visible');
+
+ // Press ESC.
+ userEvent.type(modalElement, '{esc}', { skipClick: true });
+
+ // Check Modal is still visible.
+ modalElement = document.querySelector('.usa-modal-wrapper');
+ expect(modalElement).not.toHaveClass('is-hidden');
+ });
+
+ it('does not escape when any other key is pressed', async () => {
+ render();
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ // Modal is visible.
+ let modalElement = document.querySelector('.usa-modal-wrapper');
+ expect(modalElement).toHaveClass('is-visible');
+
+ // Press ENTER.
+ userEvent.type(modalElement, '{enter}', { skipClick: true });
+
+ // Modal is still open.
+ modalElement = document.querySelector('.usa-modal-wrapper');
+ expect(modalElement).toHaveClass('is-visible');
+ });
+
+ it('correctly shows validation error', async () => {
+ render();
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ // Click submit.
+ const submit = await screen.findByText('Submit');
+ userEvent.click(submit);
+
+ // Verify validation error.
+ expect(await screen.findByText('Please select a reason for reopening this goal.')).toBeVisible();
+ });
+
+ it('correctly shows reopen radio options', async () => {
+ render();
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ // Verify title.
+ expect(await screen.findByText('Why are you reopening this goal?')).toBeVisible();
+
+ // Verify correct close radio options.
+ expect(
+ await screen.findByText(REOPEN_REASONS[GOAL_STATUS.CLOSED].ACCIDENTALLY_CLOSED),
+ ).toBeVisible();
+ expect(
+ await screen.findByText(REOPEN_REASONS[GOAL_STATUS.CLOSED].RECIPIENT_REQUEST),
+ ).toBeVisible();
+ expect(
+ await screen.findByText(REOPEN_REASONS[GOAL_STATUS.CLOSED].PS_REQUEST),
+ ).toBeVisible();
+ expect(
+ await screen.findByText(REOPEN_REASONS[GOAL_STATUS.CLOSED].NEW_RECIPIENT_STAFF_REQUEST),
+ ).toBeVisible();
+
+ // Verify Context.
+ expect(await screen.findByText('Additional context')).toBeVisible();
+ expect(await screen.findByRole('textbox', { hidden: true })).toBeVisible();
+ });
+
+ it('correctly updates context text', async () => {
+ render();
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ // Set sample context.
+ const context = await screen.findByRole('textbox', { hidden: true });
+ fireEvent.change(context, { target: { value: 'This is my sample context.' } });
+
+ // Verify context.
+ expect(context.value).toBe('This is my sample context.');
+ });
+
+ it('correctly switches between reason radio buttons', async () => {
+ render();
+
+ // Open modal.
+ const button = await screen.findByText('Open');
+ userEvent.click(button);
+
+ const firstRadio = await screen.findByRole('radio', { name: /accidentally closed/i, hidden: true });
+ const secondRadio = await screen.findByRole('radio', { name: /recipient request to/i, hidden: true });
+ const thirdRadio = await screen.findByRole('radio', { name: /ps request to/i, hidden: true });
+
+ // No radio buttons selected by default.
+ expect(firstRadio.checked).toBe(false);
+ expect(secondRadio.checked).toBe(false);
+ expect(thirdRadio.checked).toBe(false);
+
+ // Select first radio button.
+ userEvent.click(firstRadio);
+
+ // Verify its selected.
+ expect(firstRadio.checked).toBe(true);
+
+ // Select third radio button.
+ userEvent.click(thirdRadio);
+
+ // Verify first radio is no longer checked and third radio is checked.
+ expect(firstRadio.checked).toBe(false);
+ expect(thirdRadio.checked).toBe(true);
+ });
+});
diff --git a/frontend/src/fetchers/goals.js b/frontend/src/fetchers/goals.js
index 4fb894bbb7..ffcce3d43b 100644
--- a/frontend/src/fetchers/goals.js
+++ b/frontend/src/fetchers/goals.js
@@ -121,3 +121,9 @@ export async function missingDataForActivityReport(regionId, goalIds) {
const response = await get(url);
return response.json();
}
+
+export async function reopenGoal(goalId, reason, context) {
+ const url = join(goalsUrl, 'reopen');
+ const response = await put(url, { goalId, reason, context });
+ return response.json();
+}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index c4fb6940b8..b116c057e3 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2368,10 +2368,10 @@
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
-"@ttahub/common@2.0.18":
- version "2.0.18"
- resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.0.18.tgz#2ee4b17d8a4265220affe2b1cd6f40e4eda36f75"
- integrity sha512-E5jgaVCwWeWCskAm7xYotn0LSlv2onsWU7J/0kMioqecg6jK5Poe7SfbyVvfkrU6N3hmLw7fe5ga8xpmDwij0w==
+"@ttahub/common@^2.1.3":
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.1.3.tgz#56fa179bec3525d7bd322907e25fa123e30f4b7d"
+ integrity sha512-HwzTa0t4a7sFG9N4qOo3HkrWPFZVYrYFJf/dRwRuFze/+o36B9oXaz+TY7Aqj30An6I1njKdytYPnWNIaSQf7w==
"@turf/area@^6.4.0":
version "6.5.0"
diff --git a/package.json b/package.json
index 124e19f053..f20f73ed56 100644
--- a/package.json
+++ b/package.json
@@ -299,7 +299,7 @@
"@elastic/elasticsearch-mock": "^2.0.0",
"@faker-js/faker": "^6.0.0",
"@opensearch-project/opensearch": "^1.1.0",
- "@ttahub/common": "2.0.18",
+ "@ttahub/common": "^2.1.3",
"adm-zip": "^0.5.1",
"aws-sdk": "^2.826.0",
"aws4": "^1.11.0",
diff --git a/packages/common/package.json b/packages/common/package.json
index 7bf6be5b96..50743557b8 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -1,6 +1,6 @@
{
"name": "@ttahub/common",
- "version": "2.0.18",
+ "version": "2.1.3",
"description": "The purpose of this package is to reduce code duplication between the frontend and backend projects.",
"main": "src/index.js",
"author": "",
diff --git a/packages/common/src/constants.js b/packages/common/src/constants.js
index 0f581da0c2..e763864458 100644
--- a/packages/common/src/constants.js
+++ b/packages/common/src/constants.js
@@ -311,7 +311,7 @@ const GOAL_STATUS = {
exports.GOAL_STATUS = GOAL_STATUS;
-const SUPPORT_TYPES = [
+const SUPPORT_TYPES = [
'Introducing',
'Planning',
'Implementing',
@@ -333,3 +333,34 @@ const GROUP_SHARED_WITH = {
};
exports.GROUP_SHARED_WITH = GROUP_SHARED_WITH;
+
+/**
+ * A list of reasons that a CLOSED goal can be reopened.
+ */
+const REOPEN_CLOSED_REASONS = {
+ ACCIDENTALLY_CLOSED: 'Accidentally closed',
+ RECIPIENT_REQUEST: 'Recipient request to restart the work',
+ PS_REQUEST: 'PS request to restart the work',
+ NEW_RECIPIENT_STAFF_REQUEST: 'New recipient staff request similar work',
+};
+
+/**
+ * A list of reasons that a SUSPENDED goal can be reopened.
+ */
+const REOPEN_SUSPENDED_REASONS = {};
+
+/**
+ * REOPEN_REASONS is a map of FROM status to an array of
+ * possible TO statuses.
+ */
+const REOPEN_REASONS = {
+ [GOAL_STATUS.CLOSED]: REOPEN_CLOSED_REASONS,
+ [GOAL_STATUS.SUSPENDED]: REOPEN_SUSPENDED_REASONS,
+
+ INFERRED: {
+ OBJECTIVE_REOPEN: 'Objective Reopen',
+ IMPORTED_FROM_SMARTSHEET: 'Imported from Smartsheet',
+ },
+};
+
+exports.REOPEN_REASONS = REOPEN_REASONS;
diff --git a/similarity_api/src/Procfile b/similarity_api/src/Procfile
new file mode 100644
index 0000000000..bc6306acca
--- /dev/null
+++ b/similarity_api/src/Procfile
@@ -0,0 +1 @@
+web: gunicorn -w 4 -b 0.0.0.0:8000 app:app --preload
diff --git a/src/constants.js b/src/constants.js
index 68c8e459aa..d48517af7c 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -261,7 +261,7 @@ const MAINTENANCE_CATEGORY = {
const GOAL_CREATED_VIA = ['imported', 'activityReport', 'rtr', 'merge', 'admin', 'tr'];
-const CURRENT_GOAL_SIMILARITY_VERSION = 2;
+const CURRENT_GOAL_SIMILARITY_VERSION = 3;
module.exports = {
CURRENT_GOAL_SIMILARITY_VERSION,
diff --git a/src/goalServices/changeGoalStatus.test.js b/src/goalServices/changeGoalStatus.test.js
index 78d3725580..9dcc7ca61d 100644
--- a/src/goalServices/changeGoalStatus.test.js
+++ b/src/goalServices/changeGoalStatus.test.js
@@ -54,12 +54,13 @@ describe('changeGoalStatus service', () => {
});
afterAll(async () => {
- await db.User.destroy({ where: { id: mockUser.id } });
- await db.Goal.destroy({ where: { id: goal.id } });
+ await db.Goal.destroy({ where: { id: goal.id }, force: true });
+ await db.GrantNumberLink.destroy({ where: { grantId: grant.id }, force: true });
await db.Grant.destroy({ where: { id: grant.id } });
await db.Recipient.destroy({ where: { id: recipient.id } });
await db.UserRole.destroy({ where: { userId: user.id } });
await db.Role.destroy({ where: { id: role.id } });
+ await db.User.destroy({ where: { id: mockUser.id } });
});
it('should change the status of a goal and create a status change log', async () => {
diff --git a/src/goalServices/changeGoalStatus.ts b/src/goalServices/changeGoalStatus.ts
index 26fb7b789c..08dd9dab7b 100644
--- a/src/goalServices/changeGoalStatus.ts
+++ b/src/goalServices/changeGoalStatus.ts
@@ -1,3 +1,4 @@
+import * as Sequelize from 'sequelize';
import db from '../models';
interface GoalStatusChangeParams {
@@ -6,14 +7,16 @@ interface GoalStatusChangeParams {
newStatus: string;
reason: string;
context: string;
+ transaction?: Sequelize.Transaction;
}
export default async function changeGoalStatus({
goalId,
- userId,
+ userId = 1,
newStatus,
reason,
context,
+ transaction = null,
}: GoalStatusChangeParams) {
const [user, goal] = await Promise.all([
db.User.findOne({
@@ -29,6 +32,7 @@ export default async function changeGoalStatus({
},
},
],
+ ...(transaction ? { transaction } : {}),
}),
db.Goal.findByPk(goalId),
]);
diff --git a/src/goalServices/getGoalIdsBySimiliarity.test.js b/src/goalServices/getGoalIdsBySimiliarity.test.js
index ce84203a21..4639785b80 100644
--- a/src/goalServices/getGoalIdsBySimiliarity.test.js
+++ b/src/goalServices/getGoalIdsBySimiliarity.test.js
@@ -1,8 +1,10 @@
+/* eslint-disable jest/no-conditional-expect */
import faker from '@faker-js/faker';
import { REPORT_STATUSES } from '@ttahub/common';
import {
ActivityReportGoal,
Grant,
+ GrantNumberLink,
Recipient,
Goal,
GoalTemplate,
@@ -350,6 +352,13 @@ describe('getGoalIdsBySimilarity', () => {
await destroyReport(needsActionReport);
await destroyReport(report);
+ await GrantNumberLink.destroy({
+ where: {
+ grantId: activeGrant.id,
+ },
+ force: true,
+ });
+
await Grant.destroy({
where: {
id: grants.map((g) => g.id),
@@ -509,7 +518,7 @@ describe('getGoalIdsBySimilarity', () => {
const [setOne, setTwo] = filteredSet;
expect(setOne.goals.length).toBe(4);
- expect(setTwo.goals.length).toBe(3);
+ expect(setTwo.goals.length).toBe(4);
const goalIds = [...setOne.goals, ...setTwo.goals];
@@ -577,7 +586,7 @@ describe('getGoalIdsBySimilarity', () => {
const [setOne, setTwo] = filteredSet;
expect(setOne.goals.length).toBe(7);
- expect(setTwo.goals.length).toBe(4);
+ expect(setTwo.goals.length).toBe(5);
});
describe('getReportCountForGoals', () => {
@@ -655,4 +664,366 @@ describe('getGoalIdsBySimilarity', () => {
}]))).toBe(false);
});
});
+
+ describe('getGoalIdsBySimilarity (closed, curated)', () => {
+ let goalGroup = [];
+ const goalTitle = faker.lorem.sentence();
+ let recipientForClosedCurated;
+ let activeGrantForClosedCurated;
+ let templateForClosedCurated;
+
+ beforeAll(async () => {
+ recipientForClosedCurated = await createRecipient();
+
+ activeGrantForClosedCurated = await createGrant({
+ recipientId: recipientForClosedCurated.id,
+ status: 'Active',
+ });
+
+ templateForClosedCurated = await createGoalTemplate({
+ name: goalTitle,
+ creationMethod: CREATION_METHOD.CURATED,
+ });
+
+ goalGroup = await Promise.all([
+ createGoal({
+ status: GOAL_STATUS.NOT_STARTED,
+ name: goalTitle,
+ goalTemplateId: templateForClosedCurated.id,
+ grantId: activeGrantForClosedCurated.id,
+ }),
+ createGoal({
+ status: GOAL_STATUS.NOT_STARTED,
+ name: goalTitle,
+ goalTemplateId: templateForClosedCurated.id,
+ grantId: activeGrantForClosedCurated.id,
+ }),
+ createGoal({
+ status: GOAL_STATUS.CLOSED,
+ name: goalTitle,
+ goalTemplateId: templateForClosedCurated.id,
+ grantId: activeGrantForClosedCurated.id,
+ }),
+ ]);
+ });
+
+ afterAll(async () => {
+ const goals = [
+ ...goalGroup,
+ ];
+ const grants = await Grant.findAll({
+ attributes: ['id', 'recipientId'],
+ where: {
+ id: goals.map((g) => g.grantId),
+ },
+ });
+
+ const recipients = await Recipient.findAll({
+ attributes: ['id'],
+ where: {
+ id: grants.map((g) => g.recipientId),
+ },
+ });
+
+ await GoalSimilarityGroupGoal.destroy({
+ where: {
+ goalId: goals.map((g) => g.id),
+ },
+ });
+
+ await Goal.destroy({
+ where: {
+ id: goals.map((g) => g.id),
+ },
+ force: true,
+ });
+
+ await GoalTemplate.destroy({
+ where: {
+ id: templateForClosedCurated.id,
+ },
+ force: true,
+ });
+
+ await GrantNumberLink.destroy({
+ where: {
+ grantId: activeGrantForClosedCurated.id,
+ },
+ force: true,
+ });
+
+ await Grant.destroy({
+ where: {
+ id: grants.map((g) => g.id),
+ },
+ force: true,
+ individualHooks: true,
+ });
+
+ await GoalSimilarityGroup.destroy({
+ where: {
+ recipientId: [recipientForClosedCurated.id],
+ },
+ });
+
+ await Recipient.destroy({
+ where: {
+ id: [...recipients.map((r) => r.id)],
+ },
+ force: true,
+ });
+ });
+
+ it('goal similiarity group, user has permission', async () => {
+ const similarityResponse = [
+ goalGroup,
+ ].map((group) => ({
+ id: group[0].id,
+ name: group[0].name,
+ matches: group.map((g) => ({
+ id: g.id,
+ name: g.name,
+ })),
+ }));
+
+ similarGoalsForRecipient.mockResolvedValue({ result: similarityResponse });
+
+ await getGoalIdsBySimilarity(recipientForClosedCurated.id);
+
+ const goalGroupIds = goalGroup.map((g) => g.id);
+ const goalGroupSimilarityGroupGoals = await GoalSimilarityGroupGoal.findAll({
+ where: {
+ goalId: goalGroupIds,
+ },
+ include: [{
+ model: Goal,
+ as: 'goal',
+ include: [{
+ model: GoalTemplate,
+ as: 'goalTemplate',
+ }],
+ }],
+ });
+
+ expect(goalGroupSimilarityGroupGoals).toHaveLength(goalGroup.length);
+
+ goalGroupSimilarityGroupGoals.forEach((g) => {
+ if (g.excludedIfNotAdmin) {
+ expect(g.goal.goalTemplate.creationMethod).toBe(CREATION_METHOD.CURATED);
+ expect(g.goal.status).toBe(GOAL_STATUS.CLOSED);
+ }
+ });
+
+ const excludedIfNotAdminGoalGroup = goalGroupSimilarityGroupGoals
+ .filter((g) => g.excludedIfNotAdmin);
+
+ expect(excludedIfNotAdminGoalGroup).toHaveLength(1);
+
+ const user = {
+ permissions: [],
+ flags: ['closed_goal_merge_override'],
+ };
+
+ const sets = await getGoalIdsBySimilarity(
+ recipientForClosedCurated.id,
+ activeGrantForClosedCurated.regionId,
+ user,
+ );
+
+ expect(sets).toHaveLength(2);
+
+ const setsWithGoals = sets.filter((set) => set.goals.length);
+ expect(setsWithGoals).toHaveLength(1);
+
+ const [set] = setsWithGoals;
+ expect(set.goals.length).toBe(3);
+ });
+
+ it('goal similiarity group, user does not have permission', async () => {
+ const similarityResponse = [
+ goalGroup,
+ ].map((group) => ({
+ id: group[0].id,
+ name: group[0].name,
+ matches: group.map((g) => ({
+ id: g.id,
+ name: g.name,
+ })),
+ }));
+
+ similarGoalsForRecipient.mockResolvedValue({ result: similarityResponse });
+
+ await getGoalIdsBySimilarity(recipientForClosedCurated.id);
+
+ const goalGroupIds = goalGroup.map((g) => g.id);
+ const goalGroupSimilarityGroupGoals = await GoalSimilarityGroupGoal.findAll({
+ where: {
+ goalId: goalGroupIds,
+ },
+ include: [{
+ model: Goal,
+ as: 'goal',
+ include: [{
+ model: GoalTemplate,
+ as: 'goalTemplate',
+ }],
+ }],
+ });
+
+ expect(goalGroupSimilarityGroupGoals).toHaveLength(goalGroup.length);
+
+ goalGroupSimilarityGroupGoals.forEach((g) => {
+ if (g.excludedIfNotAdmin) {
+ expect(g.goal.goalTemplate.creationMethod).toBe(CREATION_METHOD.CURATED);
+ expect(g.goal.status).toBe(GOAL_STATUS.CLOSED);
+ }
+ });
+
+ const allowedIfNotAdmin = goalGroupSimilarityGroupGoals
+ .filter((g) => !g.excludedIfNotAdmin).map((g) => g.goal.id);
+
+ expect(allowedIfNotAdmin).toHaveLength(2);
+
+ const sets = await getGoalIdsBySimilarity(
+ recipientForClosedCurated.id,
+ activeGrantForClosedCurated.regionId,
+ );
+
+ expect(sets).toHaveLength(2);
+
+ const setsWithGoals = sets.filter((set) => set.goals.length);
+ expect(setsWithGoals).toHaveLength(1);
+
+ const [set] = setsWithGoals;
+ expect(set.goals.length).toBe(2);
+ expect(set.goals).toEqual(expect.arrayContaining(allowedIfNotAdmin));
+ });
+ });
+
+ describe('getGoalIdsBySimilarity (two grants, 2 goals)', () => {
+ let goalGroup = [];
+ const goalTitle = faker.lorem.sentence();
+ let recipientFor2Grants2Goals;
+ let activeGrantFor2Grants2Goals;
+ let activeGrantFor2Grants2GoalsTwo;
+
+ const region = 4;
+
+ beforeAll(async () => {
+ recipientFor2Grants2Goals = await createRecipient();
+
+ activeGrantFor2Grants2Goals = await createGrant({
+ recipientId: recipientFor2Grants2Goals.id,
+ status: 'Active',
+ regionId: region,
+ });
+
+ activeGrantFor2Grants2GoalsTwo = await createGrant({
+ recipientId: recipientFor2Grants2Goals.id,
+ status: 'Active',
+ regionId: region,
+ });
+
+ goalGroup = await Promise.all([
+ createGoal({
+ status: GOAL_STATUS.NOT_STARTED,
+ name: goalTitle,
+ grantId: activeGrantFor2Grants2Goals.id,
+ }),
+ createGoal({
+ status: GOAL_STATUS.NOT_STARTED,
+ name: goalTitle,
+ grantId: activeGrantFor2Grants2GoalsTwo.id,
+ }),
+ ]);
+ });
+
+ afterAll(async () => {
+ const goals = [
+ ...goalGroup,
+ ];
+ const grants = await Grant.findAll({
+ attributes: ['id', 'recipientId'],
+ where: {
+ id: goals.map((g) => g.grantId),
+ },
+ });
+
+ const recipients = await Recipient.findAll({
+ attributes: ['id'],
+ where: {
+ id: grants.map((g) => g.recipientId),
+ },
+ });
+
+ await GoalSimilarityGroupGoal.destroy({
+ where: {
+ goalId: goals.map((g) => g.id),
+ },
+ });
+
+ await Goal.destroy({
+ where: {
+ id: goals.map((g) => g.id),
+ },
+ force: true,
+ });
+
+ await GrantNumberLink.destroy({
+ where: {
+ grantId: activeGrantFor2Grants2Goals.id,
+ },
+ force: true,
+ });
+
+ await Grant.destroy({
+ where: {
+ id: grants.map((g) => g.id),
+ },
+ force: true,
+ individualHooks: true,
+ });
+
+ await GoalSimilarityGroup.destroy({
+ where: {
+ recipientId: [recipientFor2Grants2Goals.id],
+ },
+ });
+
+ await Recipient.destroy({
+ where: {
+ id: [...recipients.map((r) => r.id)],
+ },
+ force: true,
+ });
+ });
+
+ it('goal similiarity group w/ 2 grants and 2 goals', async () => {
+ const similarityResponse = [
+ goalGroup,
+ ].map((group) => ({
+ id: group[0].id,
+ name: group[0].name,
+ matches: group.map((g) => ({
+ id: g.id,
+ name: g.name,
+ })),
+ }));
+
+ similarGoalsForRecipient.mockResolvedValue({ result: similarityResponse });
+
+ await getGoalIdsBySimilarity(recipientFor2Grants2Goals.id);
+
+ const sets = await getGoalIdsBySimilarity(
+ recipientFor2Grants2Goals.id,
+ activeGrantFor2Grants2Goals.regionId,
+ );
+
+ expect(sets).toHaveLength(1);
+ const [set] = sets;
+
+ // we expect no goals
+ expect(set.goals.length).toBe(0);
+ });
+ });
});
diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js
index 42ac46ecd0..1042779286 100644
--- a/src/goalServices/goals.js
+++ b/src/goalServices/goals.js
@@ -14,6 +14,7 @@ import {
GoalFieldResponse,
GoalTemplate,
GoalResource,
+ GoalStatusChange,
GoalTemplateFieldPrompt,
Grant,
Objective,
@@ -98,6 +99,12 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id, recipientId) => ({
id,
},
include: [
+ {
+ model: GoalStatusChange,
+ as: 'statusChanges',
+ attributes: ['oldStatus'],
+ required: false,
+ },
{
model: GoalCollaborator,
as: 'goalCollaborators',
@@ -751,6 +758,18 @@ function reducePrompts(forReport, newPrompts = [], promptsToReduce = []) {
}, promptsToReduce);
}
+function wasGoalPreviouslyClosed(goal) {
+ if (goal.previousStatus && goal.previousStatus === GOAL_STATUS.CLOSED) {
+ return true;
+ }
+
+ if (goal.statusChanges) {
+ return goal.statusChanges.some((statusChange) => statusChange.oldStatus === GOAL_STATUS.CLOSED);
+ }
+
+ return false;
+}
+
/**
* Dedupes goals by name + status, as well as objectives by title + status
* @param {Object[]} goals
@@ -808,6 +827,8 @@ function reduceGoals(goals, forReport = false) {
},
], 'goalCreatorName');
+ existingGoal.isReopenedGoal = wasGoalPreviouslyClosed(existingGoal);
+
if (forReport) {
existingGoal.prompts = reducePrompts(
forReport,
@@ -878,6 +899,7 @@ function reduceGoals(goals, forReport = false) {
isNew: false,
endDate,
source,
+ isReopenedGoal: wasGoalPreviouslyClosed(currentValue),
};
goal.collaborators = [
@@ -1187,8 +1209,10 @@ export async function goalByIdAndRecipient(id, recipientId) {
export async function goalsByIdAndRecipient(ids, recipientId) {
let goals = await Goal.findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId));
+
goals = goals.map((goal) => ({
...goal,
+ isReopenedGoal: wasGoalPreviouslyClosed(goal),
objectives: goal.objectives
.map((objective) => {
const o = {
@@ -2806,7 +2830,7 @@ export async function getGoalIdsBySimilarity(recipientId, regionId, user = null)
let closedCurated = false;
if (current.goalTemplate && current.goalTemplate.creationMethod === CREATION_METHOD.CURATED) {
- closedCurated = current.status !== GOAL_STATUS.CLOSED;
+ closedCurated = current.status === GOAL_STATUS.CLOSED;
}
// goal on an active report
@@ -2837,11 +2861,16 @@ export async function getGoalIdsBySimilarity(recipientId, regionId, user = null)
responsesForComparison: responsesForComparison(current),
ids: [current.id],
excludedIfNotAdmin,
+ grantId: grantLookup[current.grantId],
},
];
}, []));
- const groupsWithMoreThanOneGoal = goalGroupsDeduplicated.filter((group) => group.length > 1);
+ const groupsWithMoreThanOneGoalAndMoreGoalsThanGrants = goalGroupsDeduplicated
+ .filter((group) => {
+ const grantIds = uniq(group.map((goal) => goal.grantId));
+ return group.length > 1 && group.length !== grantIds.length;
+ });
// save the groups to the database
// there should also always be an empty group
@@ -2849,7 +2878,7 @@ export async function getGoalIdsBySimilarity(recipientId, regionId, user = null)
// and that we've run these computations
await Promise.all(
- [...groupsWithMoreThanOneGoal, []]
+ [...groupsWithMoreThanOneGoalAndMoreGoalsThanGrants, []]
.map((gg) => (
createSimilarityGroup(
recipientId,
diff --git a/src/goalServices/helpers.js b/src/goalServices/helpers.js
index 4c531358be..9f7911662e 100644
--- a/src/goalServices/helpers.js
+++ b/src/goalServices/helpers.js
@@ -11,7 +11,7 @@ const goalFieldTransate = {
const findOrFailExistingGoal = (needle, haystack, translate = goalFieldTransate) => {
const needleCollaborators = (needle.collaborators || []).map(
(c) => c.goalCreatorName,
- ).filter(Boolean) ?? [];
+ ).filter(Boolean);
const haystackCollaborators = haystack.flatMap(
(g) => (g.collaborators || []).map((c) => c.goalCreatorName).filter(Boolean),
@@ -22,7 +22,12 @@ const findOrFailExistingGoal = (needle, haystack, translate = goalFieldTransate)
&& g[translate.name].trim() === needle.name.trim()
&& g[translate.source] === needle.source
&& g[translate.responsesForComparison] === responsesForComparison(needle)
- && haystackCollaborators.some((c) => needleCollaborators.includes(c))
+ && (
+ // Check if both needle and haystack goal have no valid collaborators
+ (needleCollaborators.length === 0 && (g.collaborators || [])
+ .every((c) => c.goalCreatorName === undefined))
+ || haystackCollaborators.some((c) => needleCollaborators.includes(c))
+ )
));
};
diff --git a/src/models/hooks/activityReport.js b/src/models/hooks/activityReport.js
index 900e3a8da6..fc8f9065ad 100644
--- a/src/models/hooks/activityReport.js
+++ b/src/models/hooks/activityReport.js
@@ -1,3 +1,4 @@
+const httpContext = require('express-http-context');
const { Op } = require('sequelize');
const { REPORT_STATUSES } = require('@ttahub/common');
const {
@@ -50,6 +51,8 @@ const copyStatus = (instance) => {
};
const moveDraftGoalsToNotStartedOnSubmission = async (sequelize, instance, options) => {
+ // eslint-disable-next-line global-require
+ const changeGoalStatus = require('../../goalServices/changeGoalStatus').default;
const changed = instance.changed();
if (Array.isArray(changed)
&& changed.includes('submissionStatus')
@@ -76,20 +79,17 @@ const moveDraftGoalsToNotStartedOnSubmission = async (sequelize, instance, optio
});
const goalIds = goals.map((goal) => goal.id);
- await sequelize.models.Goal.update(
- { status: 'Not Started' },
- {
- where: {
- id: {
- [Op.in]: goalIds,
- },
- },
- transaction: options.transaction,
- individualHooks: true,
- },
- );
+ const userId = httpContext.get('impersonationUserId') || httpContext.get('loggedUser');
+ await Promise.all(goalIds.map((goalId) => changeGoalStatus({
+ goalId,
+ userId,
+ newStatus: GOAL_STATUS.NOT_STARTED,
+ reason: 'Activity Report submission',
+ context: null,
+ transaction: options.transaction,
+ })));
} catch (error) {
- auditLogger.error(JSON.stringify({ error }));
+ auditLogger.error(`moveDraftGoalsToNotStartedOnSubmission error: ${error}`);
}
}
};
@@ -108,7 +108,7 @@ const setSubmittedDate = (sequelize, instance, options) => {
instance.set('submittedDate', null);
}
} catch (e) {
- auditLogger.error(JSON.stringify({ e }));
+ auditLogger.error(`setSubmittedDate error: ${e}`);
}
};
@@ -131,11 +131,12 @@ const clearAdditionalNotes = (_sequelize, instance, options) => {
instance.set('additionalNotes', '');
}
} catch (e) {
- auditLogger.error(JSON.stringify({ e }));
+ auditLogger.error(`clearAdditionalNotes: ${e}`);
}
};
const propagateSubmissionStatus = async (sequelize, instance, options) => {
+ auditLogger.info('jp: propagateSubmissionStatus');
const changed = instance.changed();
if (Array.isArray(changed)
&& changed.includes('submissionStatus')
@@ -187,7 +188,7 @@ const propagateSubmissionStatus = async (sequelize, instance, options) => {
},
)));
} catch (e) {
- auditLogger.error(JSON.stringify({ e }));
+ auditLogger.error(`propagateSubmissionStatus > updating goal: ${e}}`);
}
let objectives;
@@ -238,7 +239,7 @@ const propagateSubmissionStatus = async (sequelize, instance, options) => {
},
)));
} catch (e) {
- auditLogger.error(JSON.stringify({ e }));
+ auditLogger.error(`propagateSubmissionStatus > updating objective: ${e}`);
}
}
};
@@ -730,6 +731,8 @@ const propagateApprovedStatus = async (sequelize, instance, options) => {
};
const automaticStatusChangeOnApprovalForGoals = async (sequelize, instance, options) => {
+ // eslint-disable-next-line global-require
+ const changeGoalStatus = require('../../goalServices/changeGoalStatus').default;
const changed = instance.changed();
if (Array.isArray(changed)
&& changed.includes('calculatedStatus')
@@ -760,13 +763,22 @@ const automaticStatusChangeOnApprovalForGoals = async (sequelize, instance, opti
},
);
- return Promise.all((goals.map((goal) => {
+ return Promise.all((goals.map(async (goal) => {
const status = GOAL_STATUS.IN_PROGRESS;
// if the goal should be in a different state, we will update it
if (goal.status !== status) {
- goal.set('previousStatus', goal.status);
- goal.set('status', status);
+ const userId = httpContext.get('impersonationUserId') || httpContext.get('loggedUser');
+
+ await changeGoalStatus({
+ goalId: goal.id,
+ userId,
+ newStatus: status,
+ reason: 'Activity Report approved',
+ context: null,
+ transaction: options.transaction,
+ });
+
if (instance.endDate) {
if (!goal.firstInProgressAt) {
goal.set('firstInProgressAt', instance.endDate);
@@ -1062,7 +1074,7 @@ const afterDestroy = async (sequelize, instance, options) => {
})));
} catch (e) {
// we do not want to surface these errors to the UI
- auditLogger.error('Failed to destroy linked similarity groups', JSON.stringify({ e }));
+ auditLogger.error(`Failed to destroy linked similarity groups ${e}`);
}
};
diff --git a/src/models/hooks/activityReport.test.js b/src/models/hooks/activityReport.test.js
index c0aaaa81a2..351cd35e3e 100644
--- a/src/models/hooks/activityReport.test.js
+++ b/src/models/hooks/activityReport.test.js
@@ -11,6 +11,7 @@ import db, {
Objective,
Recipient,
Grant,
+ GrantNumberLink,
User,
} from '..';
import { unlockReport } from '../../routes/activityReports/handlers';
@@ -186,11 +187,16 @@ describe('activity report model hooks', () => {
force: true,
});
+ await GrantNumberLink.destroy({
+ where: { grantId: grant.id },
+ force: true,
+ });
+
await Grant.unscoped().destroy({
where: {
id: grant.id,
},
- individualHooks: true,
+ force: true,
});
await Recipient.unscoped().destroy({
@@ -369,10 +375,7 @@ describe('moveDraftGoalsToNotStartedOnSubmission', () => {
const mockSequelize = {
models: {
Goal: {
- findAll: jest.fn(() => []),
- update: jest.fn(() => {
- throw new Error('test error');
- }),
+ findAll: jest.fn(() => { throw new Error('test error'); }),
},
ActivityReport: {},
},
diff --git a/src/models/hooks/activityReportObjective.test.js b/src/models/hooks/activityReportObjective.test.js
index 2c1d825213..b1c8f8bf5a 100644
--- a/src/models/hooks/activityReportObjective.test.js
+++ b/src/models/hooks/activityReportObjective.test.js
@@ -13,7 +13,7 @@ import {
import { draftObject } from './testHelpers';
import { FILE_STATUSES, OBJECTIVE_STATUS } from '../../constants';
-import { beforeDestroy } from './activityReportObjective';
+import { beforeDestroy, afterCreate } from './activityReportObjective';
import { processObjectiveForResourcesById, processActivityReportObjectiveForResourcesById } from '../../services/resource';
describe('activityReportObjective hooks', () => {
@@ -131,4 +131,42 @@ describe('activityReportObjective hooks', () => {
expect(transaction.finished).toBe('commit');
});
});
+
+ describe('propagateSupportTypeToObjective', () => {
+ let supportObjective;
+ let aroWithSupportType;
+
+ beforeAll(async () => {
+ supportObjective = await Objective.create({
+ title: 'test support objective',
+ status: OBJECTIVE_STATUS.NOT_STARTED,
+ });
+ });
+
+ afterAll(async () => {
+ await ActivityReportObjective.destroy({
+ where: { objectiveId: supportObjective.id },
+ });
+
+ await Objective.destroy({
+ where: { id: supportObjective.id },
+ force: true,
+ });
+ });
+
+ it('sets supportType on the objective when a new activityReportObjective with supportType is created', async () => {
+ const supportType = 'Introducing';
+ aroWithSupportType = await ActivityReportObjective.create({
+ objectiveId: supportObjective.id,
+ activityReportId: ar.id,
+ supportType,
+ });
+
+ const updatedObjective = await Objective.findOne({
+ where: { id: supportObjective.id },
+ });
+
+ expect(updatedObjective.supportType).toEqual(supportType);
+ });
+ });
});
diff --git a/src/models/hooks/goalStatusChange.test.js b/src/models/hooks/goalStatusChange.test.js
index b111ed13db..67d7511782 100644
--- a/src/models/hooks/goalStatusChange.test.js
+++ b/src/models/hooks/goalStatusChange.test.js
@@ -3,6 +3,7 @@ import {
sequelize,
GoalStatusChange,
Grant,
+ GrantNumberLink,
Goal,
Recipient,
User,
@@ -15,7 +16,7 @@ const mockUser = {
homeRegionId: 1,
name: fakeName,
hsesUsername: fakeName,
- hsesUserId: fakeName,
+ hsesUserId: faker.datatype.number(),
lastLogin: new Date(),
};
@@ -48,10 +49,11 @@ describe('GoalStatusChange hooks', () => {
});
afterAll(async () => {
- await Goal.destroy({ where: { id: goal.id } });
await GoalStatusChange.destroy({ where: { id: goalStatusChange.id } });
- await Grant.destroy({ where: { id: grant.id } });
await User.destroy({ where: { id: user.id } });
+ await Goal.destroy({ where: { id: goal.id }, force: true });
+ await GrantNumberLink.destroy({ where: { grantId: grant.id }, force: true });
+ await Grant.destroy({ where: { id: grant.id } });
await Recipient.destroy({ where: { id: recipient.id } });
await sequelize.close();
});
@@ -73,5 +75,27 @@ describe('GoalStatusChange hooks', () => {
expect(goal.status).toBe('In Progress');
});
+
+ it('should not update the goal status if the status is the same', async () => {
+ // Create a GoalStatusChange with the same oldStatus and newStatus
+ goalStatusChange = await GoalStatusChange.create({
+ goalId: goal.id,
+ userId: user.id,
+ userName: user.name,
+ userRoles: ['a', 'b'],
+ oldStatus: 'Draft',
+ newStatus: 'Draft', // Intentionally setting the same status
+ reason: 'Testing no status change',
+ context: 'Testing',
+ });
+
+ const previousStatus = goal.status;
+
+ await goalStatusChange.reload();
+ await goal.reload();
+
+ // The status should remain unchanged
+ expect(goal.status).toBe(previousStatus);
+ });
});
});
diff --git a/src/models/hooks/objective.js b/src/models/hooks/objective.js
index 2bc4294f31..1fd28000c2 100644
--- a/src/models/hooks/objective.js
+++ b/src/models/hooks/objective.js
@@ -1,3 +1,4 @@
+import httpContext from 'express-http-context';
import { Op } from 'sequelize';
import { REPORT_STATUSES } from '@ttahub/common';
import { OBJECTIVE_STATUS, OBJECTIVE_COLLABORATORS } from '../../constants';
@@ -201,12 +202,16 @@ const propogateStatusToParentGoal = async (sequelize, instance, options) => {
// and if so, we update it (storing the previous status so we can revert if needed)
if (atLeastOneInProgress) {
- await goal.update({
- status: 'In Progress',
- previousStatus: 'Not Started',
- }, {
+ // eslint-disable-next-line global-require
+ const changeGoalStatus = require('../../goalServices/changeGoalStatus').default;
+ const userId = httpContext.get('impersonationUserId') || httpContext.get('loggedUser');
+ await changeGoalStatus({
+ goalId: goal.id,
+ userId,
+ newStatus: 'In Progress',
+ reason: 'Objective moved to In Progress',
+ context: null,
transaction: options.transaction,
- individualHooks: true,
});
}
}
diff --git a/src/models/hooks/objective.test.js b/src/models/hooks/objective.test.js
index 8363fa093c..d79115dd7e 100644
--- a/src/models/hooks/objective.test.js
+++ b/src/models/hooks/objective.test.js
@@ -1,24 +1,47 @@
import faker from '@faker-js/faker';
import db, {
Goal,
+ GoalStatusChange,
Objective,
Recipient,
Grant,
+ GrantNumberLink,
+ User,
} from '..';
import { OBJECTIVE_STATUS } from '../../constants';
jest.mock('bull');
+const mockUserId = faker.datatype.number();
+
+jest.mock('express-http-context', () => {
+ const httpContext = jest.requireActual('express-http-context');
+ httpContext.get = jest.fn(() => mockUserId);
+ return httpContext;
+});
+
+const fakeName = faker.name.firstName() + faker.name.lastName();
+const mockUser = {
+ id: mockUserId,
+ homeRegionId: 1,
+ name: fakeName,
+ hsesUsername: fakeName,
+ hsesUserId: fakeName,
+ lastLogin: new Date(),
+};
+
describe('objective model hooks', () => {
let recipient;
let grant;
let goal;
+ let user;
let objective1;
let objective2;
let objective3;
beforeAll(async () => {
+ user = await User.create(mockUser);
recipient = await Recipient.create({
id: faker.datatype.number(),
name: faker.name.firstName(),
@@ -57,6 +80,11 @@ describe('objective model hooks', () => {
force: true,
});
+ await GrantNumberLink.destroy({
+ where: { grantId: grant.id },
+ force: true,
+ });
+
await Grant.destroy({
where: {
id: grant.id,
@@ -69,6 +97,7 @@ describe('objective model hooks', () => {
id: recipient.id,
},
});
+ await User.destroy({ where: { id: user.id } });
await db.sequelize.close();
});
@@ -100,13 +129,16 @@ describe('objective model hooks', () => {
expect(testGoal.status).toEqual('Draft');
expect(testGoal.id).toEqual(goal.id);
- await Goal.update(
- { status: 'Not Started' },
- {
- where: { id: goal.id },
- individualHooks: true,
- },
- );
+ await GoalStatusChange.create({
+ goalId: goal.id,
+ userId: user.id,
+ userName: user.name,
+ userRoles: ['a', 'b'],
+ oldStatus: 'Draft',
+ newStatus: 'Not Started',
+ reason: 'Just because',
+ context: 'Testing',
+ });
objective2 = await Objective.create({
title: 'Objective 2',
@@ -135,7 +167,6 @@ describe('objective model hooks', () => {
await Objective.update({ status: OBJECTIVE_STATUS.COMPLETE }, {
where: { id: [objective3.id, objective2.id] },
individualHooks: true,
-
});
testGoal = await Goal.findByPk(goal.id);
diff --git a/src/routes/goals/handlers.js b/src/routes/goals/handlers.js
index a58f3b2df6..dab17ef181 100644
--- a/src/routes/goals/handlers.js
+++ b/src/routes/goals/handlers.js
@@ -11,6 +11,7 @@ import {
mergeGoals,
getGoalIdsBySimilarity,
} from '../../goalServices/goals';
+import _changeGoalStatus from '../../goalServices/changeGoalStatus';
import getGoalsMissingDataForActivityReportSubmission from '../../goalServices/getGoalsMissingDataForActivityReportSubmission';
import nudge from '../../goalServices/nudge';
import handleErrors from '../../lib/apiErrorHandler';
@@ -96,6 +97,29 @@ export async function createGoals(req, res) {
}
}
+export async function reopenGoal(req, res) {
+ try {
+ const { goalId, reason, context } = req.body;
+ const userId = await currentUserId(req, res);
+
+ const updatedGoal = await _changeGoalStatus({
+ goalId,
+ userId,
+ newStatus: 'In Progress',
+ reason,
+ context,
+ });
+
+ if (!updatedGoal) {
+ res.sendStatus(httpCodes.BAD_REQUEST);
+ }
+
+ res.json(updatedGoal);
+ } catch (error) {
+ await handleErrors(req, res, error, `${logContext}:REOPEN_GOAL`);
+ }
+}
+
export async function changeGoalStatus(req, res) {
try {
const {
diff --git a/src/routes/goals/index.js b/src/routes/goals/index.js
index 3befc6d082..d07a674889 100644
--- a/src/routes/goals/index.js
+++ b/src/routes/goals/index.js
@@ -2,6 +2,7 @@ import express from 'express';
import {
createGoals,
changeGoalStatus,
+ reopenGoal,
retrieveGoalsByIds,
retrieveGoalByIdAndRecipient,
deleteGoal,
@@ -44,4 +45,6 @@ router.get(
transactionWrapper(getMissingDataForActivityReport),
);
+router.put('/reopen', transactionWrapper(reopenGoal));
+
export default router;
diff --git a/src/services/goalSimilarityGroup.test.js b/src/services/goalSimilarityGroup.test.js
index 86d8bc29a7..47bbfac96f 100644
--- a/src/services/goalSimilarityGroup.test.js
+++ b/src/services/goalSimilarityGroup.test.js
@@ -168,7 +168,7 @@ describe('goalSimilarityGroup services', () => {
expect(result).toEqual({
id: 'group-id',
- goals: ['goal-1', 'goal-3', 'goal-4'],
+ goals: ['goal-1', 'goal-2', 'goal-3', 'goal-4'],
});
});
});
diff --git a/src/services/goalSimilarityGroup.ts b/src/services/goalSimilarityGroup.ts
index 2f4576f9e6..fa8e3856a5 100644
--- a/src/services/goalSimilarityGroup.ts
+++ b/src/services/goalSimilarityGroup.ts
@@ -2,8 +2,6 @@ import { Op, WhereOptions, Model } from 'sequelize';
import { uniq } from 'lodash';
import db from '../models';
import {
- GOAL_STATUS,
- CREATION_METHOD,
CURRENT_GOAL_SIMILARITY_VERSION,
} from '../constants';
@@ -48,12 +46,7 @@ interface SimilarityGroup {
export const flattenSimilarityGroupGoals = (group: SimilarityGroup) => ({
...group.toJSON(),
- goals: group.goals.filter((goal) => {
- if (goal.goalTemplate && goal.goalTemplate.creationMethod === CREATION_METHOD.CURATED) {
- return goal.status !== GOAL_STATUS.CLOSED;
- }
- return true;
- }).map((goal) => goal.id),
+ goals: group.goals.map((goal) => goal.id),
});
export async function getSimilarityGroupById(
diff --git a/src/services/recipient.js b/src/services/recipient.js
index d3f0b05d37..cf52594743 100644
--- a/src/services/recipient.js
+++ b/src/services/recipient.js
@@ -10,6 +10,7 @@ import {
Goal,
GoalCollaborator,
GoalFieldResponse,
+ GoalStatusChange,
GoalTemplate,
ActivityReport,
EventReportPilot,
@@ -449,12 +450,32 @@ export function reduceObjectivesForRecipientRecord(
: new Date(a.endDate) < new Date(b.endDate)) ? 1 : -1));
}
+function wasGoalPreviouslyClosed(goal) {
+ if (goal.previousStatus && goal.previousStatus === GOAL_STATUS.CLOSED) {
+ return true;
+ }
+
+ if (goal.statusChanges) {
+ return goal.statusChanges.some((statusChange) => statusChange.oldStatus === GOAL_STATUS.CLOSED);
+ }
+
+ return false;
+}
+
function calculatePreviousStatus(goal) {
// if we have a previous status recorded, return that
if (goal.previousStatus) {
return goal.previousStatus;
}
+ if (goal.statusChanges) {
+ // statusChanges is an array of { oldStatus, newStatus }.
+ const lastStatusChange = goal.statusChanges[goal.statusChanges.length - 1];
+ if (lastStatusChange) {
+ return lastStatusChange.oldStatus;
+ }
+ }
+
// otherwise we check to see if there is the goal is on an activity report,
// and also check the status
if (goal.objectives.length) {
@@ -547,7 +568,6 @@ export async function getGoalsByActivityRecipient(
'createdAt',
'createdVia',
'goalNumber',
- 'previousStatus',
'onApprovedAR',
'onAR',
'isRttapa',
@@ -567,6 +587,12 @@ export async function getGoalsByActivityRecipient(
],
where: goalWhere,
include: [
+ {
+ model: GoalStatusChange,
+ as: 'statusChanges',
+ attributes: ['oldStatus'],
+ required: false,
+ },
{
model: GoalCollaborator,
as: 'goalCollaborators',
@@ -801,6 +827,8 @@ export async function getGoalsByActivityRecipient(
], 'goalCreatorName');
existingGoal.onAR = existingGoal.onAR || current.onAR;
+ existingGoal.isReopenedGoal = existingGoal.isReopenedGoal || wasGoalPreviouslyClosed(current);
+
return {
goalRows: previous.goalRows,
};
@@ -818,6 +846,7 @@ export async function getGoalsByActivityRecipient(
reasons: [],
source: current.source,
previousStatus: calculatePreviousStatus(current),
+ isReopenedGoal: wasGoalPreviouslyClosed(current),
objectives: [],
grantNumbers: [current.grant.number],
isRttapa: current.isRttapa,
diff --git a/src/services/recipient.test.js b/src/services/recipient.test.js
index 421aff0295..72f676b584 100644
--- a/src/services/recipient.test.js
+++ b/src/services/recipient.test.js
@@ -869,12 +869,12 @@ describe('Recipient DB service', () => {
it('properly de-duplicates based on responses', async () => {
const { goalRows } = await getGoalsByActivityRecipient(recipient.id, region, {});
- expect(goalRows.length).toBe(4);
+ expect(goalRows.length).toBe(3);
const doubler = goalRows.find((r) => r.responsesForComparison === 'not sure,dont have to');
expect(doubler).toBeTruthy();
- expect(doubler.ids.length).toBe(1);
+ expect(doubler.ids.length).toBe(2);
const singler = goalRows.find((r) => r.responsesForComparison === 'gotta');
expect(singler).toBeTruthy();
@@ -884,6 +884,19 @@ describe('Recipient DB service', () => {
expect(noResponse).toBeTruthy();
expect(noResponse.ids.length).toBe(1);
});
+
+ it('properly combines the same goals with no creators/collaborators', async () => {
+ // Remove other goals
+ goals[0].destroy();
+ goals[3].destroy();
+
+ const { goalRows } = await getGoalsByActivityRecipient(recipient.id, region, {});
+ expect(goalRows.length).toBe(1);
+ // Verify goal 2 and 3 have empty creators/collaborators
+ expect(goalRows[0].collaborators[0].goalCreator).toBe(undefined);
+ // Verify goal 2 and 3 are rolled up
+ expect(goalRows[0].ids.length).toBe(2);
+ });
});
describe('reduceObjectivesForRecipientRecord', () => {
@@ -1062,16 +1075,16 @@ describe('Recipient DB service', () => {
it('successfully reduces data without losing topics', async () => {
const goalsForRecord = await getGoalsByActivityRecipient(recipient.id, 5, {});
- expect(goalsForRecord.count).toBe(2);
- expect(goalsForRecord.goalRows.length).toBe(2);
+ expect(goalsForRecord.count).toBe(1);
+ expect(goalsForRecord.goalRows.length).toBe(1);
expect(goalsForRecord.allGoalIds.length).toBe(2);
const goal = goalsForRecord.goalRows[0];
- expect(goal.reasons.length).toBe(0);
+ expect(goal.reasons.length).toBe(1);
expect(goal.objectives.length).toBe(1);
const objective = goal.objectives[0];
- expect(objective.topics.length).toBe(1);
+ expect(objective.topics.length).toBe(4);
expect(objective.supportType).toBe('Planning');
});
});
diff --git a/src/tools/importPlanGoals.js b/src/tools/importPlanGoals.js
index 99e43ac196..ace80c2ae8 100644
--- a/src/tools/importPlanGoals.js
+++ b/src/tools/importPlanGoals.js
@@ -8,6 +8,7 @@ import {
Grant,
} from '../models';
import { logger } from '../logger';
+import changeGoalStatus from '../goalServices/changeGoalStatus';
async function parseCsv(fileKey) {
let recipients = {};
@@ -44,15 +45,12 @@ export async function updateStatus(goal, dbgoal) {
if (dbGoalStatusIdx < goalStatusIdx) {
logger.info(`Updating goal ${dbgoal.id}: Changing status from ${dbgoal.status} to ${goal.status}`);
- await Goal.update(
- {
- status: goal.status,
- },
- {
- where: { id: dbgoal.id },
- individualHooks: true,
- },
- );
+ await changeGoalStatus({
+ goalId: dbgoal.id,
+ userId: 1,
+ newStatus: goal.status,
+ reason: 'Imported from Smartsheet',
+ });
} else {
logger.info(`Skipping goal status update for ${dbgoal.id}: goal status ${dbgoal.status} is newer or equal to ${goal.status}`);
}
diff --git a/tests/api/goals.spec.ts b/tests/api/goals.spec.ts
index b8d83d909e..80e1e60f3f 100644
--- a/tests/api/goals.spec.ts
+++ b/tests/api/goals.spec.ts
@@ -69,7 +69,12 @@ test('get /goals?goalIds[]=&reportId', async ({ request }) => {
isNew: Joi.boolean(),
collaborators: Joi.array().items(Joi.any().allow(null)),
prompts: Joi.object(),
- source: Joi.any()
+ source: Joi.any(),
+ statusChanges: Joi.array().items(Joi.object({
+ oldStatus: Joi.string(),
+ newStatus: Joi.string(),
+ })),
+ isReopenedGoal: Joi.boolean(),
}));
await validateSchema(response, schema, expect);
@@ -130,6 +135,10 @@ test('get /goals/:goalId/recipient/:recipientId', async ({ request }) => {
}),
),
goalCollaborators: Joi.array().items(Joi.any().allow(null)),
+ statusChanges: Joi.array().items(Joi.object({
+ oldStatus: Joi.string(),
+ newStatus: Joi.string(),
+ })),
});
await validateSchema(response, schema, expect);
diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts
index 397dd9c0ef..715758946e 100644
--- a/tests/api/recipient.spec.ts
+++ b/tests/api/recipient.spec.ts
@@ -239,6 +239,11 @@ test.describe('get /recipient', () => {
endDate: Joi.string().allow(null).allow(''),
goalCollaborators: Joi.array().items(Joi.any().allow(null)),
collaborators: Joi.array().items(Joi.any().allow(null)),
+ statusChanges: Joi.array().items(Joi.object({
+ oldStatus: Joi.string(),
+ newStatus: Joi.string(),
+ })),
+ isReopenedGoal: Joi.boolean(),
})
).min(1);
diff --git a/yarn.lock b/yarn.lock
index 7314ca851b..e6f4fde800 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3260,10 +3260,10 @@
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
-"@ttahub/common@2.0.18":
- version "2.0.18"
- resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.0.18.tgz#2ee4b17d8a4265220affe2b1cd6f40e4eda36f75"
- integrity sha512-E5jgaVCwWeWCskAm7xYotn0LSlv2onsWU7J/0kMioqecg6jK5Poe7SfbyVvfkrU6N3hmLw7fe5ga8xpmDwij0w==
+"@ttahub/common@^2.1.3":
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/@ttahub/common/-/common-2.1.3.tgz#56fa179bec3525d7bd322907e25fa123e30f4b7d"
+ integrity sha512-HwzTa0t4a7sFG9N4qOo3HkrWPFZVYrYFJf/dRwRuFze/+o36B9oXaz+TY7Aqj30An6I1njKdytYPnWNIaSQf7w==
"@types/argparse@1.0.38":
version "1.0.38"