From 89eead18e854fdd8616ec98a4a32d0cc35859417 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 13 May 2024 12:00:31 -0400 Subject: [PATCH 01/78] Begin the grand refactor --- frontend/src/components/GoalForm/Form.js | 12 - .../src/components/GoalForm/ObjectiveForm.js | 192 +--- .../GoalForm/__tests__/ObjectiveForm.js | 64 +- .../components/GoalForm/__tests__/index.js | 322 +------ frontend/src/components/GoalForm/index.js | 230 +---- src/goalServices/goals.js | 852 +----------------- src/goalServices/goalsByIdAndRecipient.ts | 355 ++++++++ src/goalServices/reduceGoals.ts | 457 ++++++++++ src/goalServices/wasGoalPreviouslyClosed.ts | 11 + src/routes/goals/handlers.js | 32 - src/routes/goals/handlers.test.js | 126 --- src/routes/goals/index.js | 2 - 12 files changed, 852 insertions(+), 1803 deletions(-) create mode 100644 src/goalServices/goalsByIdAndRecipient.ts create mode 100644 src/goalServices/reduceGoals.ts create mode 100644 src/goalServices/wasGoalPreviouslyClosed.ts diff --git a/frontend/src/components/GoalForm/Form.js b/frontend/src/components/GoalForm/Form.js index c02a15a3f9..c80541fb0c 100644 --- a/frontend/src/components/GoalForm/Form.js +++ b/frontend/src/components/GoalForm/Form.js @@ -44,7 +44,6 @@ export default function Form({ objectives, setObjectives, setObjectiveError, - topicOptions, isOnApprovedReport, isOnReport, isCurated, @@ -54,7 +53,6 @@ export default function Form({ fetchError, goalNumbers, clearEmptyObjectiveError, - onUploadFiles, userCanEdit, source, setSource, @@ -101,11 +99,8 @@ export default function Form({ }; const objectiveErrors = errors[FORM_FIELD_INDEXES.OBJECTIVES]; - const formTitle = goalNumbers && goalNumbers.length ? `Goal ${goalNumbers.join(', ')}${isReopenedGoal ? '-R' : ''}` : 'Recipient TTA goal'; - const showAlert = isOnReport && status !== 'Closed'; - const notClosedWithEditPermission = (() => (status !== 'Closed' && userCanEdit))(); return ( @@ -238,8 +233,6 @@ export default function Form({ // that way we don't get the white screen of death errors={objectiveErrors[i] || OBJECTIVE_DEFAULT_ERRORS} setObjective={(data) => setObjective(data, i)} - topicOptions={topicOptions} - onUploadFiles={onUploadFiles} goalStatus={status} userCanEdit={userCanEdit} /> @@ -302,10 +295,6 @@ Form.propTypes = { endDate: PropTypes.string, setEndDate: PropTypes.func.isRequired, setObjectives: PropTypes.func.isRequired, - topicOptions: PropTypes.arrayOf(PropTypes.shape({ - label: PropTypes.string, - value: PropTypes.number, - })).isRequired, objectives: PropTypes.arrayOf(PropTypes.shape({ objective: PropTypes.string, topics: PropTypes.arrayOf(PropTypes.shape({ @@ -325,7 +314,6 @@ Form.propTypes = { [PropTypes.string, PropTypes.arrayOf(PropTypes.string)], ).isRequired, clearEmptyObjectiveError: PropTypes.func.isRequired, - onUploadFiles: PropTypes.func.isRequired, validateGoalNameAndRecipients: PropTypes.func.isRequired, userCanEdit: PropTypes.bool, prompts: PropTypes.shape({ diff --git a/frontend/src/components/GoalForm/ObjectiveForm.js b/frontend/src/components/GoalForm/ObjectiveForm.js index 8845292166..3c1fd17f4b 100644 --- a/frontend/src/components/GoalForm/ObjectiveForm.js +++ b/frontend/src/components/GoalForm/ObjectiveForm.js @@ -1,29 +1,15 @@ -import React, { useMemo, useContext, useRef } from 'react'; +import React, { useMemo, useContext } from 'react'; import PropTypes from 'prop-types'; import { REPORT_STATUSES } from '@ttahub/common'; import { Button } from '@trussworks/react-uswds'; import ObjectiveTitle from './ObjectiveTitle'; -import ObjectiveTopics from './ObjectiveTopics'; -import ResourceRepeater from './ResourceRepeater'; -import ObjectiveFiles from './ObjectiveFiles'; import { OBJECTIVE_FORM_FIELD_INDEXES, - validateListOfResources, OBJECTIVE_ERROR_MESSAGES, } from './constants'; - -import ObjectiveStatus from './ObjectiveStatus'; import AppLoadingContext from '../../AppLoadingContext'; -import ObjectiveSuspendModal from '../ObjectiveSuspendModal'; -import ObjectiveStatusSuspendReason from '../ObjectiveStatusSuspendReason'; -import ObjectiveSupportType from '../ObjectiveSupportType'; -const [ - objectiveTitleError, - objectiveTopicsError, - objectiveResourcesError, - objectiveSupportTypeError, -] = OBJECTIVE_ERROR_MESSAGES; +const [objectiveTitleError] = OBJECTIVE_ERROR_MESSAGES; export default function ObjectiveForm({ index, @@ -32,20 +18,10 @@ export default function ObjectiveForm({ objective, setObjective, errors, - topicOptions, - onUploadFiles, - goalStatus, userCanEdit, }) { // the parent objective data from props - const { - title, - topics, - resources, - status, - files, - supportType, - } = objective; + const { title, status } = objective; const isOnReport = useMemo(() => ( objective.activityReports && objective.activityReports.length > 0 @@ -59,30 +35,8 @@ export default function ObjectiveForm({ const { isAppLoading } = useContext(AppLoadingContext); - const modalRef = useRef(null); - // onchange handlers const onChangeTitle = (e) => setObjective({ ...objective, title: e.target.value }); - const onChangeTopics = (newTopics) => setObjective({ ...objective, topics: newTopics }); - const setResources = (newResources) => setObjective({ ...objective, resources: newResources }); - const onChangeFiles = (e) => { - setObjective({ ...objective, files: e }); - }; - const onChangeStatus = (newStatus) => setObjective({ ...objective, status: newStatus }); - const onChangeSupportType = (newSupportType) => setObjective( - { - ...objective, - supportType: newSupportType, - }, - ); - - const onUpdateStatus = (newStatus) => { - if (newStatus === 'Suspended') { - modalRef.current.toggleModal(); - return; - } - onChangeStatus(newStatus); - }; // validate different fields const validateObjectiveTitle = () => { @@ -97,58 +51,6 @@ export default function ObjectiveForm({ } }; - const validateSupportType = () => { - if (!supportType) { - const newErrors = [...errors]; - newErrors.splice(OBJECTIVE_FORM_FIELD_INDEXES.SUPPORT_TYPE, 1, {objectiveSupportTypeError}); - setObjectiveError(index, newErrors); - } else { - const newErrors = [...errors]; - newErrors.splice(OBJECTIVE_FORM_FIELD_INDEXES.SUPPORT_TYPE, 1, <>); - setObjectiveError(index, newErrors); - } - }; - - const validateObjectiveTopics = () => { - if (!topics.length) { - const newErrors = [...errors]; - newErrors.splice(OBJECTIVE_FORM_FIELD_INDEXES.TOPICS, 1, {objectiveTopicsError}); - setObjectiveError(index, newErrors); - } else { - const newErrors = [...errors]; - newErrors.splice(OBJECTIVE_FORM_FIELD_INDEXES.TOPICS, 1, <>); - setObjectiveError(index, newErrors); - } - }; - - const validateResourcesPassed = (newResources) => { - const validated = validateListOfResources(newResources); - let error; - if (!validated) { - error = {objectiveResourcesError}; - } else { - error = <>; - } - - const newErrors = [...errors]; - newErrors.splice(OBJECTIVE_FORM_FIELD_INDEXES.RESOURCES, 1, error); - setObjectiveError(index, newErrors); - }; - - const validateResources = () => { - validateResourcesPassed(resources); - }; - - const validateResourcesOnRemove = (newResources) => { - validateResourcesPassed(newResources); - }; - - const setSuspendReasonError = () => { - const newErrors = [...errors]; - newErrors.splice(OBJECTIVE_FORM_FIELD_INDEXES.STATUS_SUSPEND_REASON, 1, Select a reason for suspension); - setObjectiveError(index, newErrors); - }; - return (
@@ -168,94 +70,11 @@ export default function ObjectiveForm({ isLoading={isAppLoading} userCanEdit={userCanEdit} /> - - - - - { title && ( - ({ ...f, objectiveIds: objective.ids })) : []} - onChangeFiles={onChangeFiles} - objective={objective} - isOnReport={isOnReport || false} - isLoading={isAppLoading} - onUploadFiles={onUploadFiles} - index={index} - goalStatus={goalStatus} - userCanEdit={userCanEdit} - selectedObjectiveId={objective.id} - /> - )} - - setObjective( - { ...objective, closeSuspendReason: e.target.value }, - )} - objectiveSuspendInputName={`suspend-objective-${objective.id}-reason`} - objectiveSuspendContextInputName={`suspend-objective-${objective.id}-context`} - objectiveSuspendContext={objective.closeSuspendContext} - onChangeSuspendContext={(e) => setObjective({ - ...objective, - closeSuspendContext: e.target.value, - })} - onChangeStatus={onChangeStatus} - setError={setSuspendReasonError} - error={errors[OBJECTIVE_FORM_FIELD_INDEXES.STATUS_SUSPEND_REASON]} - /> - - - - - - -
); } ObjectiveForm.propTypes = { - goalStatus: PropTypes.string.isRequired, index: PropTypes.number.isRequired, removeObjective: PropTypes.func.isRequired, errors: PropTypes.arrayOf(PropTypes.node).isRequired, @@ -296,11 +115,6 @@ ObjectiveForm.propTypes = { })), status: PropTypes.string, }), - topicOptions: PropTypes.arrayOf(PropTypes.shape({ - label: PropTypes.string, - value: PropTypes.number, - })).isRequired, - onUploadFiles: PropTypes.func.isRequired, userCanEdit: PropTypes.bool.isRequired, }; diff --git a/frontend/src/components/GoalForm/__tests__/ObjectiveForm.js b/frontend/src/components/GoalForm/__tests__/ObjectiveForm.js index 71119103cc..a100bb0764 100644 --- a/frontend/src/components/GoalForm/__tests__/ObjectiveForm.js +++ b/frontend/src/components/GoalForm/__tests__/ObjectiveForm.js @@ -5,17 +5,11 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; -import selectEvent from 'react-select-event'; import ObjectiveForm from '../ObjectiveForm'; import UserContext from '../../../UserContext'; +import { OBJECTIVE_ERROR_MESSAGES } from '../constants'; -import { - OBJECTIVE_ERROR_MESSAGES, -} from '../constants'; - -const [ - objectiveTextError, objectiveTopicsError, -] = OBJECTIVE_ERROR_MESSAGES; +const [objectiveTextError] = OBJECTIVE_ERROR_MESSAGES; describe('ObjectiveForm', () => { const defaultObjective = { @@ -105,62 +99,10 @@ describe('ObjectiveForm', () => { renderObjectiveForm(objective, removeObjective, setObjectiveError, setObjective); - const topics = await screen.findByLabelText(/topics \*/i, { selector: '#topics' }); - userEvent.click(topics); - - const resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.click(resourceOne); - - expect(setObjectiveError).toHaveBeenCalledWith(index, [<>, {objectiveTopicsError}, <>, <>, <>, <>]); - - await selectEvent.select(topics, ['Coaching', 'Communication']); - - userEvent.click(topics); - userEvent.click(resourceOne); - expect(setObjectiveError).toHaveBeenCalledWith( - index, [<>, <>, <>, <>, <>, <>, - ], - ); - const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.click(objectiveText); - userEvent.click(resourceOne); + userEvent.tab(); // trigger blur event expect(setObjectiveError).toHaveBeenCalledWith(index, [{objectiveTextError}, <>, <>, <>, <>, <>]); }); - - it('you can change status', async () => { - const removeObjective = jest.fn(); - const setObjectiveError = jest.fn(); - const setObjective = jest.fn(); - - renderObjectiveForm( - { ...defaultObjective, status: 'In Progress' }, - removeObjective, - setObjectiveError, - setObjective, - 'In Progress', - ); - - const statusSelect = await screen.findByLabelText('Objective status'); - userEvent.selectOptions(statusSelect, 'Complete'); - expect(setObjective).toHaveBeenCalledWith({ ...defaultObjective, status: 'Complete' }); - userEvent.click(statusSelect); - }); - - it('displays the correct label based on resources from api', async () => { - const removeObjective = jest.fn(); - const setObjectiveError = jest.fn(); - const setObjective = jest.fn(); - - renderObjectiveForm( - defaultObjective, - removeObjective, - setObjectiveError, - setObjective, - ); - - const label = await screen.findByText('Link to TTA resource'); - expect(label).toBeVisible(); - }); }); diff --git a/frontend/src/components/GoalForm/__tests__/index.js b/frontend/src/components/GoalForm/__tests__/index.js index 129eaf2014..476243983a 100644 --- a/frontend/src/components/GoalForm/__tests__/index.js +++ b/frontend/src/components/GoalForm/__tests__/index.js @@ -6,8 +6,6 @@ import { screen, within, waitFor, - act, - fireEvent, } from '@testing-library/react'; import { REPORT_STATUSES, SCOPE_IDS } from '@ttahub/common'; import selectEvent from 'react-select-event'; @@ -21,9 +19,7 @@ import { OBJECTIVE_ERROR_MESSAGES } from '../constants'; import { BEFORE_OBJECTIVES_CREATE_GOAL, BEFORE_OBJECTIVES_SELECT_RECIPIENTS } from '../Form'; import AppLoadingContext from '../../../AppLoadingContext'; -const [ - objectiveTitleError, objectiveTopicsError, -] = OBJECTIVE_ERROR_MESSAGES; +const [objectiveTitleError] = OBJECTIVE_ERROR_MESSAGES; const topicsFromApi = [ 'Behavioral / Mental Health / Trauma', @@ -212,17 +208,6 @@ describe('create goal', () => { const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.type(objectiveText, 'test'); - const topics = await screen.findByLabelText(/topics \*/i, { selector: '#topics' }); - await selectEvent.select(topics, ['CLASS: Instructional Support']); - - const resourceOne = document.querySelector('#resource-1'); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - userEvent.click(save); expect(fetchMock.called('/api/goals')).toBeTruthy(); @@ -360,17 +345,6 @@ describe('create goal', () => { const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.type(objectiveText, 'test'); - const topics = await screen.findByLabelText(/topics \*/i, { selector: '#topics' }); - await selectEvent.select(topics, ['CLASS: Instructional Support']); - - const resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - expect(fetchMock.called('/api/goals')).toBe(false); userEvent.click(save); @@ -423,20 +397,6 @@ describe('create goal', () => { const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.type(objectiveText, 'test'); - const topicsText = screen.queryAllByLabelText(/topics \*/i); - expect(topicsText.length).toBe(1); - const topics = document.querySelector('#topics'); - - await selectEvent.select(topics, ['CLASS: Instructional Support']); - - const resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - const save = await screen.findByRole('button', { name: /save and continue/i }); userEvent.click(save); @@ -493,20 +453,6 @@ describe('create goal', () => { let objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.type(objectiveText, 'test'); - const topicsText = screen.queryAllByLabelText(/topics \*/i); - expect(topicsText.length).toBe(1); - let topics = document.querySelector('#topics'); - - await selectEvent.select(topics, ['CLASS: Instructional Support']); - - let supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - - let resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - const cancel = await screen.findByRole('link', { name: 'Cancel' }); let save = await screen.findByRole('button', { name: /save and continue/i }); userEvent.click(save); @@ -546,17 +492,6 @@ describe('create goal', () => { objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.type(objectiveText, 'test'); - topics = document.querySelector('#topics'); - await selectEvent.select(topics, ['CLASS: Instructional Support']); - - supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - - resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - userEvent.click((await screen.findByRole('button', { name: /save draft/i }))); save = await screen.findByRole('button', { name: /save and continue/i }); @@ -601,17 +536,6 @@ describe('create goal', () => { const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); userEvent.type(objectiveText, 'test'); - const topics = await screen.findByLabelText(/topics \*/i, { selector: '#topics' }); - await selectEvent.select(topics, ['CLASS: Instructional Support']); - - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - - const resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - let save = await screen.findByRole('button', { name: /save and continue/i }); userEvent.click(save); @@ -705,18 +629,6 @@ describe('create goal', () => { userEvent.click(save); - await screen.findByText(objectiveTopicsError); - - const topics = await screen.findByLabelText(/topics \*/i, { selector: '#topics' }); - await selectEvent.select(topics, ['Coaching']); - - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - - userEvent.click(save); - await screen.findByText(`Your goal was last saved at ${moment().format('MM/DD/YYYY [at] h:mm a')}`); expect(cancel).not.toBeVisible(); @@ -726,187 +638,6 @@ describe('create goal', () => { expect(fetchMock.called('/api/goals')).toBe(true); }); - it('can add and validate objective resources', async () => { - const recipient = { - id: 2, - grants: [ - { - id: 2, - numberWithProgramTypes: 'Turtle 2', - status: 'Active', - }, - ], - }; - - renderForm(recipient); - - await screen.findByRole('heading', { name: 'Goal summary' }); - fetchMock.restore(); - fetchMock.post('/api/goals', postResponse); - fetchMock.get('/api/feeds/item?tag=ttahub-topic', ` - Whats New - - Confluence Syndication Feed - https://acf-ohs.atlassian.net/wiki`); - fetchMock.get('/api/goals/recipient/2/region/1/nudge?name=This%20is%20goal%20text&grantNumbers=undefined', []); - - const goalText = await screen.findByRole('textbox', { name: /Recipient's goal/i }); - userEvent.type(goalText, 'This is goal text'); - - const ed = await screen.findByRole('textbox', { name: /anticipated close date \(mm\/dd\/yyyy\)/i }); - userEvent.type(ed, '08/15/2023'); - - let newObjective = await screen.findByRole('button', { name: 'Add new objective' }); - userEvent.click(newObjective); - - const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); - userEvent.type(objectiveText, 'This is objective text'); - - const topics = await screen.findByLabelText(/topics \*/i, { selector: '#topics' }); - await selectEvent.select(topics, ['Coaching']); - - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - act(() => { - userEvent.selectOptions(supportType, 'Implementing'); - }); - - const resourceOne = await screen.findByRole('textbox', { name: 'Resource 1' }); - userEvent.type(resourceOne, 'garrgeler'); - - const save = await screen.findByRole('button', { name: /save and continue/i }); - userEvent.click(save); - - await screen.findByText('Enter one resource per field. Valid resource links must start with http:// or https://'); - - userEvent.clear(resourceOne); - userEvent.type(resourceOne, 'https://search.marginalia.nu/'); - - let addNewResource = await screen.findByRole('button', { name: 'Add new resource' }); - userEvent.click(addNewResource); - - const resourceTwo = await screen.findByRole('textbox', { name: 'Resource 2' }); - userEvent.type(resourceTwo, 'https://search.marginalia.nu/https://search.marginalia.nu/https://search.marginalia.nu/'); - - const saveDraft = await screen.findByRole('button', { name: /save draft/i }); - userEvent.click(saveDraft); - - await screen.findByText('Enter one resource per field. Valid resource links must start with http:// or https://'); - - userEvent.clear(resourceTwo); - userEvent.type(resourceTwo, 'https://search.marginalia.nu/'); - - addNewResource = await screen.findByRole('button', { name: 'Add new resource' }); - userEvent.click(addNewResource); - - const resourceThree = await screen.findByRole('textbox', { name: 'Resource 3' }); - userEvent.type(resourceThree, 'NOT A LINK NOT A LINK HAHAHAHA'); - - userEvent.click(save); - - await screen.findByText('Enter one resource per field. Valid resource links must start with http:// or https://'); - - addNewResource = await screen.findByRole('button', { name: 'Add new resource' }); - userEvent.click(addNewResource); - - const removeResource = await screen.findByRole('button', { name: /remove resource 3/i }); - userEvent.click(removeResource); - - newObjective = await screen.findByRole('button', { name: 'Add new objective' }); - userEvent.click(newObjective); - - const removeObjective = await screen.findByRole('button', { name: 'Remove objective 2' }); - userEvent.click(removeObjective); - - userEvent.click(save); - - await screen.findByText(`Your goal was last saved at ${moment().format('MM/DD/YYYY [at] h:mm a')}`); - - const submit = await screen.findByRole('button', { name: /submit goal/i }); - userEvent.click(submit); - expect(fetchMock.called()).toBe(true); - }); - - it('can add objective files', async () => { - const recipient = { - id: 2, - grants: [ - { - id: 2, - numberWithProgramTypes: 'Turtle 2', - status: 'Active', - }, - ], - }; - - renderForm(recipient); - - await screen.findByRole('heading', { name: 'Goal summary' }); - fetchMock.restore(); - fetchMock.get('/api/feeds/item?tag=ttahub-topic', ` - Whats New - - Confluence Syndication Feed - https://acf-ohs.atlassian.net/wiki`); - fetchMock.post('/api/goals', postResponse); - fetchMock.get('/api/goals/recipient/2/region/1/nudge?name=This%20is%20goal%20text&grantNumbers=undefined', []); - - const goalText = await screen.findByRole('textbox', { name: /Recipient's goal/i }); - userEvent.type(goalText, 'This is goal text'); - - const ed = await screen.findByRole('textbox', { name: /anticipated close date \(mm\/dd\/yyyy\)/i }); - userEvent.type(ed, '08/15/2023'); - - const newObjective = await screen.findByRole('button', { name: 'Add new objective' }); - userEvent.click(newObjective); - - expect(document.querySelectorAll('ttahub-objective-files').length).toBe(0); - - const objectiveText = await screen.findByRole('textbox', { name: /TTA objective \*/i }); - userEvent.type(objectiveText, 'This is objective text'); - - const saveDraft = await screen.findByRole('button', { name: /save draft/i }); - await waitFor(() => userEvent.click(saveDraft)); - - const fieldset = document.querySelector('.ttahub-objective-files'); - - const yes = await within(fieldset).findByRole('radio', { name: 'Yes' }); - const no = await within(fieldset).findByRole('radio', { name: 'No' }); - - expect(no.checked).toBe(true); - act(() => userEvent.click(yes)); - expect(yes.checked).toBe(true); - - await screen.findByText('Attach any non-link resources'); - - const dispatchEvt = (node, type, data) => { - const event = new Event(type, { bubbles: true }); - Object.assign(event, data); - fireEvent(node, event); - }; - - const mockData = (files) => ({ - dataTransfer: { - files, - items: files.map((file) => ({ - kind: 'file', - type: file.type, - getAsFile: () => file, - })), - types: ['Files'], - }, - }); - - const file = (name, id, status = 'Uploaded') => ({ - originalFileName: name, id, fileSize: 2000, status, lastModified: 123456, - }); - - const data = mockData([file('file.csv', 1)]); - - const dropzone = await screen.findByRole('button', { name: 'Select and upload' }); - act(() => dispatchEvt(dropzone, 'drop', data)); - await waitFor(() => fetchMock.called('/api/files/objectives')); - }); - it('fetches and prepopulates goal data given an appropriate ID', async () => { fetchMock.get('/api/recipient/1/goals?goalIds=', [{ name: 'This is a goal name', @@ -929,8 +660,6 @@ describe('create goal', () => { id: 1238474, title: 'This is an objective', status: 'Not Started', - resources: [], - topics: [topicsFromApi[0]], }, ], }]); @@ -947,51 +676,6 @@ describe('create goal', () => { expect(endDate.value).toBe('10/08/2021'); }); - it('objective can be suspended', async () => { - fetchMock.get('/api/recipient/1/goals?goalIds=', [{ - name: 'This is a goal name', - status: 'In Progress', - endDate: '10/08/2021', - goalNumbers: ['G-12389'], - isRttapa: null, - prompts: [], - sources: [], - grants: [{ - id: 1, - number: '1', - programs: [{ - programType: 'EHS', - }], - status: 'Active', - }], - objectives: [ - { - id: 1238474, - title: 'This is an objective', - status: 'Not Started', - resources: [], - topics: [topicsFromApi[0]], - }, - ], - }]); - - renderForm(defaultRecipient, '12389'); - const objectiveStatus = await screen.findByRole('combobox', { name: /objective status/i }); - - userEvent.selectOptions(objectiveStatus, 'Suspended'); - const recipientRequestReason = await screen.findByLabelText(/Recipient request/i); - userEvent.click(recipientRequestReason); - - const context = await screen.findByLabelText(/Additional context/i); - userEvent.type(context, 'This is the context'); - - userEvent.click(await screen.findByText(/Submit/i)); - - expect(await screen.findByLabelText(/objective status/i)).toBeVisible(); - expect(await screen.findByText(/reason suspended/i)).toBeVisible(); - expect(await screen.findByText(/recipient request - this is the context/i)).toBeVisible(); - }); - it('draft goals don\'t show status dropdowns', async () => { fetchMock.get('/api/recipient/1/goals?goalIds=', [{ name: 'This is a goal name', @@ -1013,8 +697,6 @@ describe('create goal', () => { id: 1238474, title: 'This is an objective', status: 'Not Started', - resources: [], - topics: [topicsFromApi[0]], }, ], }]); @@ -1055,8 +737,6 @@ describe('create goal', () => { id: 1238474, title: 'This is an objective', status: 'Not Started', - resources: [], - topics: [topicsFromApi[0]], activityReports: [ { status: REPORT_STATUSES.SUBMITTED, diff --git a/frontend/src/components/GoalForm/index.js b/frontend/src/components/GoalForm/index.js index a0cdd55b60..df5739e9f8 100644 --- a/frontend/src/components/GoalForm/index.js +++ b/frontend/src/components/GoalForm/index.js @@ -7,7 +7,6 @@ import React, { } from 'react'; import moment from 'moment'; import { DECIMAL_BASE, SCOPE_IDS } from '@ttahub/common'; -import { v4 as uuidv4 } from 'uuid'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { Link, useHistory } from 'react-router-dom'; @@ -18,13 +17,10 @@ import useDeepCompareEffect from 'use-deep-compare-effect'; import Container from '../Container'; import { createOrUpdateGoals, deleteGoal, updateGoalStatus } from '../../fetchers/goals'; import { goalsByIdAndRecipient } from '../../fetchers/recipient'; -import { uploadObjectivesFile } from '../../fetchers/File'; -import { getTopics } from '../../fetchers/topics'; import Form from './Form'; import { FORM_FIELD_INDEXES, FORM_FIELD_DEFAULT_ERRORS, - validateListOfResources, OBJECTIVE_ERROR_MESSAGES, GOAL_NAME_ERROR, GOAL_DATE_ERROR, @@ -41,13 +37,7 @@ import useUrlParamState from '../../hooks/useUrlParamState'; import UserContext from '../../UserContext'; import VanillaModal from '../VanillaModal'; -const [ - objectiveTextError, - objectiveTopicsError, - objectiveResourcesError, - objectiveSupportTypeError, - objectiveStatusError, -] = OBJECTIVE_ERROR_MESSAGES; +const [objectiveTextError] = OBJECTIVE_ERROR_MESSAGES; export default function GoalForm({ recipient, @@ -83,9 +73,6 @@ export default function GoalForm({ // this will store our created goals (vs the goal that's occupying the form at present) const [createdGoals, setCreatedGoals] = useState([]); - - // this is for the topic options returned from the API - const [topicOptions, setTopicOptions] = useState([]); const [goalName, setGoalName] = useState(goalDefaults.name); const [endDate, setEndDate] = useState(goalDefaults.endDate); const [prompts, setPrompts] = useState( @@ -100,6 +87,14 @@ export default function GoalForm({ const [goalOnAnyReport, setGoalOnAnyReport] = useState(goalDefaults.onAnyReport); const [nudgedGoalSelection, setNudgedGoalSelection] = useState({}); const [isReopenedGoal, setIsReopenedGoal] = useState(goalDefaults.isReopenedGoal); + // we need to set this key to get the component to re-render (uncontrolled input) + const [datePickerKey, setDatePickerKey] = useState('DPK-00'); + const [status, setStatus] = useState(goalDefaults.status); + const [objectives, setObjectives] = useState(goalDefaults.objectives); + const [alert, setAlert] = useState({ message: '', type: 'success' }); + const [goalNumbers, setGoalNumbers] = useState(''); + const [goalCollaborators, setGoalCollaborators] = useState([]); + const [errors, setErrors] = useState(FORM_FIELD_DEFAULT_ERRORS); useDeepCompareEffect(() => { const newPrompts = grantsToMultiValue(selectedGrants, { ...prompts }); @@ -115,21 +110,7 @@ export default function GoalForm({ } }, [selectedGrants, source]); - // we need to set this key to get the component to re-render (uncontrolled input) - const [datePickerKey, setDatePickerKey] = useState('DPK-00'); - - const [status, setStatus] = useState(goalDefaults.status); - const [objectives, setObjectives] = useState(goalDefaults.objectives); - - const [alert, setAlert] = useState({ message: '', type: 'success' }); - const [goalNumbers, setGoalNumbers] = useState(''); - - const [goalCollaborators, setGoalCollaborators] = useState([]); - - const [errors, setErrors] = useState(FORM_FIELD_DEFAULT_ERRORS); - const { isAppLoading, setIsAppLoading, setAppLoadingText } = useContext(AppLoadingContext); - const { user } = useContext(UserContext); const canView = useMemo(() => user.permissions.filter( @@ -183,21 +164,7 @@ export default function GoalForm({ objectiveErrors, // and we need a matching error for each objective ] = goal.objectives.reduce((previous, objective) => { const [newObjs, objErrors] = previous; - let newObjective = objective; - - if (!objective.resources.length) { - newObjective = { - ...objective, - resources: [ - // this is the expected format of a blank resource - // all objectives start off with one - { - key: uuidv4(), - value: '', - }, - ], - }; - } + const newObjective = objective; newObjs.push(newObjective); // this is the format of an objective error @@ -235,19 +202,6 @@ export default function GoalForm({ setIsAppLoading, ]); - // for fetching topic options from API - useEffect(() => { - async function fetchTopics() { - try { - const topics = await getTopics(); - setTopicOptions(topics); - } catch (err) { - setFetchError('There was an error loading topics'); - } - } - fetchTopics(); - }, []); - const setObjectiveError = (objectiveIndex, errorText) => { const newErrors = [...errors]; const objectiveErrors = [...newErrors[FORM_FIELD_INDEXES.OBJECTIVES]]; @@ -405,85 +359,6 @@ export default function GoalForm({ ]; } - if (!objective.topics.length) { - isValid = false; - return [ - <>, - {objectiveTopicsError}, - <>, - <>, - <>, - <>, - ]; - } - - if (!validateListOfResources(objective.resources)) { - isValid = false; - return [ - <>, - <>, - {objectiveResourcesError}, - <>, - <>, - <>, - ]; - } - - if (!objective.status) { - isValid = false; - return [ - <>, - <>, - <>, - {objectiveStatusError}, - <>, - <>, - ]; - } - - if (!objective.supportType) { - isValid = false; - return [ - <>, - <>, - <>, - <>, - <>, - {objectiveSupportTypeError}, - ]; - } - - return [ - ...OBJECTIVE_DEFAULT_ERRORS, - ]; - }); - - newErrors.splice(FORM_FIELD_INDEXES.OBJECTIVES, 1, newObjectiveErrors); - setErrors(newErrors); - - return isValid; - }; - - const validateResourcesOnly = () => { - if (!objectives.length) { - return true; - } - - const newErrors = [...errors]; - let isValid = true; - - const newObjectiveErrors = objectives.map((objective) => { - if (!validateListOfResources(objective.resources)) { - isValid = false; - return [ - <>, - <>, - {objectiveResourcesError}, - <>, - <>, - <>, - ]; - } return [ ...OBJECTIVE_DEFAULT_ERRORS, ]; @@ -514,7 +389,6 @@ export default function GoalForm({ const isValidDraft = () => ( validateGrantNumbers() && validateGoalName() - && validateResourcesOnly() ); const updateObjectives = (updatedObjectives) => { @@ -580,88 +454,6 @@ export default function GoalForm({ } }; - const onUploadFiles = async (files, objective, setFileUploadErrorMessage, index) => { - // The first thing we need to know is... does this objective need to be created? - setAppLoadingText('Uploading'); - setIsAppLoading(true); - - // there is some weirdness where an objective may or may not have the "ids" property - let objectiveIds = objective.ids ? objective.ids : []; - if (!objectiveIds.length && objective.id) { - objectiveIds = [objective.id]; - } - - if (objective.isNew) { - // if so, we save the objective to the database first - try { - // but to do that, we first need to save the goals - const newGoals = grantsToGoals({ - selectedGrants, - name: goalName, - status, - source, - isCurated, - endDate, - regionId, - recipient, - objectives, - ids, - prompts, - }); - - // so we save them, as before creating one for each grant - const savedGoals = await createOrUpdateGoals(newGoals); - - // and then we pluck out the objectives from the newly saved goals - // (there will be only "one") - objectiveIds = savedGoals.reduce((p, c) => { - const newObjectives = c.objectives.reduce((prev, o) => { - if (objective.title === o.title) { - return [ - ...prev, - o.id, - ...o.ids, - ]; - } - - return prev; - }, []); - - return Array.from(new Set([...p, ...newObjectives])); - }, []); - } catch (err) { - setFileUploadErrorMessage('File could not be uploaded'); - } - } - - try { - // an objective that's been saved should have a set of IDS - // in the case that it has been rolled up to match a goal for multiple grants - const data = new FormData(); - data.append('objectiveIds', JSON.stringify(objectiveIds)); - files.forEach((file) => { - data.append('file', file); - }); - - const response = await uploadObjectivesFile(data); - setFileUploadErrorMessage(null); - - return { - ...response, - objectives, - setObjectives, - objectiveIds, - index, - }; - } catch (error) { - setFileUploadErrorMessage('File(s) could not be uploaded'); - } finally { - setIsAppLoading(false); - } - - return null; - }; - const onSaveDraft = async () => { if (!isValidDraft()) { // attempt to focus on the first invalid field @@ -1088,13 +880,11 @@ export default function GoalForm({ setObjectives={setObjectives} setObjectiveError={setObjectiveError} clearEmptyObjectiveError={clearEmptyObjectiveError} - topicOptions={topicOptions} isOnReport={goalOnAnyReport} isOnApprovedReport={goalOnApprovedAR} isCurated={isCurated} status={status || 'Needs status'} goalNumbers={goalNumbers} - onUploadFiles={onUploadFiles} userCanEdit={canEdit} validatePrompts={validatePrompts} source={source} diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index f8e8c0dc38..7f821df973 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -1,5 +1,4 @@ import { Op } from 'sequelize'; -import moment from 'moment'; import { uniqBy, uniq } from 'lodash'; import { DECIMAL_BASE, @@ -8,9 +7,7 @@ import { } from '@ttahub/common'; import { processObjectiveForResourcesById } from '../services/resource'; import { - CollaboratorType, Goal, - GoalCollaborator, GoalFieldResponse, GoalTemplate, GoalResource, @@ -28,7 +25,6 @@ import { ActivityReportObjectiveResource, ActivityReportObjectiveCourse, sequelize, - Recipient, Resource, ActivityReport, ActivityReportGoal, @@ -36,11 +32,7 @@ import { ActivityReportGoalFieldResponse, Topic, Course, - Program, File, - User, - UserRole, - Role, } from '../models'; import { OBJECTIVE_STATUS, @@ -67,251 +59,16 @@ import { } from '../services/goalSimilarityGroup'; import Users from '../policies/user'; import changeGoalStatus from './changeGoalStatus'; +import goalsByIdAndRecipient, { + OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, +} from './goalsByIdAndRecipient'; +import { reduceGoals } from './reduceGoals'; const namespace = 'SERVICE:GOALS'; - const logContext = { namespace, }; -const OPTIONS_FOR_GOAL_FORM_QUERY = (id, recipientId) => ({ - attributes: [ - 'id', - 'endDate', - 'name', - 'status', - [sequelize.col('grant.regionId'), 'regionId'], - [sequelize.col('grant.recipient.id'), 'recipientId'], - 'goalNumber', - 'createdVia', - 'goalTemplateId', - 'source', - [ - 'onAR', - 'onAnyReport', - ], - 'onApprovedAR', - [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], - 'rtrOrder', - ], - order: [['rtrOrder', 'asc']], - where: { - id, - }, - include: [ - { - model: GoalStatusChange, - as: 'statusChanges', - attributes: ['oldStatus'], - required: false, - }, - { - model: GoalCollaborator, - as: 'goalCollaborators', - attributes: ['id'], - required: false, - include: [ - { - model: CollaboratorType, - as: 'collaboratorType', - where: { - name: 'Creator', - }, - attributes: ['name'], - }, - { - model: User, - as: 'user', - attributes: ['name'], - required: true, - include: [ - { - model: UserRole, - as: 'userRoles', - include: [ - { - model: Role, - as: 'role', - attributes: ['name'], - }, - ], - attributes: ['id'], - }, - ], - }, - ], - }, - { - attributes: [ - 'title', - 'id', - 'status', - 'onApprovedAR', - 'rtrOrder', - 'supportType', - [ - 'onAR', - 'onAnyReport', - ], - 'closeSuspendReason', - 'closeSuspendContext', - ], - model: Objective, - as: 'objectives', - order: [['rtrOrder', 'ASC']], - include: [ - { - model: ObjectiveResource, - as: 'objectiveResources', - attributes: [ - [ - 'onAR', - 'onAnyReport', - ], - [ - 'onApprovedAR', - 'isOnApprovedReport', - ], - ], - include: [ - { - model: Resource, - as: 'resource', - attributes: [ - ['url', 'value'], - ['id', 'key'], - ], - }, - ], - where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.REPORTOBJECTIVE.RESOURCE] } }, - required: false, - }, - { - model: ObjectiveTopic, - as: 'objectiveTopics', - attributes: [ - [ - 'onAR', - 'onAnyReport', - ], - [ - 'onApprovedAR', - 'isOnApprovedReport', - ], - ], - include: [ - { - model: Topic, - as: 'topic', - attributes: ['id', 'name'], - }, - ], - }, - { - model: ObjectiveFile, - as: 'objectiveFiles', - attributes: [ - [ - 'onAR', - 'onAnyReport', - ], - [ - 'onApprovedAR', - 'isOnApprovedReport', - ], - ], - include: [ - { - model: File, - as: 'file', - }, - ], - }, - { - model: ActivityReport, - as: 'activityReports', - where: { - calculatedStatus: { - [Op.not]: REPORT_STATUSES.DELETED, - }, - }, - required: false, - }, - ], - }, - { - model: Grant, - as: 'grant', - attributes: [ - 'id', - 'number', - 'regionId', - 'recipientId', - 'numberWithProgramTypes', - ], - include: [ - { - attributes: ['programType'], - model: Program, - as: 'programs', - }, - { - attributes: ['id'], - model: Recipient, - as: 'recipient', - where: { - id: recipientId, - }, - required: true, - }, - ], - }, - { - model: GoalTemplate, - as: 'goalTemplate', - attributes: [], - required: false, - }, - { - model: GoalTemplateFieldPrompt, - as: 'prompts', - attributes: [ - ['id', 'promptId'], - 'ordinal', - 'title', - 'prompt', - 'hint', - 'fieldType', - 'options', - 'validations', - ], - required: false, - include: [ - { - model: GoalFieldResponse, - as: 'responses', - attributes: ['response'], - required: false, - where: { goalId: id }, - }, - { - model: ActivityReportGoalFieldResponse, - as: 'reportResponses', - attributes: ['response'], - required: false, - include: [{ - model: ActivityReportGoal, - as: 'activityReportGoal', - attributes: ['activityReportId', ['id', 'activityReportGoalId']], - required: true, - where: { goalId: id }, - }], - }, - ], - }, - ], -}); - export async function saveObjectiveAssociations( objective, resources = [], @@ -459,467 +216,6 @@ export async function saveObjectiveAssociations( }; } -// this is the reducer called when not getting objectives for a report, IE, the RTR table -export function reduceObjectives(newObjectives, currentObjectives = []) { - // objectives = accumulator - // we pass in the existing objectives as the accumulator - const objectivesToSort = newObjectives.reduce((objectives, objective) => { - const exists = objectives.find((o) => ( - o.title.trim() === objective.title.trim() && o.status === objective.status - )); - // eslint-disable-next-line no-nested-ternary - const id = objective.getDataValue - ? objective.getDataValue('id') - ? objective.getDataValue('id') - : objective.getDataValue('value') - : objective.id; - const otherEntityId = objective.getDataValue - ? objective.getDataValue('otherEntityId') - : objective.otherEntityId; - - if (exists) { - exists.ids = [...exists.ids, id]; - // Make sure we pass back a list of recipient ids for subsequent saves. - exists.recipientIds = otherEntityId - ? [...exists.recipientIds, otherEntityId] - : [...exists.recipientIds]; - exists.activityReports = [ - ...(exists.activityReports || []), - ...(objective.activityReports || []), - ]; - return objectives; - } - - return [...objectives, { - ...(objective.dataValues - ? objective.dataValues - : objective), - title: objective.title.trim(), - value: id, - ids: [id], - // Make sure we pass back a list of recipient ids for subsequent saves. - recipientIds: otherEntityId - ? [otherEntityId] - : [], - isNew: false, - }]; - }, currentObjectives); - - objectivesToSort.sort((o1, o2) => { - if (o1.rtrOrder < o2.rtrOrder) { - return -1; - } - return 1; - }); - - return objectivesToSort; -} - -/** - * Reduces the relation through activity report objectives. - * - * @param {Object} objective - The objective object. - * @param {string} join tablename that joins aro <> relation. e.g. activityReportObjectiveResources - * @param {string} relation - The relation that will be returned. e.g. resource. - * @param {Object} [exists={}] - The existing relation object. - * @returns {Array} - The reduced relation array. - */ -const reduceRelationThroughActivityReportObjectives = ( - objective, - join, - relation, - exists = {}, - uniqueBy = 'id', -) => { - const existingRelation = exists[relation] || []; - return uniqBy([ - ...existingRelation, - ...(objective.activityReportObjectives - && objective.activityReportObjectives.length > 0 - ? objective.activityReportObjectives[0][join] - .map((t) => t[relation].dataValues) - .filter((t) => t) - : []), - ], (e) => e[uniqueBy]); -}; - -export function reduceObjectivesForActivityReport(newObjectives, currentObjectives = []) { - const objectivesToSort = newObjectives.reduce((objectives, objective) => { - // check the activity report objective status - const objectiveStatus = objective.activityReportObjectives - && objective.activityReportObjectives[0] - && objective.activityReportObjectives[0].status - ? objective.activityReportObjectives[0].status : objective.status; - - const objectiveSupportType = objective.activityReportObjectives - && objective.activityReportObjectives[0] - && objective.activityReportObjectives[0].supportType - ? objective.activityReportObjectives[0].supportType : objective.supportType; - - // objectives represent the accumulator in the find below - // objective is the objective as it is returned from the API - const exists = objectives.find((o) => ( - o.title.trim() === objective.title.trim() && o.status === objectiveStatus - )); - - if (exists) { - const { id } = objective; - exists.ids = [...exists.ids, id]; - - // we can dedupe these using lodash - exists.resources = reduceRelationThroughActivityReportObjectives( - objective, - 'activityReportObjectiveResources', - 'resource', - exists, - 'value', - ); - - exists.topics = reduceRelationThroughActivityReportObjectives( - objective, - 'activityReportObjectiveTopics', - 'topic', - exists, - ); - - exists.courses = reduceRelationThroughActivityReportObjectives( - objective, - 'activityReportObjectiveCourses', - 'course', - exists, - ); - - exists.files = uniqBy([ - ...exists.files, - ...(objective.activityReportObjectives - && objective.activityReportObjectives.length > 0 - ? objective.activityReportObjectives[0].activityReportObjectiveFiles - .map((f) => ({ ...f.file.dataValues, url: f.file.url })) - : []), - ], (e) => e.key); - return objectives; - } - - // since this method is used to rollup both objectives on and off activity reports - // we need to handle the case where there is TTA provided and TTA not provided - // NOTE: there will only be one activity report objective, it is queried by activity report id - const ttaProvided = objective.activityReportObjectives - && objective.activityReportObjectives[0] - && objective.activityReportObjectives[0].ttaProvided - ? objective.activityReportObjectives[0].ttaProvided : null; - const arOrder = objective.activityReportObjectives - && objective.activityReportObjectives[0] - && objective.activityReportObjectives[0].arOrder - ? objective.activityReportObjectives[0].arOrder : null; - const closeSuspendContext = objective.activityReportObjectives - && objective.activityReportObjectives[0] - && objective.activityReportObjectives[0].closeSuspendContext - ? objective.activityReportObjectives[0].closeSuspendContext : null; - const closeSuspendReason = objective.activityReportObjectives - && objective.activityReportObjectives[0] - && objective.activityReportObjectives[0].closeSuspendReason - ? objective.activityReportObjectives[0].closeSuspendReason : null; - - const { id } = objective; - - return [...objectives, { - ...objective.dataValues, - title: objective.title.trim(), - value: id, - ids: [id], - ttaProvided, - supportType: objectiveSupportType, - status: objectiveStatus, // the status from above, derived from the activity report objective - isNew: false, - arOrder, - closeSuspendContext, - closeSuspendReason, - - // for the associated models, we need to return not the direct associations - // but those associated through an activity report since those reflect the state - // of the activity report not the state of the objective, which is what - // we are getting at with this method (getGoalsForReport) - - topics: reduceRelationThroughActivityReportObjectives( - objective, - 'activityReportObjectiveTopics', - 'topic', - ), - resources: reduceRelationThroughActivityReportObjectives( - objective, - 'activityReportObjectiveResources', - 'resource', - {}, - 'value', - ), - files: objective.activityReportObjectives - && objective.activityReportObjectives.length > 0 - ? objective.activityReportObjectives[0].activityReportObjectiveFiles - .map((f) => ({ ...f.file.dataValues, url: f.file.url })) - : [], - courses: reduceRelationThroughActivityReportObjectives( - objective, - 'activityReportObjectiveCourses', - 'course', - ), - }]; - }, currentObjectives); - - // Sort by AR Order in place. - objectivesToSort.sort((o1, o2) => { - if (o1.arOrder < o2.arOrder) { - return -1; - } - return 1; - }); - return objectivesToSort; -} - -/** - * - * @param {Boolean} forReport - * @param {Array} newPrompts - * @param {Array} promptsToReduce - * @returns Array of reduced prompts - */ -function reducePrompts(forReport, newPrompts = [], promptsToReduce = []) { - return newPrompts - ?.reduce((previousPrompts, currentPrompt) => { - const promptId = currentPrompt.promptId - ? currentPrompt.promptId : currentPrompt.dataValues.promptId; - - const existingPrompt = previousPrompts.find((pp) => pp.promptId === currentPrompt.promptId); - if (existingPrompt) { - if (!forReport) { - existingPrompt.response = uniq( - [...existingPrompt.response, ...currentPrompt.responses.flatMap((r) => r.response)], - ); - } - - if (forReport) { - existingPrompt.response = uniq( - [ - ...existingPrompt.response, - ...(currentPrompt.response || []), - ...(currentPrompt.reportResponse || []), - ], - ); - existingPrompt.reportResponse = uniq( - [ - ...(existingPrompt.reportResponse || []), - ...(currentPrompt.reportResponse || []), - ], - ); - - if (existingPrompt.allGoalsHavePromptResponse && (currentPrompt.response || []).length) { - existingPrompt.allGoalsHavePromptResponse = true; - } else { - existingPrompt.allGoalsHavePromptResponse = false; - } - } - - return previousPrompts; - } - - const newPrompt = { - promptId, - ordinal: currentPrompt.ordinal, - title: currentPrompt.title, - prompt: currentPrompt.prompt, - hint: currentPrompt.hint, - fieldType: currentPrompt.fieldType, - options: currentPrompt.options, - validations: currentPrompt.validations, - allGoalsHavePromptResponse: false, - }; - - if (forReport) { - newPrompt.response = uniq( - [ - ...(currentPrompt.response || []), - ...(currentPrompt.reportResponse || []), - ], - ); - newPrompt.reportResponse = (currentPrompt.reportResponse || []); - - if (newPrompt.response.length) { - newPrompt.allGoalsHavePromptResponse = true; - } - } - - if (!forReport) { - newPrompt.response = uniq(currentPrompt.responses.flatMap((r) => r.response)); - } - - return [ - ...previousPrompts, - newPrompt, - ]; - }, promptsToReduce); -} - -function wasGoalPreviouslyClosed(goal) { - 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 - * @returns {Object[]} array of deduped goals - */ -function reduceGoals(goals, forReport = false) { - const objectivesReducer = forReport ? reduceObjectivesForActivityReport : reduceObjectives; - - const where = (g, currentValue) => (forReport - ? g.name === currentValue.dataValues.name - && g.status === currentValue.dataValues.status - : g.name === currentValue.dataValues.name - && g.status === currentValue.dataValues.status); - - function getGoalCollaboratorDetails(collabType, dataValues) { - // eslint-disable-next-line max-len - const collaborator = dataValues.goalCollaborators?.find((gc) => gc.collaboratorType.name === collabType); - return { - [`goal${collabType}`]: collaborator, - [`goal${collabType}Name`]: collaborator?.user?.name, - [`goal${collabType}Roles`]: collaborator?.user?.userRoles?.map((ur) => ur.role.name).join(', '), - }; - } - - const r = goals.reduce((previousValues, currentValue) => { - try { - const existingGoal = previousValues.find((g) => where(g, currentValue)); - if (existingGoal) { - existingGoal.goalNumbers = [...existingGoal.goalNumbers, currentValue.goalNumber || `G-${currentValue.dataValues.id}`]; - existingGoal.goalIds = [...existingGoal.goalIds, currentValue.dataValues.id]; - existingGoal.grants = [ - ...existingGoal.grants, - { - ...currentValue.grant.dataValues, - recipient: currentValue.grant.recipient.dataValues, - name: currentValue.grant.name, - goalId: currentValue.dataValues.id, - numberWithProgramTypes: currentValue.grant.numberWithProgramTypes, - }, - ]; - existingGoal.grantIds = [...existingGoal.grantIds, currentValue.grant.id]; - existingGoal.objectives = objectivesReducer( - currentValue.objectives, - existingGoal.objectives, - ); - - existingGoal.collaborators = existingGoal.collaborators || []; - - existingGoal.collaborators = uniqBy([ - ...existingGoal.collaborators, - { - goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, - ...getGoalCollaboratorDetails('Creator', currentValue.dataValues), - ...getGoalCollaboratorDetails('Linker', currentValue.dataValues), - }, - ], 'goalCreatorName'); - - existingGoal.isReopenedGoal = wasGoalPreviouslyClosed(existingGoal); - - if (forReport) { - existingGoal.prompts = reducePrompts( - forReport, - currentValue.dataValues.prompts || [], - existingGoal.prompts || [], - ); - } else { - existingGoal.prompts = { - ...existingGoal.prompts, - [currentValue.grant.numberWithProgramTypes]: reducePrompts( - forReport, - currentValue.dataValues.prompts || [], - [], // we don't want to combine existing prompts if reducing for the RTR - ), - }; - existingGoal.source = { - ...existingGoal.source, - [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, - }; - } - return previousValues; - } - - const endDate = (() => { - const date = moment(currentValue.dataValues.endDate, 'YYYY-MM-DD').format('MM/DD/YYYY'); - - if (date === 'Invalid date') { - return ''; - } - - return date; - })(); - - let { source } = currentValue.dataValues; - let prompts = reducePrompts( - forReport, - currentValue.dataValues.prompts || [], - [], - ); - - if (!forReport) { - source = { - [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, - }; - prompts = { - [currentValue.grant.numberWithProgramTypes]: prompts, - }; - } - - const goal = { - ...currentValue.dataValues, - goalNumbers: [currentValue.goalNumber || `G-${currentValue.dataValues.id}`], - goalIds: [currentValue.dataValues.id], - grants: [ - { - ...currentValue.grant.dataValues, - numberWithProgramTypes: currentValue.grant.numberWithProgramTypes, - recipient: currentValue.grant.recipient.dataValues, - name: currentValue.grant.name, - goalId: currentValue.dataValues.id, - }, - ], - grantIds: [currentValue.grant.id], - objectives: objectivesReducer( - currentValue.objectives, - ), - prompts, - isNew: false, - endDate, - source, - isReopenedGoal: wasGoalPreviouslyClosed(currentValue), - }; - - goal.collaborators = [ - { - goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, - ...getGoalCollaboratorDetails('Creator', currentValue.dataValues), - ...getGoalCollaboratorDetails('Linker', currentValue.dataValues), - }, - ]; - - goal.collaborators = goal.collaborators.filter( - (c) => c.goalCreatorName !== null, - ); - - return [...previousValues, goal]; - } catch (err) { - auditLogger.error('Error reducing goal in services/goals reduceGoals, exiting reducer early', err); - return previousValues; - } - }, []); - - return r; -} - /** * * @param {number} id @@ -1171,90 +467,6 @@ export function goalByIdAndActivityReport(goalId, activityReportId) { }); } -export async function goalByIdAndRecipient(id, recipientId) { - const goal = await Goal.findOne(OPTIONS_FOR_GOAL_FORM_QUERY(id, recipientId)); - goal.objectives = goal.objectives - .map((objective) => ({ - ...objective.dataValues, - topics: objective.objectiveTopics - .map((objectiveTopic) => ({ - ...objectiveTopic.dataValues, - ...( - objectiveTopic.topic && objectiveTopic.topic.dataValues - ? objectiveTopic.topic.dataValues - : [] - ), - })) - .map((o) => ({ ...o, topic: undefined })), - files: objective.objectiveFiles - .map((objectiveFile) => ({ - ...objectiveFile.dataValues, - ...objectiveFile.file.dataValues, - })) - .map((f) => ({ ...f, file: undefined })), - resources: objective.objectiveResources - .map((objectiveResource) => ({ - ...objectiveResource.dataValues, - ...objectiveResource.resource.dataValues, - })) - .map((r) => ({ ...r, resource: undefined })), - })) - .map((objective) => ({ ...objective, objectiveTopics: undefined, objectiveFiles: undefined })); - return goal; -} - -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 = { - ...objective.dataValues, - topics: objective.objectiveTopics - .map((objectiveTopic) => { - const ot = { - ...objectiveTopic.dataValues, - ...( - objectiveTopic.topic && objectiveTopic.topic.dataValues - ? objectiveTopic.topic.dataValues - : [] - ), - }; - delete ot.topic; - return ot; - }), - files: objective.objectiveFiles - .map((objectiveFile) => { - const of = { - ...objectiveFile.dataValues, - ...objectiveFile.file.dataValues, - // url: objectiveFile.file.url, - }; - delete of.file; - return of; - }), - resources: objective.objectiveResources - .map((objectiveResource) => { - const oR = { - ...objectiveResource.dataValues, - ...objectiveResource.resource.dataValues, - }; - delete oR.resource; - return oR; - }), - }; - delete o.objectiveTopics; - delete o.objectiveFiles; - return o; - }), - })); - - return reduceGoals(goals); -} - export async function goalByIdWithActivityReportsAndRegions(goalId) { const goal = Goal.findOne({ attributes: [ @@ -1460,15 +672,9 @@ export async function createOrUpdateGoals(goals) { const newObjectives = await Promise.all( objectives.map(async (o, index) => { const { - resources, - topics, title, - files, status: objectiveStatus, id: objectiveIdsMayContainStrings, - closeSuspendContext, - closeSuspendReason, - supportType, } = o; const objectiveIds = [objectiveIdsMayContainStrings] @@ -1481,6 +687,7 @@ export async function createOrUpdateGoals(goals) { // we need to handle things a little differently if (objectiveStatus === OBJECTIVE_STATUS.COMPLETE && objectiveIds && objectiveIds.length) { objective = await Objective.findOne({ + attributes: OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, where: { id: objectiveIds, status: OBJECTIVE_STATUS.COMPLETE, @@ -1489,12 +696,7 @@ export async function createOrUpdateGoals(goals) { }); if (objective) { - return { - ...objective.dataValues, - topics, - resources, - files, - }; + return objective.toJSON(); } } @@ -1502,6 +704,7 @@ export async function createOrUpdateGoals(goals) { // this needs to find "complete" objectives as well // since we could be moving the status back from the RTR objective = await Objective.findOne({ + attributes: OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, where: { id: objectiveIds, goalId: newGoal.id, @@ -1514,6 +717,7 @@ export async function createOrUpdateGoals(goals) { // first we check to see if there is an objective with the same title // so we can reuse it (given it is not complete) objective = await Objective.findOne({ + attributes: OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, where: { status: { [Op.not]: OBJECTIVE_STATUS.COMPLETE }, title, @@ -1527,60 +731,28 @@ export async function createOrUpdateGoals(goals) { title, goalId: newGoal.id, createdVia: 'rtr', - supportType, }); } } - // here we update the objective, checking to see if the objective is on an approved AR - // and if the title has changed before we update the title specifically... - // otherwise, we only update the status and rtrOrder + // if the objective is not on an approved report + // and the title is different, update title objective.set({ - ...(!objective.dataValues.onApprovedAR + ...(!objective.onApprovedAR && title.trim() !== objective.dataValues.title.trim() && { title }), - status: objectiveStatus, rtrOrder: index + 1, }); - if (supportType && objective.supportType !== supportType) { - objective.set({ supportType }); - } - - // if the objective has been suspended, a reason and context should have been collected - if (objectiveStatus === OBJECTIVE_STATUS.SUSPENDED) { - objective.set({ - closeSuspendContext, - closeSuspendReason, - }); - } - // save the objective to the database await objective.save({ individualHooks: true }); - // save all our objective join tables (ObjectiveResource, ObjectiveTopic, ObjectiveFile) - const deleteUnusedAssociations = true; - await saveObjectiveAssociations( - objective, - resources, - topics, - files, - [], - deleteUnusedAssociations, - ); - - return { - ...objective.dataValues, - topics, - resources, - files, - }; + return objective.toJSON(); }), ); // this function deletes unused objectives await cleanupObjectivesForGoal(newGoal.id, newObjectives); - return newGoal.id; })); diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts new file mode 100644 index 0000000000..d69bfcd651 --- /dev/null +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -0,0 +1,355 @@ +import db from '../models'; +import { CREATION_METHOD } from '../constants'; +import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; +import { reduceGoals } from './reduceGoals'; + +const { + Goal, + GoalCollaborator, + GoalFieldResponse, + GoalTemplate, + GoalStatusChange, + GoalTemplateFieldPrompt, + Grant, + Objective, + ActivityReportObjective, + sequelize, + Recipient, + Resource, + ActivityReportGoal, + ActivityReportGoalFieldResponse, + Topic, + Program, + File, + User, + UserRole, + Role, + CollaboratorType, +} = db; + +export const OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR = [ + 'id', + 'title', + 'status', + 'goalId', + 'onApprovedAR', + 'rtrOrder', +]; + +interface ITopicFromDB { + name: string; + toJSON: () => { name: string }; +} + +interface IResourceFromDB { + url: string; + title: string; + toJSON: () => { url: string; title: string } +} + +interface IFileFromDB { + originalFileName: string; + key: string; + toJSON: () => { originalFileName: string; key: string } +} + +interface IActivityReportObjectivesFromDB { + topics: ITopicFromDB[]; + resources: IResourceFromDB[]; + files: IFileFromDB[]; +} + +interface IObjectiveFromDB { + id: number; + title: string; + status: string; + goalId: number; + onApprovedAR: boolean; + rtrOrder: number; + activityReportObjectives: IActivityReportObjectivesFromDB[]; +} + +interface IReducedObjective { + id: number; + title: string; + status: string; + goalId: number; + onApprovedAR: boolean; + rtrOrder: number; + topics: { + name: string + }[]; + resources: { + url: string; + title: string; + }[]; + files: { + originalFileName: string; + key: string; + }[]; +} + +interface IGoalForForm { + id: number; + endDate: string; + name: string; + status: string; + regionId: number; + recipientId: number; + goalNumber: string; + createdVia: string; + goalTemplateId: number; + source: string; + onAnyReport: boolean; + onApprovedAR: boolean; + isCurated: boolean; + rtrOrder: number; + statusChanges: { oldStatus: string }[]; + objectives: IObjectiveFromDB[]; + goalCollaborators: { + id: number; + collaboratorType: { name: string }; + user: { + name: string; + userRoles: { + role: { name: string }; + }[]; + }; + }[]; + grant: { + id: number; + number: string; + regionId: number; + recipientId: number; + numberWithProgramTypes: string; + programs: { programType: string }[]; + }; + goalTemplateFieldPrompts: { + promptId: number; + ordinal: number; + title: string; + prompt: string; + hint: string; + fieldType: string; + options: string; + validations: string; + responses: { response: string }[]; + reportResponses: { + response: string; + activityReportGoal: { + activityReportId: number; + activityReportGoalId: number; + }; + }[]; + }[]; +} + +type IReducedGoal = Omit & { + isReopenedGoal: boolean; + objectives: IReducedObjective[]; +}; + +const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) => ({ + attributes: [ + 'id', + 'endDate', + 'name', + 'status', + [sequelize.col('grant.regionId'), 'regionId'], + [sequelize.col('grant.recipient.id'), 'recipientId'], + 'goalNumber', + 'createdVia', + 'goalTemplateId', + 'source', + [ + 'onAR', + 'onAnyReport', + ], + 'onApprovedAR', + [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], + 'rtrOrder', + ], + order: [['rtrOrder', 'asc']], + where: { + id, + }, + include: [ + { + model: GoalStatusChange, + as: 'statusChanges', + attributes: ['oldStatus'], + required: false, + }, + { + model: GoalCollaborator, + as: 'goalCollaborators', + attributes: ['id'], + required: false, + include: [ + { + model: CollaboratorType, + as: 'collaboratorType', + where: { + name: 'Creator', + }, + attributes: ['name'], + }, + { + model: User, + as: 'user', + attributes: ['name'], + required: true, + include: [ + { + model: UserRole, + as: 'userRoles', + include: [ + { + model: Role, + as: 'role', + attributes: ['name'], + }, + ], + attributes: ['id'], + }, + ], + }, + ], + }, + { + model: Objective, + attributes: OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, + as: 'objectives', + order: [['rtrOrder', 'ASC']], + include: [ + { + model: ActivityReportObjective, + as: 'activityReportObjectives', + attributes: [], + include: [ + { + model: Topic, + as: 'topics', + attributes: ['name'], + }, + { + model: Resource, + as: 'resources', + attributes: ['url', 'title'], + }, + { + model: File, + as: 'files', + attributes: ['originalFileName', 'key'], + }, + ], + }, + ], + }, + { + model: Grant, + as: 'grant', + attributes: [ + 'id', + 'number', + 'regionId', + 'recipientId', + 'numberWithProgramTypes', + ], + include: [ + { + attributes: ['programType'], + model: Program, + as: 'programs', + }, + { + attributes: ['id'], + model: Recipient, + as: 'recipient', + where: { + id: recipientId, + }, + required: true, + }, + ], + }, + { + model: GoalTemplate, + as: 'goalTemplate', + attributes: [], + required: false, + }, + { + model: GoalTemplateFieldPrompt, + as: 'prompts', + attributes: [ + ['id', 'promptId'], + 'ordinal', + 'title', + 'prompt', + 'hint', + 'fieldType', + 'options', + 'validations', + ], + required: false, + include: [ + { + model: GoalFieldResponse, + as: 'responses', + attributes: ['response'], + required: false, + where: { goalId: id }, + }, + { + model: ActivityReportGoalFieldResponse, + as: 'reportResponses', + attributes: ['response'], + required: false, + include: [{ + model: ActivityReportGoal, + as: 'activityReportGoal', + attributes: ['activityReportId', ['id', 'activityReportGoalId']], + required: true, + where: { goalId: id }, + }], + }, + ], + }, + ], +}); + +function extractObjectiveAssociationsFromActivityReportObjectives( + activityReportObjectives: IActivityReportObjectivesFromDB[], + associationName: 'topics' | 'resources' | 'files', +) { + return activityReportObjectives.map((aro) => aro[associationName].map((a: + ITopicFromDB | IResourceFromDB | IFileFromDB) => a.toJSON())).flat(); +} + +export default async function goalsByIdAndRecipient(ids: number | number[], recipientId: number) { + const goals = await Goal.findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalForForm[]; + + const reformattedGoals = goals.map((goal: IGoalForForm) => ({ + ...goal, + isReopenedGoal: wasGoalPreviouslyClosed(goal), + objectives: goal.objectives + .map((objective) => ({ + ...OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR.map((field) => objective[field]), + topics: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'topics', + ), + resources: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'resources', + ), + files: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'files', + ), + } as unknown as IReducedObjective)), // Convert to 'unknown' first + })) as IReducedGoal[]; + + return reduceGoals(reformattedGoals); +} diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts new file mode 100644 index 0000000000..3b09f7f2b9 --- /dev/null +++ b/src/goalServices/reduceGoals.ts @@ -0,0 +1,457 @@ +import { uniq, uniqBy } from 'lodash'; +import moment from 'moment'; +import { auditLogger } from '../logger'; +import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; + +// this is the reducer called when not getting objectives for a report, IE, the RTR table +export function reduceObjectives(newObjectives, currentObjectives = []) { + // objectives = accumulator + // we pass in the existing objectives as the accumulator + const objectivesToSort = newObjectives.reduce((objectives, objective) => { + const exists = objectives.find((o) => ( + o.title.trim() === objective.title.trim() && o.status === objective.status + )); + // eslint-disable-next-line no-nested-ternary + const id = objective.getDataValue + ? objective.getDataValue('id') + ? objective.getDataValue('id') + : objective.getDataValue('value') + : objective.id; + const otherEntityId = objective.getDataValue + ? objective.getDataValue('otherEntityId') + : objective.otherEntityId; + + if (exists) { + exists.ids = [...exists.ids, id]; + // Make sure we pass back a list of recipient ids for subsequent saves. + exists.recipientIds = otherEntityId + ? [...exists.recipientIds, otherEntityId] + : [...exists.recipientIds]; + exists.activityReports = [ + ...(exists.activityReports || []), + ...(objective.activityReports || []), + ]; + return objectives; + } + + return [...objectives, { + ...(objective.dataValues + ? objective.dataValues + : objective), + title: objective.title.trim(), + value: id, + ids: [id], + // Make sure we pass back a list of recipient ids for subsequent saves. + recipientIds: otherEntityId + ? [otherEntityId] + : [], + isNew: false, + }]; + }, currentObjectives); + + objectivesToSort.sort((o1, o2) => { + if (o1.rtrOrder < o2.rtrOrder) { + return -1; + } + return 1; + }); + + return objectivesToSort; +} + +/** + * Reduces the relation through activity report objectives. + * + * @param {Object} objective - The objective object. + * @param {string} join tablename that joins aro <> relation. activityReportObjectiveResources + * @param {string} relation - The relation that will be returned. e.g. resource. + * @param {Object} [exists={}] - The existing relation object. + * @returns {Array} - The reduced relation array. + */ +const reduceRelationThroughActivityReportObjectives = ( + objective, + join, + relation, + exists = {}, + uniqueBy = 'id', +) => { + const existingRelation = exists[relation] || []; + return uniqBy([ + ...existingRelation, + ...(objective.activityReportObjectives + && objective.activityReportObjectives.length > 0 + ? objective.activityReportObjectives[0][join] + .map((t) => t[relation].dataValues) + .filter((t) => t) + : []), + ], (e) => e[uniqueBy]); +}; + +export function reduceObjectivesForActivityReport(newObjectives, currentObjectives = []) { + const objectivesToSort = newObjectives.reduce((objectives, objective) => { + // check the activity report objective status + const objectiveStatus = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].status + ? objective.activityReportObjectives[0].status : objective.status; + + const objectiveSupportType = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].supportType + ? objective.activityReportObjectives[0].supportType : objective.supportType; + + // objectives represent the accumulator in the find below + // objective is the objective as it is returned from the API + const exists = objectives.find((o) => ( + o.title.trim() === objective.title.trim() && o.status === objectiveStatus + )); + + if (exists) { + const { id } = objective; + exists.ids = [...exists.ids, id]; + + // we can dedupe these using lodash + exists.resources = reduceRelationThroughActivityReportObjectives( + objective, + 'activityReportObjectiveResources', + 'resource', + exists, + 'value', + ); + + exists.topics = reduceRelationThroughActivityReportObjectives( + objective, + 'activityReportObjectiveTopics', + 'topic', + exists, + ); + + exists.courses = reduceRelationThroughActivityReportObjectives( + objective, + 'activityReportObjectiveCourses', + 'course', + exists, + ); + + exists.files = uniqBy([ + ...exists.files, + ...(objective.activityReportObjectives + && objective.activityReportObjectives.length > 0 + ? objective.activityReportObjectives[0].activityReportObjectiveFiles + .map((f) => ({ ...f.file.dataValues, url: f.file.url })) + : []), + ], (e) => e.key); + return objectives; + } + + // since this method is used to rollup both objectives on and off activity reports + // we need to handle the case where there is TTA provided and TTA not provided + // NOTE: there will only be one activity report objective, it is queried by activity report id + const ttaProvided = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].ttaProvided + ? objective.activityReportObjectives[0].ttaProvided : null; + const arOrder = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].arOrder + ? objective.activityReportObjectives[0].arOrder : null; + const closeSuspendContext = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].closeSuspendContext + ? objective.activityReportObjectives[0].closeSuspendContext : null; + const closeSuspendReason = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].closeSuspendReason + ? objective.activityReportObjectives[0].closeSuspendReason : null; + + const { id } = objective; + + return [...objectives, { + ...objective.dataValues, + title: objective.title.trim(), + value: id, + ids: [id], + ttaProvided, + supportType: objectiveSupportType, + status: objectiveStatus, // the status from above, derived from the activity report objective + isNew: false, + arOrder, + closeSuspendContext, + closeSuspendReason, + + // for the associated models, we need to return not the direct associations + // but those associated through an activity report since those reflect the state + // of the activity report not the state of the objective, which is what + // we are getting at with this method (getGoalsForReport) + + topics: reduceRelationThroughActivityReportObjectives( + objective, + 'activityReportObjectiveTopics', + 'topic', + ), + resources: reduceRelationThroughActivityReportObjectives( + objective, + 'activityReportObjectiveResources', + 'resource', + {}, + 'value', + ), + files: objective.activityReportObjectives + && objective.activityReportObjectives.length > 0 + ? objective.activityReportObjectives[0].activityReportObjectiveFiles + .map((f) => ({ ...f.file.dataValues, url: f.file.url })) + : [], + courses: reduceRelationThroughActivityReportObjectives( + objective, + 'activityReportObjectiveCourses', + 'course', + ), + }]; + }, currentObjectives); + + // Sort by AR Order in place. + objectivesToSort.sort((o1, o2) => { + if (o1.arOrder < o2.arOrder) { + return -1; + } + return 1; + }); + return objectivesToSort; +} + +/** + * + * @param {Boolean} forReport + * @param {Array} newPrompts + * @param {Array} promptsToReduce + * @returns Array of reduced prompts + */ +function reducePrompts(forReport, newPrompts = [], promptsToReduce = []) { + return newPrompts + ?.reduce((previousPrompts, currentPrompt) => { + const promptId = currentPrompt.promptId + ? currentPrompt.promptId : currentPrompt.dataValues.promptId; + + const existingPrompt = previousPrompts.find((pp) => pp.promptId === currentPrompt.promptId); + if (existingPrompt) { + if (!forReport) { + existingPrompt.response = uniq( + [...existingPrompt.response, ...currentPrompt.responses.flatMap((r) => r.response)], + ); + } + + if (forReport) { + existingPrompt.response = uniq( + [ + ...existingPrompt.response, + ...(currentPrompt.response || []), + ...(currentPrompt.reportResponse || []), + ], + ); + existingPrompt.reportResponse = uniq( + [ + ...(existingPrompt.reportResponse || []), + ...(currentPrompt.reportResponse || []), + ], + ); + + if (existingPrompt.allGoalsHavePromptResponse && (currentPrompt.response || []).length) { + existingPrompt.allGoalsHavePromptResponse = true; + } else { + existingPrompt.allGoalsHavePromptResponse = false; + } + } + + return previousPrompts; + } + + const newPrompt = { + promptId, + ordinal: currentPrompt.ordinal, + title: currentPrompt.title, + prompt: currentPrompt.prompt, + hint: currentPrompt.hint, + fieldType: currentPrompt.fieldType, + options: currentPrompt.options, + validations: currentPrompt.validations, + allGoalsHavePromptResponse: false, + }; + + if (forReport) { + newPrompt.response = uniq( + [ + ...(currentPrompt.response || []), + ...(currentPrompt.reportResponse || []), + ], + ); + newPrompt.reportResponse = (currentPrompt.reportResponse || []); + + if (newPrompt.response.length) { + newPrompt.allGoalsHavePromptResponse = true; + } + } + + if (!forReport) { + newPrompt.response = uniq(currentPrompt.responses.flatMap((r) => r.response)); + } + + return [ + ...previousPrompts, + newPrompt, + ]; + }, promptsToReduce); +} + +/** + * Dedupes goals by name + status, as well as objectives by title + status + * @param {Object[]} goals + * @returns {Object[]} array of deduped goals + */ +export function reduceGoals(goals, forReport = false) { + const objectivesReducer = forReport ? reduceObjectivesForActivityReport : reduceObjectives; + + const where = (g, currentValue) => (forReport + ? g.name === currentValue.dataValues.name + && g.status === currentValue.dataValues.status + : g.name === currentValue.dataValues.name + && g.status === currentValue.dataValues.status); + + function getGoalCollaboratorDetails(collabType, dataValues) { + // eslint-disable-next-line max-len + const collaborator = dataValues.goalCollaborators?.find((gc) => gc.collaboratorType.name === collabType); + return { + [`goal${collabType}`]: collaborator, + [`goal${collabType}Name`]: collaborator?.user?.name, + [`goal${collabType}Roles`]: collaborator?.user?.userRoles?.map((ur) => ur.role.name).join(', '), + }; + } + + const r = goals.reduce((previousValues, currentValue) => { + try { + const existingGoal = previousValues.find((g) => where(g, currentValue)); + if (existingGoal) { + existingGoal.goalNumbers = [...existingGoal.goalNumbers, currentValue.goalNumber || `G-${currentValue.dataValues.id}`]; + existingGoal.goalIds = [...existingGoal.goalIds, currentValue.dataValues.id]; + existingGoal.grants = [ + ...existingGoal.grants, + { + ...currentValue.grant.dataValues, + recipient: currentValue.grant.recipient.dataValues, + name: currentValue.grant.name, + goalId: currentValue.dataValues.id, + numberWithProgramTypes: currentValue.grant.numberWithProgramTypes, + }, + ]; + existingGoal.grantIds = [...existingGoal.grantIds, currentValue.grant.id]; + existingGoal.objectives = objectivesReducer( + currentValue.objectives, + existingGoal.objectives, + ); + + existingGoal.collaborators = existingGoal.collaborators || []; + + existingGoal.collaborators = uniqBy([ + ...existingGoal.collaborators, + { + goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, + ...getGoalCollaboratorDetails('Creator', currentValue.dataValues), + ...getGoalCollaboratorDetails('Linker', currentValue.dataValues), + }, + ], 'goalCreatorName'); + + existingGoal.isReopenedGoal = wasGoalPreviouslyClosed(existingGoal); + + if (forReport) { + existingGoal.prompts = reducePrompts( + forReport, + currentValue.dataValues.prompts || [], + existingGoal.prompts || [], + ); + } else { + existingGoal.prompts = { + ...existingGoal.prompts, + [currentValue.grant.numberWithProgramTypes]: reducePrompts( + forReport, + currentValue.dataValues.prompts || [], + [], // we don't want to combine existing prompts if reducing for the RTR + ), + }; + existingGoal.source = { + ...existingGoal.source, + [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, + }; + } + return previousValues; + } + + const endDate = (() => { + const date = moment(currentValue.dataValues.endDate, 'YYYY-MM-DD').format('MM/DD/YYYY'); + + if (date === 'Invalid date') { + return ''; + } + + return date; + })(); + + let { source } = currentValue.dataValues; + let prompts = reducePrompts( + forReport, + currentValue.dataValues.prompts || [], + [], + ); + + if (!forReport) { + source = { + [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, + }; + prompts = { + [currentValue.grant.numberWithProgramTypes]: prompts, + }; + } + + const goal = { + ...currentValue.dataValues, + goalNumbers: [currentValue.goalNumber || `G-${currentValue.dataValues.id}`], + goalIds: [currentValue.dataValues.id], + grants: [ + { + ...currentValue.grant.dataValues, + numberWithProgramTypes: currentValue.grant.numberWithProgramTypes, + recipient: currentValue.grant.recipient.dataValues, + name: currentValue.grant.name, + goalId: currentValue.dataValues.id, + }, + ], + grantIds: [currentValue.grant.id], + objectives: objectivesReducer( + currentValue.objectives, + ), + prompts, + isNew: false, + endDate, + source, + isReopenedGoal: wasGoalPreviouslyClosed(currentValue), + }; + + goal.collaborators = [ + { + goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, + ...getGoalCollaboratorDetails('Creator', currentValue.dataValues), + ...getGoalCollaboratorDetails('Linker', currentValue.dataValues), + }, + ]; + + goal.collaborators = goal.collaborators.filter( + (c) => c.goalCreatorName !== null, + ); + + return [...previousValues, goal]; + } catch (err) { + auditLogger.error('Error reducing goal in services/goals reduceGoals, exiting reducer early', err); + return previousValues; + } + }, []); + + return r; +} diff --git a/src/goalServices/wasGoalPreviouslyClosed.ts b/src/goalServices/wasGoalPreviouslyClosed.ts new file mode 100644 index 0000000000..35eca93744 --- /dev/null +++ b/src/goalServices/wasGoalPreviouslyClosed.ts @@ -0,0 +1,11 @@ +import { GOAL_STATUS } from '../constants'; + +export default function wasGoalPreviouslyClosed( + goal: { statusChanges: { oldStatus: string }[] }, +) { + if (goal.statusChanges) { + return goal.statusChanges.some((statusChange) => statusChange.oldStatus === GOAL_STATUS.CLOSED); + } + + return false; +} diff --git a/src/routes/goals/handlers.js b/src/routes/goals/handlers.js index 842c8b2cc1..5be7201e6b 100644 --- a/src/routes/goals/handlers.js +++ b/src/routes/goals/handlers.js @@ -6,7 +6,6 @@ import { createOrUpdateGoals, goalsByIdsAndActivityReport, goalByIdWithActivityReportsAndRegions, - goalByIdAndRecipient, destroyGoal, mergeGoals, getGoalIdsBySimilarity, @@ -254,37 +253,6 @@ export async function retrieveGoalsByIds(req, res) { } } -export async function retrieveGoalByIdAndRecipient(req, res) { - try { - const { goalId, recipientId } = req.params; - - const userId = await currentUserId(req, res); - const user = await userById(userId); - const goal = await goalByIdWithActivityReportsAndRegions(goalId); - - const policy = new Goal(user, goal); - - if (!policy.canView()) { - res.sendStatus(401); - return; - } - - const gId = parseInt(goalId, 10); - const rId = parseInt(recipientId, 10); - - const retrievedGoal = await goalByIdAndRecipient(gId, rId); - - if (!retrievedGoal) { - res.sendStatus(404); - return; - } - - res.json(retrievedGoal); - } catch (error) { - await handleErrors(req, res, error, `${logContext}:RETRIEVE_GOAL_BY_ID_AND_RECIPIENT`); - } -} - export async function mergeGoalHandler(req, res) { try { const canMergeGoalsForRecipient = await validateMergeGoalPermissions(req, res); diff --git a/src/routes/goals/handlers.test.js b/src/routes/goals/handlers.test.js index 479de1a48a..196b277a10 100644 --- a/src/routes/goals/handlers.test.js +++ b/src/routes/goals/handlers.test.js @@ -12,7 +12,6 @@ import SCOPES from '../../middleware/scopeConstants'; import { changeGoalStatus, createGoals, - retrieveGoalByIdAndRecipient, retrieveGoalsByIds, deleteGoal, createGoalsForReport, @@ -26,7 +25,6 @@ import { createOrUpdateGoals, destroyGoal, goalByIdWithActivityReportsAndRegions, - goalByIdAndRecipient, createOrUpdateGoalsForActivityReport, goalsByIdsAndActivityReport, mergeGoals, @@ -148,130 +146,6 @@ describe('merge goals', () => { }); }); -describe('retrieve goal', () => { - it('checks permissions', async () => { - const req = { - params: { - goalId: 2, - recipientId: 2, - }, - session: { - userId: 1, - }, - }; - - userById.mockResolvedValueOnce({ - permissions: [], - }); - - goalByIdWithActivityReportsAndRegions.mockResolvedValueOnce({ - objectives: [], - grant: { - regionId: 2, - }, - }); - - await retrieveGoalByIdAndRecipient(req, mockResponse); - - expect(mockResponse.sendStatus).toHaveBeenCalledWith(401); - }); - it('handles success', async () => { - const req = { - params: { - goalId: 2, - recipientId: 2, - }, - session: { - userId: 1, - }, - }; - - userById.mockResolvedValueOnce({ - permissions: [ - { - regionId: 2, - scopeId: SCOPES.READ_REPORTS, - }, - ], - }); - - goalByIdWithActivityReportsAndRegions.mockResolvedValueOnce({ - objectives: [], - grant: { regionId: 2 }, - }); - - goalByIdAndRecipient.mockResolvedValueOnce({}); - await retrieveGoalByIdAndRecipient(req, mockResponse); - - expect(mockResponse.json).toHaveBeenCalledWith({}); - }); - - it('handles not found', async () => { - const req = { - params: { - goalId: 2, - recipientId: 2, - }, - session: { - userId: 1, - }, - }; - - userById.mockResolvedValueOnce({ - permissions: [ - { - regionId: 2, - scopeId: SCOPES.READ_REPORTS, - }, - ], - }); - - goalByIdWithActivityReportsAndRegions.mockResolvedValueOnce({ - objectives: [], - grant: { regionId: 2 }, - }); - - goalByIdAndRecipient.mockResolvedValueOnce(null); - await retrieveGoalByIdAndRecipient(req, mockResponse); - - expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); - }); - - it('handles failures', async () => { - const req = { - params: { - goalId: 2, - recipientId: 2, - }, - session: { - userId: 1, - }, - }; - - userById.mockResolvedValueOnce({ - permissions: [ - { - regionId: 2, - scopeId: SCOPES.READ_REPORTS, - }, - ], - }); - - goalByIdWithActivityReportsAndRegions.mockResolvedValueOnce({ - objectives: [], - grants: [{ regionId: 2 }], - }); - - goalByIdAndRecipient.mockImplementationOnce(() => { - throw new Error(); - }); - - await retrieveGoalByIdAndRecipient(req, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(INTERNAL_SERVER_ERROR); - }); -}); - describe('createGoals', () => { afterAll(async () => { jest.clearAllMocks(); diff --git a/src/routes/goals/index.js b/src/routes/goals/index.js index d07a674889..fe694d4716 100644 --- a/src/routes/goals/index.js +++ b/src/routes/goals/index.js @@ -4,7 +4,6 @@ import { changeGoalStatus, reopenGoal, retrieveGoalsByIds, - retrieveGoalByIdAndRecipient, deleteGoal, mergeGoalHandler, getSimilarGoalsForRecipient, @@ -17,7 +16,6 @@ import { checkRegionIdParam, checkRecipientIdParam } from '../../middleware/chec const router = express.Router(); router.post('/', transactionWrapper(createGoals)); router.get('/', transactionWrapper(retrieveGoalsByIds)); -router.get('/:goalId/recipient/:recipientId', transactionWrapper(retrieveGoalByIdAndRecipient)); router.get( '/recipient/:recipientId/region/:regionId/nudge', checkRegionIdParam, From 00fd48cfed82550b8395231277468eb3c196c72e Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 13 May 2024 12:53:36 -0400 Subject: [PATCH 02/78] Update file refs --- src/goalServices/goalByIdAndRecipient.test.js | 9 +++++---- src/goalServices/goals.alt.test.js | 3 +-- src/routes/recipient/handlers.js | 2 +- src/routes/recipient/handlers.test.js | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/goalServices/goalByIdAndRecipient.test.js b/src/goalServices/goalByIdAndRecipient.test.js index 13c781ea54..6572600e4b 100644 --- a/src/goalServices/goalByIdAndRecipient.test.js +++ b/src/goalServices/goalByIdAndRecipient.test.js @@ -21,7 +21,8 @@ import db, { } from '../models'; import { createReport, destroyReport } from '../testUtils'; import { processObjectiveForResourcesById } from '../services/resource'; -import { goalByIdAndRecipient, saveGoalsForReport, goalsByIdAndRecipient } from './goals'; +import { saveGoalsForReport } from './goals'; +import goalsByIdAndRecipient from './goalsByIdAndRecipient'; import { FILE_STATUSES } from '../constants'; describe('goalById', () => { @@ -249,7 +250,7 @@ describe('goalById', () => { }); it('retrieves a goal with associated data', async () => { - const goal = await goalByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); + const [goal] = await goalsByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); // seems to be something with the aliasing attributes that requires // them to be accessed in this way expect(goal.dataValues.name).toBe('Goal on activity report'); @@ -304,7 +305,7 @@ describe('goalById', () => { }, ], report); - const goal = await goalByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); + const [goal] = await goalsByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); // seems to be something with the aliasing attributes that requires // them to be accessed in this way expect(goal.dataValues.name).toBe('Goal on activity report'); @@ -339,7 +340,7 @@ describe('goalById', () => { }, individualHooks: true, }); - const goal = await goalByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); + const [goal] = await goalsByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); expect(goal.dataValues.name).toBe('Goal on activity report'); expect(goal.objectives.length).toBe(1); expect(goal.grant.id).toBe(grantForReport.id); diff --git a/src/goalServices/goals.alt.test.js b/src/goalServices/goals.alt.test.js index a0b2d8a500..16db2f136b 100644 --- a/src/goalServices/goals.alt.test.js +++ b/src/goalServices/goals.alt.test.js @@ -14,10 +14,9 @@ import db, { User, } from '../models'; import { - reduceObjectives, - reduceObjectivesForActivityReport, createMultiRecipientGoalsFromAdmin, } from './goals'; +import { reduceObjectives, reduceObjectivesForActivityReport } from './reduceGoals'; import { OBJECTIVE_STATUS, AUTOMATIC_CREATION, diff --git a/src/routes/recipient/handlers.js b/src/routes/recipient/handlers.js index 25dbdf61be..f47488cb77 100644 --- a/src/routes/recipient/handlers.js +++ b/src/routes/recipient/handlers.js @@ -6,7 +6,7 @@ import { recipientsByUserId, recipientLeadership, } from '../../services/recipient'; -import { goalsByIdAndRecipient } from '../../goalServices/goals'; +import goalsByIdAndRecipient from '../../goalServices/goalsByIdAndRecipient'; import handleErrors from '../../lib/apiErrorHandler'; import filtersToScopes from '../../scopes'; import Recipient from '../../policies/recipient'; diff --git a/src/routes/recipient/handlers.test.js b/src/routes/recipient/handlers.test.js index e0dfc28695..fedc247947 100644 --- a/src/routes/recipient/handlers.test.js +++ b/src/routes/recipient/handlers.test.js @@ -24,7 +24,7 @@ import { recipientsByUserId, allArUserIdsByRecipientAndRegion, } from '../../services/recipient'; -import { goalsByIdAndRecipient } from '../../goalServices/goals'; +import goalsByIdAndRecipient from '../../goalServices/goalsByIdAndRecipient'; import SCOPES from '../../middleware/scopeConstants'; import { currentUserId } from '../../services/currentUser'; import { userById } from '../../services/users'; From 302ff0a3dce3670890e719023934033c0eafc860 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 14 May 2024 10:26:28 -0400 Subject: [PATCH 03/78] clean up typescript on reduceGoals --- src/goalServices/reduceGoals.ts | 64 +++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index 3b09f7f2b9..87fd470d8d 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -3,8 +3,48 @@ import moment from 'moment'; import { auditLogger } from '../logger'; import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; +interface IPrompt { + dataValues?: { + promptId: number; + }; + responses?: { + response: string[]; + }[]; + promptId?: number; + ordinal: number; + title: string; + prompt: string; + hint: string; + fieldType: string; + options: string; + validations: string; + response: string[]; + reportResponse: string[]; + allGoalsHavePromptResponse: boolean; +} + +interface IAR { + id: number +} + +interface IObjectiveModel { + getDataValue?: (key: string) => number | string | boolean | null; + dataValues?: { + id: number; + value: number; + title: string; + status: string; + otherEntityId: number; + }; + id?: number; + title?: string; + status?: string; + otherEntityId?: number; + activityReports?: IAR[]; +} + // this is the reducer called when not getting objectives for a report, IE, the RTR table -export function reduceObjectives(newObjectives, currentObjectives = []) { +export function reduceObjectives(newObjectives: IObjectiveModel[], currentObjectives = []) { // objectives = accumulator // we pass in the existing objectives as the accumulator const objectivesToSort = newObjectives.reduce((objectives, objective) => { @@ -226,7 +266,11 @@ export function reduceObjectivesForActivityReport(newObjectives, currentObjectiv * @param {Array} promptsToReduce * @returns Array of reduced prompts */ -function reducePrompts(forReport, newPrompts = [], promptsToReduce = []) { +function reducePrompts( + forReport: boolean, + newPrompts: IPrompt[] = [], + promptsToReduce: IPrompt[] = [], +) { return newPrompts ?.reduce((previousPrompts, currentPrompt) => { const promptId = currentPrompt.promptId @@ -275,6 +319,8 @@ function reducePrompts(forReport, newPrompts = [], promptsToReduce = []) { options: currentPrompt.options, validations: currentPrompt.validations, allGoalsHavePromptResponse: false, + response: [], + reportResponse: [], }; if (forReport) { @@ -316,7 +362,7 @@ export function reduceGoals(goals, forReport = false) { : g.name === currentValue.dataValues.name && g.status === currentValue.dataValues.status); - function getGoalCollaboratorDetails(collabType, dataValues) { + function getGoalCollaboratorDetails(collabType: string, dataValues) { // eslint-disable-next-line max-len const collaborator = dataValues.goalCollaborators?.find((gc) => gc.collaboratorType.name === collabType); return { @@ -395,18 +441,22 @@ export function reduceGoals(goals, forReport = false) { })(); let { source } = currentValue.dataValues; - let prompts = reducePrompts( + const prompts = reducePrompts( forReport, currentValue.dataValues.prompts || [], [], ); + let promptsIfNotForReport = {}; + if (!forReport) { source = { [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, }; - prompts = { + promptsIfNotForReport = { [currentValue.grant.numberWithProgramTypes]: prompts, + } as { + [key: string]: IPrompt[]; }; } @@ -427,7 +477,7 @@ export function reduceGoals(goals, forReport = false) { objectives: objectivesReducer( currentValue.objectives, ), - prompts, + prompts: forReport ? prompts : promptsIfNotForReport, isNew: false, endDate, source, @@ -443,7 +493,7 @@ export function reduceGoals(goals, forReport = false) { ]; goal.collaborators = goal.collaborators.filter( - (c) => c.goalCreatorName !== null, + (c: { goalCreatorName: string }) => c.goalCreatorName !== null, ); return [...previousValues, goal]; From c5bcd5e2e51ac18a0c8e91540f2696cebda16cbb Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 14 May 2024 14:13:32 -0400 Subject: [PATCH 04/78] Stash updates --- frontend/src/components/GoalCards/GoalCard.js | 26 +++- .../src/components/GoalForm/ObjectiveForm.js | 28 ++-- frontend/src/components/GoalForm/index.js | 8 +- frontend/src/pages/RecipientRecord/index.js | 10 ++ .../RecipientRecord/pages/ViewGoals/index.js | 137 ++++++++++++++++++ src/goalServices/goalsByIdAndRecipient.ts | 54 ++++--- 6 files changed, 213 insertions(+), 50 deletions(-) create mode 100644 frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js diff --git a/frontend/src/components/GoalCards/GoalCard.js b/frontend/src/components/GoalCards/GoalCard.js index d61d0b55ab..78dbb4bea8 100644 --- a/frontend/src/components/GoalCards/GoalCard.js +++ b/frontend/src/components/GoalCards/GoalCard.js @@ -127,6 +127,7 @@ function GoalCard({ const { setIsAppLoading } = useContext(AppLoadingContext); const editLink = `/recipient-tta-records/${recipientId}/region/${regionId}/goals?id[]=${ids.join(',')}`; + const viewLink = `/recipient-tta-records/${recipientId}/region/${regionId}/goals/view?${ids.map((d) => `id[]=${d}`).join('&')}`; const onUpdateGoalStatus = (newStatus) => { const statusesThatNeedObjectivesFinished = [ @@ -155,20 +156,31 @@ function GoalCard({ }; const contextMenuLabel = `Actions for goal ${id}`; - const menuItems = [ - ...(goalStatus === 'Closed' ? [{ + + const menuItems = []; + + if (goalStatus === 'Closed') { + menuItems.push({ label: 'Reopen', onClick: () => { showReopenGoalModal(id); }, - }] : []), - { - label: goalStatus === 'Closed' ? 'View' : 'Edit', + }); + + menuItems.push({ + label: 'View', + onClick: () => { + history.push(viewLink); + }, + }); + } else { + menuItems.push({ + label: 'Edit', onClick: () => { history.push(editLink); }, - }, - ]; + }); + } const canDeleteQualifiedGoals = (() => { if (isAdmin(user)) { diff --git a/frontend/src/components/GoalForm/ObjectiveForm.js b/frontend/src/components/GoalForm/ObjectiveForm.js index 3c1fd17f4b..841294ee16 100644 --- a/frontend/src/components/GoalForm/ObjectiveForm.js +++ b/frontend/src/components/GoalForm/ObjectiveForm.js @@ -1,6 +1,5 @@ -import React, { useMemo, useContext } from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; -import { REPORT_STATUSES } from '@ttahub/common'; import { Button } from '@trussworks/react-uswds'; import ObjectiveTitle from './ObjectiveTitle'; import { @@ -21,17 +20,12 @@ export default function ObjectiveForm({ userCanEdit, }) { // the parent objective data from props - const { title, status } = objective; - - const isOnReport = useMemo(() => ( - objective.activityReports && objective.activityReports.length > 0 - ), [objective.activityReports]); - - const isOnApprovedReport = useMemo(() => ( - (objective.activityReports && objective.activityReports.some((report) => ( - report.status === REPORT_STATUSES.APPROVED - ))) - ), [objective.activityReports]); + const { + title, + status, + onAR, + onApprovedAR, + } = objective; const { isAppLoading } = useContext(AppLoadingContext); @@ -55,14 +49,14 @@ export default function ObjectiveForm({

Objective summary

- { !isOnReport + { !onAR && userCanEdit && ()}
)} /> + ( + + )} + /> ( diff --git a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js new file mode 100644 index 0000000000..aac0b38d8b --- /dev/null +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js @@ -0,0 +1,137 @@ +import React, { + useEffect, + useState, + useContext, +} from 'react'; +import { DECIMAL_BASE } from '@ttahub/common'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; +import { Link } from 'react-router-dom'; +import { Alert } from '@trussworks/react-uswds'; +import PropTypes from 'prop-types'; +import Container from '../../../../components/Container'; +import { goalsByIdAndRecipient } from '../../../../fetchers/recipient'; +import colors from '../../../../colors'; +import AppLoadingContext from '../../../../AppLoadingContext'; +import UserContext from '../../../../UserContext'; + +export default function ViewGoals({ + recipient, + regionId, +}) { + const possibleGrants = recipient.grants.filter(((g) => g.status === 'Active')); + + const goalDefaults = { + name: '', + endDate: null, + status: 'Draft', + grants: possibleGrants.length === 1 ? [possibleGrants[0]] : [], + objectives: [], + id: 'new', + onApprovedAR: false, + onAR: false, + prompts: {}, + isCurated: false, + source: {}, + createdVia: '', + goalTemplateId: null, + isReopenedGoal: false, + }; + + const [fetchError, setFetchError] = useState(''); + const [fetchAttempted, setFetchAttempted] = useState(false); + const [, setGoal] = useState(goalDefaults); + + const { isAppLoading, setIsAppLoading, setAppLoadingText } = useContext(AppLoadingContext); + const { user } = useContext(UserContext); + + const canView = user.permissions.filter( + (permission) => permission.regionId === parseInt(regionId, DECIMAL_BASE), + ).length > 0; + + // for fetching goal data from api if it exists + useEffect(() => { + async function fetchGoal() { + const url = new URL(window.location); + const params = new URLSearchParams(url.search); + const ids = params.getAll('id[]').map((id) => parseInt(id, DECIMAL_BASE)); + + setFetchAttempted(true); // as to only fetch once + try { + const [fetchedGoal] = await goalsByIdAndRecipient( + ids, recipient.id.toString(), + ); + + // for these, the API sends us back things in a format we expect + setGoal(fetchedGoal); + } catch (err) { + setFetchError(true); + } finally { + setIsAppLoading(false); + } + } + + if (!fetchAttempted && !isAppLoading) { + setAppLoadingText('Loading goal'); + setIsAppLoading(true); + fetchGoal(); + } + }, [fetchAttempted, isAppLoading, recipient.id, setAppLoadingText, setIsAppLoading]); + + if (!canView) { + return ( + + You don't have permission to view this page + + ); + } + + if (fetchError) { + return ( + + There was an error fetching your goal + + ); + } + + return ( + <> + + + + Back to RTTAPA + + +

+ TTA Goals for + {' '} + {recipient.name} + {' '} + - Region + {' '} + {regionId} +

+ + +

Goal summary

+
+ + ); +} + +ViewGoals.propTypes = { + recipient: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + grants: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + numberWithProgramTypes: PropTypes.string, + }), + ), + }).isRequired, + regionId: PropTypes.string.isRequired, +}; diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index d69bfcd651..83efa45b21 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -33,6 +33,7 @@ export const OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR = [ 'status', 'goalId', 'onApprovedAR', + 'onAR', 'rtrOrder', ]; @@ -59,23 +60,22 @@ interface IActivityReportObjectivesFromDB { files: IFileFromDB[]; } -interface IObjectiveFromDB { +interface IObjective { id: number; title: string; status: string; goalId: number; onApprovedAR: boolean; + onAR: boolean; rtrOrder: number; activityReportObjectives: IActivityReportObjectivesFromDB[]; } -interface IReducedObjective { - id: number; - title: string; - status: string; - goalId: number; - onApprovedAR: boolean; - rtrOrder: number; +interface IObjectiveFromDB extends IObjective { + toJSON: () => IObjective; +} + +type IReducedObjective = Omit & { topics: { name: string }[]; @@ -87,9 +87,9 @@ interface IReducedObjective { originalFileName: string; key: string; }[]; -} +}; -interface IGoalForForm { +interface IGoal { id: number; endDate: string; name: string; @@ -144,28 +144,27 @@ interface IGoalForForm { }[]; } -type IReducedGoal = Omit & { +interface IGoalForForm extends IGoal { + toJSON: () => IGoal; +} + +type IGoalWithReducedObjectives = Omit & { isReopenedGoal: boolean; objectives: IReducedObjective[]; }; const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) => ({ attributes: [ - 'id', 'endDate', 'name', 'status', + 'source', + 'onAR', + 'onApprovedAR', + 'id', [sequelize.col('grant.regionId'), 'regionId'], [sequelize.col('grant.recipient.id'), 'recipientId'], - 'goalNumber', - 'createdVia', 'goalTemplateId', - 'source', - [ - 'onAR', - 'onAnyReport', - ], - 'onApprovedAR', [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], 'rtrOrder', ], @@ -225,22 +224,31 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) { model: ActivityReportObjective, as: 'activityReportObjectives', - attributes: [], + attributes: ['id', 'objectiveId'], include: [ { model: Topic, as: 'topics', attributes: ['name'], + through: { + attributes: [], + }, }, { model: Resource, as: 'resources', attributes: ['url', 'title'], + through: { + attributes: [], + }, }, { model: File, as: 'files', attributes: ['originalFileName', 'key'], + through: { + attributes: [], + }, }, ], }, @@ -335,7 +343,7 @@ export default async function goalsByIdAndRecipient(ids: number | number[], reci isReopenedGoal: wasGoalPreviouslyClosed(goal), objectives: goal.objectives .map((objective) => ({ - ...OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR.map((field) => objective[field]), + ...objective.toJSON(), topics: extractObjectiveAssociationsFromActivityReportObjectives( objective.activityReportObjectives, 'topics', @@ -349,7 +357,7 @@ export default async function goalsByIdAndRecipient(ids: number | number[], reci 'files', ), } as unknown as IReducedObjective)), // Convert to 'unknown' first - })) as IReducedGoal[]; + })) as IGoalWithReducedObjectives[]; return reduceGoals(reformattedGoals); } From b81a45c951822da2225fad3974d1a67d197821ea Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 14 May 2024 15:49:36 -0400 Subject: [PATCH 05/78] More updated designs, fix of existing issues --- frontend/src/components/GoalCards/GoalCard.js | 200 +++++++++--------- frontend/src/components/GoalForm/Form.js | 30 +-- .../src/components/GoalForm/GoalFormTitle.js | 21 ++ .../src/components/GoalForm/ObjectiveForm.js | 36 ++-- .../src/components/GoalForm/ObjectiveTitle.js | 47 ++-- .../components/GoalForm/__tests__/index.js | 10 +- frontend/src/components/ReadOnlyField.js | 4 + .../components/ReadOnlyGoalCollaborators.js | 37 ++++ .../src/components/__tests__/ReadOnlyField.js | 24 +++ .../RecipientRecord/pages/ViewGoals/index.js | 116 +++++++++- src/goalServices/goalsByIdAndRecipient.ts | 21 +- src/routes/activityReports/handlers.js | 2 + 12 files changed, 366 insertions(+), 182 deletions(-) create mode 100644 frontend/src/components/GoalForm/GoalFormTitle.js create mode 100644 frontend/src/components/ReadOnlyGoalCollaborators.js create mode 100644 frontend/src/components/__tests__/ReadOnlyField.js diff --git a/frontend/src/components/GoalCards/GoalCard.js b/frontend/src/components/GoalCards/GoalCard.js index c74ea80e7f..b634e181bb 100644 --- a/frontend/src/components/GoalCards/GoalCard.js +++ b/frontend/src/components/GoalCards/GoalCard.js @@ -211,18 +211,19 @@ export default function GoalCard({ } }, }); + } - const internalLeftMargin = hideCheckbox ? '' : 'desktop:margin-left-5'; - const border = erroneouslySelected || deleteError ? 'smart-hub-border-base-error' : 'smart-hub-border-base-lighter'; + const internalLeftMargin = hideCheckbox ? '' : 'desktop:margin-left-5'; + const border = erroneouslySelected || deleteError ? 'smart-hub-border-base-error' : 'smart-hub-border-base-lighter'; - return ( -
-
-
- { !hideCheckbox && ( + return ( +
+
+
+ { !hideCheckbox && ( - )} - -
- { !hideGoalOptions && ( + )} + +
+ { !hideGoalOptions && ( - )} -
- -
-
-

- Goal - {' '} - {goalNumbers} - {isMerged && ( + )} +

+ +
+
+

+ Goal + {' '} + {goalNumbers} + {isMerged && ( Merged - )} -

-

- {goalText} - {' '} - -

-
-
-

Goal source

-

{goal.source}

-
-
-

Created on

-

{moment(createdOn, 'YYYY-MM-DD').format(DATE_DISPLAY_FORMAT)}

-
-
-

Last TTA

-

{lastTTA}

-
-
-

Entered by

- {collaborators.map((c) => { - if (!c.goalCreatorName) return null; + )} + +

+ {goalText} + {' '} + +

+
+
+

Goal source

+

{goal.source}

+
+
+

Created on

+

{moment(createdOn, 'YYYY-MM-DD').format(DATE_DISPLAY_FORMAT)}

+
+
+

Last TTA

+

{lastTTA}

+
+
+

Entered by

+ {collaborators.map((c) => { + if (!c.goalCreatorName) return null; - return ( -

- {collaborators.length > 1 && ( + return ( +

+ {collaborators.length > 1 && ( <> {c.goalNumber} {' '} - )} - -

- ); - })} -
+ )} + +

+ ); + })}
+
-
- -
- {sortedObjectives.map((obj) => ( - - ))} +
+ +
+ {sortedObjectives.map((obj) => ( + + ))} -
- ); - } + + ); } GoalCard.propTypes = { diff --git a/frontend/src/components/GoalForm/Form.js b/frontend/src/components/GoalForm/Form.js index 4c100beb07..c1218e6c99 100644 --- a/frontend/src/components/GoalForm/Form.js +++ b/frontend/src/components/GoalForm/Form.js @@ -3,7 +3,7 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { uniq } from 'lodash'; import { - Alert, FormGroup, + Alert, } from '@trussworks/react-uswds'; import ObjectiveForm from './ObjectiveForm'; import PlusButton from './PlusButton'; @@ -16,11 +16,12 @@ import { } from './constants'; import AppLoadingContext from '../../AppLoadingContext'; import './Form.scss'; -import ReadOnlyField from '../ReadOnlyField'; import GoalName from './GoalName'; import RTRGoalSource from './RTRGoalSource'; import FormFieldThatIsSometimesReadOnly from './FormFieldThatIsSometimesReadOnly'; import RTRGoalPrompts from './RTRGoalPrompts'; +import ReadOnlyGoalCollaborators from '../ReadOnlyGoalCollaborators'; +import GoalFormTitle from './GoalFormTitle'; export const BEFORE_OBJECTIVES_CREATE_GOAL = 'Enter a goal before adding an objective'; export const BEFORE_OBJECTIVES_SELECT_RECIPIENTS = 'Select a grant number before adding an objective'; @@ -99,14 +100,16 @@ export default function Form({ }; const objectiveErrors = errors[FORM_FIELD_INDEXES.OBJECTIVES]; - const formTitle = goalNumbers && goalNumbers.length ? `Goal ${goalNumbers.join(', ')}${isReopenedGoal ? '-R' : ''}` : 'Recipient TTA goal'; const showAlert = isOnReport && status !== 'Closed'; const notClosedWithEditPermission = (() => (status !== 'Closed' && userCanEdit))(); return (
{ fetchError ? { fetchError } : null}
-

{formTitle}

+ { status.toLowerCase() === 'draft' && ( Draft @@ -129,22 +132,9 @@ export default function Form({

Goal summary

- {collaborators.length > 0 ? collaborators.map((collaborator) => { - const { - goalCreatorName, - goalCreatorRoles, - goalNumber, - } = collaborator; - if (!goalCreatorName) return null; - return ( - - 1 ? ` (${goalNumber})` : ''}`}> - {goalCreatorName} - {goalCreatorRoles ? `, ${goalCreatorRoles}` : ''} - - - ); - }) : null} + { + const formTitle = goalNumbers && goalNumbers.length ? `Goal ${goalNumbers.join(', ')}${isReopenedGoal ? '-R' : ''}` : 'Recipient TTA goal'; + return ( +

{formTitle}

+ ); +}; + +GoalFormTitle.propTypes = { + goalNumbers: PropTypes.arrayOf(PropTypes.string), + isReopenedGoal: PropTypes.bool, +}; + +GoalFormTitle.defaultProps = { + goalNumbers: [], + isReopenedGoal: false, +}; + +export default GoalFormTitle; diff --git a/frontend/src/components/GoalForm/ObjectiveForm.js b/frontend/src/components/GoalForm/ObjectiveForm.js index 841294ee16..6a713a4ae9 100644 --- a/frontend/src/components/GoalForm/ObjectiveForm.js +++ b/frontend/src/components/GoalForm/ObjectiveForm.js @@ -7,6 +7,7 @@ import { OBJECTIVE_ERROR_MESSAGES, } from './constants'; import AppLoadingContext from '../../AppLoadingContext'; +import FormFieldThatIsSometimesReadOnly from './FormFieldThatIsSometimesReadOnly'; const [objectiveTitleError] = OBJECTIVE_ERROR_MESSAGES; @@ -18,13 +19,13 @@ export default function ObjectiveForm({ setObjective, errors, userCanEdit, + goalStatus, }) { // the parent objective data from props const { title, status, onAR, - onApprovedAR, } = objective; const { isAppLoading } = useContext(AppLoadingContext); @@ -53,17 +54,27 @@ export default function ObjectiveForm({ && ()}
- + + +
); } @@ -112,6 +123,7 @@ ObjectiveForm.propTypes = { status: PropTypes.string, }), userCanEdit: PropTypes.bool.isRequired, + goalStatus: PropTypes.string.isRequired, }; ObjectiveForm.defaultProps = { diff --git a/frontend/src/components/GoalForm/ObjectiveTitle.js b/frontend/src/components/GoalForm/ObjectiveTitle.js index 5be4a99702..5de861acc7 100644 --- a/frontend/src/components/GoalForm/ObjectiveTitle.js +++ b/frontend/src/components/GoalForm/ObjectiveTitle.js @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { FormGroup, Label, @@ -7,61 +7,40 @@ import AutomaticResizingTextarea from '../AutomaticResizingTextarea'; export default function ObjectiveTitle({ error, - isOnApprovedReport, - isOnReport, title, onChangeTitle, validateObjectiveTitle, - status, inputName, isLoading, - userCanEdit, }) { - const readOnly = useMemo(() => ( - isOnApprovedReport - || status === 'Complete' - || status === 'Suspended' - || (status === 'Not Started' && isOnReport) - || (status === 'In Progress' && isOnReport) - || !userCanEdit), - [isOnApprovedReport, isOnReport, status, userCanEdit]); - return ( - ); } ObjectiveTitle.propTypes = { error: PropTypes.node.isRequired, - isOnApprovedReport: PropTypes.bool.isRequired, - isOnReport: PropTypes.bool.isRequired, title: PropTypes.string.isRequired, validateObjectiveTitle: PropTypes.func.isRequired, onChangeTitle: PropTypes.func.isRequired, - status: PropTypes.string.isRequired, inputName: PropTypes.string, isLoading: PropTypes.bool, - userCanEdit: PropTypes.bool.isRequired, }; ObjectiveTitle.defaultProps = { diff --git a/frontend/src/components/GoalForm/__tests__/index.js b/frontend/src/components/GoalForm/__tests__/index.js index 476243983a..00e7d7cd55 100644 --- a/frontend/src/components/GoalForm/__tests__/index.js +++ b/frontend/src/components/GoalForm/__tests__/index.js @@ -7,7 +7,7 @@ import { within, waitFor, } from '@testing-library/react'; -import { REPORT_STATUSES, SCOPE_IDS } from '@ttahub/common'; +import { SCOPE_IDS } from '@ttahub/common'; import selectEvent from 'react-select-event'; import fetchMock from 'fetch-mock'; import { Router } from 'react-router'; @@ -722,7 +722,7 @@ describe('create goal', () => { isRttapa: 'No', prompts: [], sources: [], - onAnyReport: true, + onAR: true, onApprovedAR: false, grants: [{ id: 1, @@ -737,11 +737,7 @@ describe('create goal', () => { id: 1238474, title: 'This is an objective', status: 'Not Started', - activityReports: [ - { - status: REPORT_STATUSES.SUBMITTED, - }, - ], + onAR: true, }, ], }]); diff --git a/frontend/src/components/ReadOnlyField.js b/frontend/src/components/ReadOnlyField.js index 0a74ff2f56..02cd662151 100644 --- a/frontend/src/components/ReadOnlyField.js +++ b/frontend/src/components/ReadOnlyField.js @@ -2,6 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; export default function ReadOnlyField({ label, children }) { + if (!children) { + return null; + } + return ( <>

{label}

diff --git a/frontend/src/components/ReadOnlyGoalCollaborators.js b/frontend/src/components/ReadOnlyGoalCollaborators.js new file mode 100644 index 0000000000..04fc4111ca --- /dev/null +++ b/frontend/src/components/ReadOnlyGoalCollaborators.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup } from '@trussworks/react-uswds'; +import ReadOnlyField from './ReadOnlyField'; + +export default function ReadOnlyGoalCollaborators({ collaborators }) { + if (!collaborators.length) return null; + + return collaborators.map((collaborator) => { + const { + goalCreatorName, + goalCreatorRoles, + goalNumber, + } = collaborator; + if (!goalCreatorName) return null; + return ( + + 1 ? ` (${goalNumber})` : ''}`}> + {goalCreatorName} + {goalCreatorRoles ? `, ${goalCreatorRoles}` : ''} + + + ); + }); +} + +ReadOnlyGoalCollaborators.propTypes = { + collaborators: PropTypes.arrayOf(PropTypes.shape({ + goalCreatorName: PropTypes.string, + goalCreatorRoles: PropTypes.string, + goalNumber: PropTypes.number, + })), +}; + +ReadOnlyGoalCollaborators.defaultProps = { + collaborators: [], +}; diff --git a/frontend/src/components/__tests__/ReadOnlyField.js b/frontend/src/components/__tests__/ReadOnlyField.js new file mode 100644 index 0000000000..f77cbf41f1 --- /dev/null +++ b/frontend/src/components/__tests__/ReadOnlyField.js @@ -0,0 +1,24 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ReadOnlyField from '../ReadOnlyField'; + +describe('ReadOnlyField', () => { + const label = 'label'; + const children = 'children'; + + test('renders label', () => { + render({children}); + expect(screen.getByText(label)).toBeDefined(); + }); + + test('renders children', () => { + render({children}); + expect(screen.getByText(children)).toBeDefined(); + }); + + test('does not render children when children is null', () => { + render({null}); + expect(screen.queryByText(children)).toBeNull(); + }); +}); diff --git a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js index aac0b38d8b..f6a5aa0e41 100644 --- a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js @@ -4,6 +4,7 @@ import React, { useContext, } from 'react'; import { DECIMAL_BASE } from '@ttahub/common'; +import { uniq, uniqueId } from 'lodash'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { Link } from 'react-router-dom'; @@ -14,18 +15,56 @@ import { goalsByIdAndRecipient } from '../../../../fetchers/recipient'; import colors from '../../../../colors'; import AppLoadingContext from '../../../../AppLoadingContext'; import UserContext from '../../../../UserContext'; +import GoalFormTitle from '../../../../components/GoalForm/GoalFormTitle'; +import ReadOnlyGoalCollaborators from '../../../../components/ReadOnlyGoalCollaborators'; +import ReadOnlyField from '../../../../components/ReadOnlyField'; + +export function ResourceLink({ resource }) { + const { url, title } = resource; + const linkText = title || url; + + return ( + {linkText} + ); +} + +export function FileLink({ file }) { + const { url, originalFileName } = file; + + return ( + + ); +} + +FileLink.propTypes = { + file: PropTypes.shape({ + url: PropTypes.shape({ + url: PropTypes.string, + }).isRequired, + originalFileName: PropTypes.string.isRequired, + }).isRequired, +}; + +ResourceLink.propTypes = { + resource: PropTypes.shape({ + url: PropTypes.string.isRequired, + title: PropTypes.string, + }).isRequired, +}; export default function ViewGoals({ recipient, regionId, }) { - const possibleGrants = recipient.grants.filter(((g) => g.status === 'Active')); - const goalDefaults = { name: '', endDate: null, status: 'Draft', - grants: possibleGrants.length === 1 ? [possibleGrants[0]] : [], + grants: [], objectives: [], id: 'new', onApprovedAR: false, @@ -40,11 +79,12 @@ export default function ViewGoals({ const [fetchError, setFetchError] = useState(''); const [fetchAttempted, setFetchAttempted] = useState(false); - const [, setGoal] = useState(goalDefaults); + const [goal, setGoal] = useState(goalDefaults); const { isAppLoading, setIsAppLoading, setAppLoadingText } = useContext(AppLoadingContext); const { user } = useContext(UserContext); + // this is checked on the backend as well but we can save a fetch by checking here const canView = user.permissions.filter( (permission) => permission.regionId === parseInt(regionId, DECIMAL_BASE), ).length > 0; @@ -94,6 +134,17 @@ export default function ViewGoals({ ); } + const { + collaborators, + isReopenedGoal, + goalNumbers, + source, + objectives, + endDate, + name: goalName, + grants, + } = goal; + return ( <> @@ -116,7 +167,62 @@ export default function ViewGoals({ -

Goal summary

+
+ +

Goal summary

+ + + {grants + .map((grant) => `${grant.recipient.name} ${grant.numberWithProgramTypes}`) + .join('\n')} + + + {goalName} + + + + {uniq(Object.values(source || {})).join(', ') || ''} + + + + {endDate} + +
+ + {objectives.map((objective) => ( +
+

Objective summary

+ + {objective.title} + + {objective.topics.length > 0 && ( + + {objective.topics.map((topic) => topic.name).join(', ')} + + )} + {objective.courses.length > 0 && ( + + {objective.courses.map((course) => course.name).join(', ')} + + )} + {objective.resources.length > 0 && ( + + {objective.resources.map((resource) => )} + + )} + {objective.files.length > 0 && ( + + {objective.files.map((file) => )} + + )} +
+ ))} +
); diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index 83efa45b21..ca1f700ed5 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -25,6 +25,7 @@ const { UserRole, Role, CollaboratorType, + Course, } = db; export const OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR = [ @@ -100,7 +101,7 @@ interface IGoal { createdVia: string; goalTemplateId: number; source: string; - onAnyReport: boolean; + onAR: boolean; onApprovedAR: boolean; isCurated: boolean; rtrOrder: number; @@ -245,7 +246,15 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) { model: File, as: 'files', - attributes: ['originalFileName', 'key'], + attributes: ['originalFileName', 'key', 'url'], + through: { + attributes: [], + }, + }, + { + model: Course, + as: 'courses', + attributes: ['name'], through: { attributes: [], }, @@ -271,7 +280,7 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) as: 'programs', }, { - attributes: ['id'], + attributes: ['id', 'name'], model: Recipient, as: 'recipient', where: { @@ -329,7 +338,7 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) function extractObjectiveAssociationsFromActivityReportObjectives( activityReportObjectives: IActivityReportObjectivesFromDB[], - associationName: 'topics' | 'resources' | 'files', + associationName: 'topics' | 'resources' | 'files' | 'courses', ) { return activityReportObjectives.map((aro) => aro[associationName].map((a: ITopicFromDB | IResourceFromDB | IFileFromDB) => a.toJSON())).flat(); @@ -348,6 +357,10 @@ export default async function goalsByIdAndRecipient(ids: number | number[], reci objective.activityReportObjectives, 'topics', ), + courses: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'courses', + ), resources: extractObjectiveAssociationsFromActivityReportObjectives( objective.activityReportObjectives, 'resources', diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index 35f66e021e..1e6bef8b55 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -946,6 +946,8 @@ export async function createReport(req, res) { } res.json(report); } catch (error) { + console.log(error); + await handleErrors(req, res, error, logContext); } } From 02307ae20d871ef1bf296dd1fefa32415f309078 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 14 May 2024 15:50:59 -0400 Subject: [PATCH 06/78] Remove console.log --- src/routes/activityReports/handlers.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index 1e6bef8b55..35f66e021e 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -946,8 +946,6 @@ export async function createReport(req, res) { } res.json(report); } catch (error) { - console.log(error); - await handleErrors(req, res, error, logContext); } } From d18a75e5d19d4c8f34cb5bcd8dcccf31b0c5f70e Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 09:50:17 -0400 Subject: [PATCH 07/78] Another layer of "polish" on "view goals" --- .../src/components/GoalForm/GoalFormTitle.js | 2 +- frontend/src/components/ReadOnlyField.js | 11 +++++--- .../components/ReadOnlyGoalCollaborators.js | 2 +- .../RecipientRecord/pages/ViewGoals/index.js | 27 ++++++++++++++++--- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/GoalForm/GoalFormTitle.js b/frontend/src/components/GoalForm/GoalFormTitle.js index 2ce2640480..b2e10cff03 100644 --- a/frontend/src/components/GoalForm/GoalFormTitle.js +++ b/frontend/src/components/GoalForm/GoalFormTitle.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; const GoalFormTitle = ({ goalNumbers, isReopenedGoal }) => { const formTitle = goalNumbers && goalNumbers.length ? `Goal ${goalNumbers.join(', ')}${isReopenedGoal ? '-R' : ''}` : 'Recipient TTA goal'; return ( -

{formTitle}

+

{formTitle}

); }; diff --git a/frontend/src/components/ReadOnlyField.js b/frontend/src/components/ReadOnlyField.js index 02cd662151..4b9cae73de 100644 --- a/frontend/src/components/ReadOnlyField.js +++ b/frontend/src/components/ReadOnlyField.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; export default function ReadOnlyField({ label, children }) { - if (!children) { + if (!children || !label) { return null; } @@ -15,6 +15,11 @@ export default function ReadOnlyField({ label, children }) { } ReadOnlyField.propTypes = { - label: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, + label: PropTypes.string, + children: PropTypes.node, +}; + +ReadOnlyField.defaultProps = { + label: undefined, + children: undefined, }; diff --git a/frontend/src/components/ReadOnlyGoalCollaborators.js b/frontend/src/components/ReadOnlyGoalCollaborators.js index 04fc4111ca..0aac7773de 100644 --- a/frontend/src/components/ReadOnlyGoalCollaborators.js +++ b/frontend/src/components/ReadOnlyGoalCollaborators.js @@ -28,7 +28,7 @@ ReadOnlyGoalCollaborators.propTypes = { collaborators: PropTypes.arrayOf(PropTypes.shape({ goalCreatorName: PropTypes.string, goalCreatorRoles: PropTypes.string, - goalNumber: PropTypes.number, + goalNumber: PropTypes.string, })), }; diff --git a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js index f6a5aa0e41..2005efd8d8 100644 --- a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js @@ -18,6 +18,7 @@ import UserContext from '../../../../UserContext'; import GoalFormTitle from '../../../../components/GoalForm/GoalFormTitle'; import ReadOnlyGoalCollaborators from '../../../../components/ReadOnlyGoalCollaborators'; import ReadOnlyField from '../../../../components/ReadOnlyField'; +import RTRGoalPrompts from '../../../../components/GoalForm/RTRGoalPrompts'; export function ResourceLink({ resource }) { const { url, title } = resource; @@ -147,7 +148,6 @@ export default function ViewGoals({ return ( <> - + {}} + validate={() => {}} + errors={{}} + selectedGrants={grants} + isCurated={goal.isCurated} + goalTemplateId={goal.goalTemplateId} + /> + {uniq(Object.values(source || {})).join(', ') || ''} @@ -212,12 +223,22 @@ export default function ViewGoals({ )} {objective.resources.length > 0 && ( - {objective.resources.map((resource) => )} + {objective.resources.map((resource) => ( + + +
+
+ ))}
)} {objective.files.length > 0 && ( - {objective.files.map((file) => )} + {objective.files.map((file) => ( + + +
+
+ ))}
)}
From beb4102df679e1e7dbdc40208056e390f8bc4f04 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 09:58:00 -0400 Subject: [PATCH 08/78] Test for GoalCollaboratorName --- .../__tests__/ReadOnlyGoalCollaborators.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/components/__tests__/ReadOnlyGoalCollaborators.js diff --git a/frontend/src/components/__tests__/ReadOnlyGoalCollaborators.js b/frontend/src/components/__tests__/ReadOnlyGoalCollaborators.js new file mode 100644 index 0000000000..6642c81388 --- /dev/null +++ b/frontend/src/components/__tests__/ReadOnlyGoalCollaborators.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ReadOnlyGoalCollaborators from '../ReadOnlyGoalCollaborators'; + +describe('ReadOnlyGoalCollaborators', () => { + test('renders nothing when collaborators array is empty', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('renders goal collaborators correctly', () => { + const collaborators = [ + { + goalCreatorName: 'John Doe', + goalCreatorRoles: 'Role 1, Role 2', + goalNumber: '1', + }, + { + goalCreatorName: 'Jane Smith', + goalCreatorRoles: 'Role 3', + goalNumber: '2', + }, + ]; + + const { getByText } = render(); + + expect(getByText('Entered by (1)')).toBeInTheDocument(); + expect(getByText(/John Doe/i)).toBeInTheDocument(); + expect(getByText(/Role 1, Role 2/i)).toBeInTheDocument(); + + expect(getByText('Entered by (2)')).toBeInTheDocument(); + expect(getByText(/Jane Smith/i)).toBeInTheDocument(); + expect(getByText(/Role 3/i)).toBeInTheDocument(); + }); + + test('renders null when goalCreatorName is missing', () => { + const collaborators = [ + { + goalCreatorRoles: 'Role 1', + goalNumber: '1', + }, + ]; + + const { queryByText } = render(); + + expect(queryByText(/Entered by (1)/i)).not.toBeInTheDocument(); + }); +}); From a892e24a09d3621dbd6479ac36c158aa7661aed4 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 10:01:21 -0400 Subject: [PATCH 09/78] Add test for GoalFormTitle --- .../GoalForm/__tests__/GoalFormTitle.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/src/components/GoalForm/__tests__/GoalFormTitle.js diff --git a/frontend/src/components/GoalForm/__tests__/GoalFormTitle.js b/frontend/src/components/GoalForm/__tests__/GoalFormTitle.js new file mode 100644 index 0000000000..dd11b65851 --- /dev/null +++ b/frontend/src/components/GoalForm/__tests__/GoalFormTitle.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import GoalFormTitle from '../GoalFormTitle'; + +describe('GoalFormTitle', () => { + test('renders the correct form title when goalNumbers is provided', () => { + const goalNumbers = ['1', '2', '3']; + const isReopenedGoal = false; + render(); + const formTitle = screen.getByText('Goal 1, 2, 3'); + expect(formTitle).toBeInTheDocument(); + }); + + test('renders the correct form title when goalNumbers is empty', () => { + const goalNumbers = []; + const isReopenedGoal = false; + render(); + const formTitle = screen.getByText('Recipient TTA goal'); + expect(formTitle).toBeInTheDocument(); + }); + + test('renders the correct form title when isReopenedGoal is true', () => { + const goalNumbers = ['1', '2', '3']; + const isReopenedGoal = true; + render(); + const formTitle = screen.getByText('Goal 1, 2, 3-R'); + expect(formTitle).toBeInTheDocument(); + }); +}); From d4254664e0d54a773272cbf979073b7943bc7acb Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 10:22:20 -0400 Subject: [PATCH 10/78] Handle no goal returned from fetch --- frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js index 2005efd8d8..47f91fa91f 100644 --- a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js @@ -103,6 +103,11 @@ export default function ViewGoals({ ids, recipient.id.toString(), ); + if (!fetchedGoal) { + setFetchError(true); + return; + } + // for these, the API sends us back things in a format we expect setGoal(fetchedGoal); } catch (err) { From 1c744f3c65af52523eb616b1b067280670c8ee94 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 10:36:32 -0400 Subject: [PATCH 11/78] Add test for new page --- .../pages/ViewGoals/__tests__/index.js | 214 ++++++++++++++++++ .../RecipientRecord/pages/ViewGoals/index.js | 4 +- 2 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/RecipientRecord/pages/ViewGoals/__tests__/index.js diff --git a/frontend/src/pages/RecipientRecord/pages/ViewGoals/__tests__/index.js b/frontend/src/pages/RecipientRecord/pages/ViewGoals/__tests__/index.js new file mode 100644 index 0000000000..1b1c564857 --- /dev/null +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/__tests__/index.js @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SCOPE_IDS } from '@ttahub/common/src/constants'; +import fetchMock from 'fetch-mock'; +import ViewGoals from '..'; +import UserContext from '../../../../../UserContext'; +import AppLoadingContext from '../../../../../AppLoadingContext'; + +const basicGoalResponse = [{ + name: 'A goal', + endDate: '1/1/1915', + status: 'Draft', + grants: [{ + recipient: { + name: 'Recipient 1', + }, + numberWithProgramTypes: '012345 HS', + }], + objectives: [{ + courses: [{ + name: 'Example course', + }], + topics: [{ + name: 'Example topic', + }], + resources: [{ + title: 'Example resource', + url: 'http://exampleresource.com', + }, { + title: '', + url: 'http://exampleresouce2.com', + }], + id: 1, + title: 'objective title', + files: [ + { + originalFileName: 'Example resource.txt', + url: { + url: 'http://examplefile1.com', + }, + }, + ], + }], + id: 'new', + onApprovedAR: false, + onAR: false, + prompts: {}, + isCurated: false, + source: { + grant1: ['special source'], + }, + createdVia: 'rtr', + goalTemplateId: null, + isReopenedGoal: false, +}]; + +describe('ViewGoals', () => { + const recipient = { + id: 1, + name: 'John Doe', + grants: [ + { + id: 1, + numberWithProgramTypes: 'Grant 1', + }, + { + id: 2, + numberWithProgramTypes: 'Grant 2', + }, + ], + }; + const regionId = '1'; + + const DEFAULT_USER = { + id: 1, + permissions: [{ + regionId: 1, + scopeId: SCOPE_IDS.READ_REPORTS, + }], + }; + + const renderViewGoals = (user = DEFAULT_USER) => render( + + + + + + + , + ); + + afterEach(() => fetchMock.restore()); + + test('renders the page heading with recipient name and region id', async () => { + fetchMock.get('/api/recipient/1/goals?goalIds=', [{ + name: '', + endDate: null, + status: 'Draft', + grants: [], + objectives: [], + id: 'new', + onApprovedAR: false, + onAR: false, + prompts: {}, + isCurated: false, + source: {}, + createdVia: '', + goalTemplateId: null, + isReopenedGoal: false, + }]); + act(() => { + renderViewGoals(); + }); + + expect(fetchMock.called()).toBe(true); + const heading = document.querySelector('h1.page-heading'); + expect(heading.textContent).toBe('TTA Goals for John Doe - Region 1'); + }); + + test('handles no goals returned', async () => { + fetchMock.get('/api/recipient/1/goals?goalIds=', []); + act(() => { + renderViewGoals(); + }); + + expect(fetchMock.called()).toBe(true); + expect(await screen.findByText(/there was an error fetching your goal/i)).toBeInTheDocument(); + }); + + test('handles server error', async () => { + fetchMock.get('/api/recipient/1/goals?goalIds=', 500); + act(() => { + renderViewGoals(); + }); + + expect(fetchMock.called()).toBe(true); + expect(await screen.findByText(/there was an error fetching your goal/i)).toBeInTheDocument(); + }); + + test('handles permissions mismatch', async () => { + fetchMock.get('/api/recipient/1/goals?goalIds=', [{ + name: '', + endDate: null, + status: 'Draft', + grants: [], + objectives: [], + id: 'new', + onApprovedAR: false, + onAR: false, + prompts: {}, + isCurated: false, + source: {}, + createdVia: '', + goalTemplateId: null, + isReopenedGoal: false, + }]); + act(() => { + renderViewGoals({ id: 1, permissions: [] }); + }); + + expect(fetchMock.called()).toBe(false); + expect(await screen.findByText(/You don't have permission to view this page/i)).toBeInTheDocument(); + }); + + test('renders the back to RTTAPA link', async () => { + fetchMock.get('/api/recipient/1/goals?goalIds=', [{ + name: '', + endDate: null, + status: 'Draft', + grants: [], + objectives: [], + id: 'new', + onApprovedAR: false, + onAR: false, + prompts: {}, + isCurated: false, + source: {}, + createdVia: '', + goalTemplateId: null, + isReopenedGoal: false, + }]); + act(() => { + renderViewGoals(); + }); + + const link = await screen.findByRole('link', { name: /Back to RTTAPA/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/recipient-tta-records/1/region/1/rttapa/'); + }); + + it('renders the goal data', async () => { + fetchMock.get('/api/recipient/1/goals?goalIds=', basicGoalResponse); + + act(() => { + renderViewGoals(); + }); + + expect(fetchMock.called()).toBe(true); + + // assert goal data + expect(await screen.findByText('A goal')).toBeInTheDocument(); + expect(await screen.findByText('1/1/1915')).toBeInTheDocument(); + expect(await screen.findByText('Example course')).toBeInTheDocument(); + expect(await screen.findByText('Example topic')).toBeInTheDocument(); + expect(await screen.findByText('Example resource')).toBeInTheDocument(); + expect(await screen.findByText('Example resource.txt')).toBeInTheDocument(); + expect(await screen.findByText('special source')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js index 47f91fa91f..ac3a72d6c9 100644 --- a/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js @@ -117,12 +117,12 @@ export default function ViewGoals({ } } - if (!fetchAttempted && !isAppLoading) { + if (!fetchAttempted && !isAppLoading && canView) { setAppLoadingText('Loading goal'); setIsAppLoading(true); fetchGoal(); } - }, [fetchAttempted, isAppLoading, recipient.id, setAppLoadingText, setIsAppLoading]); + }, [fetchAttempted, isAppLoading, recipient.id, setAppLoadingText, setIsAppLoading, canView]); if (!canView) { return ( From 385280d28c9f1788c761b4dc65fa40a0daf43e53 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 11:10:14 -0400 Subject: [PATCH 12/78] Hopefully fix API tests --- tests/api/goals.spec.ts | 64 ------------------------------------- tests/api/recipient.spec.ts | 6 ++-- 2 files changed, 4 insertions(+), 66 deletions(-) diff --git a/tests/api/goals.spec.ts b/tests/api/goals.spec.ts index 47ecb4d692..cd7b161ce2 100644 --- a/tests/api/goals.spec.ts +++ b/tests/api/goals.spec.ts @@ -80,70 +80,6 @@ test('get /goals?goalIds[]=&reportId', async ({ request }) => { await validateSchema(response, schema, expect); }); -test('get /goals/:goalId/recipient/:recipientId', async ({ request }) => { - const response = await request.get( - `${root}/goals/4/recipient/2`, - { headers: { 'playwright-user-id': '1' } }, - ); - - expect(response.status()).toBe(200); - - const recipientSchema = Joi.object({ - id: Joi.number() - }); - - const programSchema = Joi.object({ - programType: Joi.string() - }); - - const grantSchema = Joi.object({ - numberWithProgramTypes: Joi.string(), - id: Joi.number(), - number: Joi.string(), - regionId: Joi.number(), - recipientId: Joi.number(), - recipient: recipientSchema, - programs: Joi.array().items(programSchema) - }); - - const schema = Joi.object({ - goalTemplateId: Joi.number().allow(null), - endDate: Joi.string().allow(null), - goalNumber: Joi.string(), - id: Joi.number(), - name: Joi.string(), - status: Joi.string(), - regionId: Joi.number(), - recipientId: Joi.number(), - createdVia: Joi.allow(null), - isRttapa: Joi.allow(null), - onAnyReport: Joi.boolean(), - onApprovedAR: Joi.boolean(), - rtrOrder: Joi.number(), - isCurated: Joi.boolean(), - objectives: Joi.array(), - grant: grantSchema, - source: Joi.string().allow(null), - prompts: Joi.array().items( - Joi.object({ - id: Joi.number(), - title: Joi.string(), - response: Joi.array().items( - Joi.string() - ), - prompt: Joi.string(), - }), - ), - 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); -}); - test('put /goals/changeStatus', async ({ request }) => { const response = await request.put( `${root}/goals/changeStatus`, diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts index 715758946e..1bdfdb01c1 100644 --- a/tests/api/recipient.spec.ts +++ b/tests/api/recipient.spec.ts @@ -205,7 +205,8 @@ test.describe('get /recipient', () => { regionId: Joi.number(), recipientId: Joi.number(), recipient: Joi.object({ - id: Joi.number() + id: Joi.number(), + name: Joi.string(), }), programs: Joi.array().items( Joi.object({ @@ -222,7 +223,8 @@ test.describe('get /recipient', () => { regionId: Joi.number(), recipientId: Joi.number(), recipient: Joi.object({ - id: Joi.number() + id: Joi.number(), + name: Joi.number }), programs: Joi.array().items( Joi.object({ From 606ec9fc07f99172528ab9b1ded906e2d421086f Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 11:14:17 -0400 Subject: [PATCH 13/78] Hopefully update e2e tests --- tests/e2e/activity-report.spec.ts | 46 ------------------------------ tests/e2e/recipient-record.spec.ts | 29 +------------------ 2 files changed, 1 insertion(+), 74 deletions(-) diff --git a/tests/e2e/activity-report.spec.ts b/tests/e2e/activity-report.spec.ts index 95db8202bf..2bf6cba9f1 100644 --- a/tests/e2e/activity-report.spec.ts +++ b/tests/e2e/activity-report.spec.ts @@ -447,25 +447,6 @@ test.describe('Activity Report', () => { await expect(page.getByText('g1', { exact: true })).toBeVisible(); await expect(page.getByText('g1o1')).toBeVisible(); - const topic = page.locator('#main-content > div > div > form > div.ttahub-create-goals-form > div.margin-top-5.ttahub-create-goals-objective-form > ul:nth-child(4) > li'); - expect(await topic.textContent()).toBe('Behavioral / Mental Health / Trauma'); - await expect(page.getByRole('link', { name: 'https://banana.banana.com' })).toBeVisible(); - await expect(page.getByRole('group', { name: 'Do you plan to use any TTA resources that aren\'t available as a link? * Examples include: Presentation slides from PD events PDF\'s you created from multiple TTA resources Other OHS-provided resources' }).getByLabel('No')).toBeChecked(); - - let objectiveStatus = page.getByRole('combobox', { name: 'Objective status' }); - - // verify the correct value is selected in the Objective status dropdown - expect(await extractSelectedDisplayedValue(objectiveStatus)).toBe('Not Started'); - // Change g1o1's status - await objectiveStatus.click(); - await objectiveStatus.selectOption({ label: 'In Progress' }); - await page.getByRole('button', { name: 'Save' }).click(); - - // expand the objective for g1 - await page.getByRole('button', { name: `View objectives for goal ${g1GoalsForObjectives}` }).click(); - // verify the 'In Progress' status is now visible - await expect(page.getByRole('listitem').filter({ hasText: 'Objective status In progress' })).toBeVisible(); - // Check g2 await page.getByText('g2', { exact: true }).locator('..').locator('..').locator('..') .getByRole('button', { name: 'Actions for goal' }) @@ -478,25 +459,6 @@ test.describe('Activity Report', () => { await expect(page.getByText("This goal is used on an activity report, so some fields can't be edited.")).toBeVisible(); await expect(page.getByText('g2', { exact: true })).toBeVisible(); await expect(page.getByText('g2o1')).toBeVisible(); - await expect(page.getByRole('listitem').filter({ hasText: 'Behavioral / Mental Health / Trauma' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'https://banana.banana.com' })).not.toBeVisible(); - await expect(page.getByRole('group', { name: 'Do you plan to use any TTA resources that aren\'t available as a link? * Examples include: Presentation slides from PD events PDF\'s you created from multiple TTA resources Other OHS-provided resources' }).getByLabel('No')).toBeChecked(); - objectiveStatus = page.getByRole('combobox', { name: 'Objective status' }); - expect(await extractSelectedDisplayedValue(objectiveStatus)).toBe('Not Started'); - - await objectiveStatus.click(); - await objectiveStatus.selectOption({ label: 'Complete' }); - // Instead of saving, cancel out of the 'Edit' form - await page.getByRole('link', { name: 'Cancel' }).click(); - - // expand the objective for g2 - await page.getByRole('button', { name: `View objectives for goal ${g2GoalsForObjectives}` }).click(); - // follow the AR link for g2 - await page.getByText('g2', { exact: true }).locator('..').locator('..').locator('..') - .getByRole('link', { name: `R0${regionNumber}-AR-${arNumber}` }) - .click(); - // verify the link works by checking whether the recipients are visible - await expect(page.getByText(`${recipients}`)).toBeVisible(); }); test('multi recipient goal used on an AR', async ({ page }) => { @@ -529,14 +491,6 @@ test.describe('Activity Report', () => { // objective title await page.getByLabel('TTA objective *').fill('A new objective'); - // objective topic - await page.getByLabel(/topics/i).focus(); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - - // support type - await page.getByRole('combobox', { name: /Support type/i }).selectOption('Implementing'); - // save goal await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Submit goal' }).click(); diff --git a/tests/e2e/recipient-record.spec.ts b/tests/e2e/recipient-record.spec.ts index 2cfa31ae3a..2ac67d7e0f 100644 --- a/tests/e2e/recipient-record.spec.ts +++ b/tests/e2e/recipient-record.spec.ts @@ -37,27 +37,7 @@ test.describe('Recipient record', () => { await page.getByRole('button', { name: 'Add new objective' }).click(); await page.getByLabel('TTA objective *').fill('A new objective'); - // try it with an invalid URL - await page.getByTestId('textInput').fill('FISH BANANA GARBAGE MAN'); - await page.getByRole('button', { name: 'Save draft' }).click(); - await expect(page.getByText('Enter one resource per field. Valid resource links must start with http:// or https://')).toBeVisible(); - - await page.getByTestId('textInput').fill('HTTP:// FISH BANANA GARBAGE MAN'); - await page.getByRole('button', { name: 'Save draft' }).click(); - await expect(page.getByText('Enter one resource per field. Valid resource links must start with http:// or https://')).toBeVisible(); - - await page.getByTestId('textInput').fill('http://www.fish-banana-garbage-man.com'); - await page.getByRole('button', { name: 'Save draft' }).click(); - await page.getByRole('button', { name: 'Save and continue' }).click(); - await page.getByLabel(/topics/i).focus(); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - - const supportType = page.getByRole('combobox', { name: /Support type/i }); - await supportType.selectOption('Implementing'); - - // first click blurs + // first click blurs await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Submit goal' }).click(); @@ -99,13 +79,6 @@ test.describe('Recipient record', () => { await page.getByLabel('TTA objective *').fill('A new objective for this second goal'); await page.getByRole('button', { name: 'Save draft' }).click(); await page.getByRole('button', { name: 'Save and continue' }).click(); - await page.getByLabel(/topics/i).focus(); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - - const supportType = page.getByRole('combobox', { name: /Support type/i }); - await supportType.selectOption('Implementing'); // first click blurs await page.getByRole('button', { name: 'Save and continue' }).click(); From dbd99f7a80cbb21aba3d6a3537e1ebcd976e094e Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 11:31:50 -0400 Subject: [PATCH 14/78] Just a typo --- tests/api/recipient.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts index 1bdfdb01c1..fafe0d5e27 100644 --- a/tests/api/recipient.spec.ts +++ b/tests/api/recipient.spec.ts @@ -224,7 +224,7 @@ test.describe('get /recipient', () => { recipientId: Joi.number(), recipient: Joi.object({ id: Joi.number(), - name: Joi.number + name: Joi.string(), }), programs: Joi.array().items( Joi.object({ From 49ffef229cab05963c73ad9487f6ed6eb587f6be Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 11:40:01 -0400 Subject: [PATCH 15/78] add onAR to api validation --- tests/api/recipient.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts index fafe0d5e27..fabfa10f54 100644 --- a/tests/api/recipient.spec.ts +++ b/tests/api/recipient.spec.ts @@ -194,7 +194,7 @@ test.describe('get /recipient', () => { recipientId: Joi.number(), createdVia: Joi.any().allow(null), isRttapa: Joi.any().allow(null), - onAnyReport: Joi.boolean(), + onAR: Joi.boolean(), onApprovedAR: Joi.boolean(), rtrOrder: Joi.number(), objectives: Joi.array().items(), From ffce029e3f46a25dbee0203b01ddd09a92f7b528 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 12:38:36 -0400 Subject: [PATCH 16/78] Update tests --- src/goalServices/createOrUpdateGoals.test.js | 270 ------------- src/goalServices/goalByIdAndRecipient.test.js | 375 ------------------ src/goalServices/goalsByIdAndRecipient.ts | 1 + 3 files changed, 1 insertion(+), 645 deletions(-) delete mode 100644 src/goalServices/goalByIdAndRecipient.test.js diff --git a/src/goalServices/createOrUpdateGoals.test.js b/src/goalServices/createOrUpdateGoals.test.js index a992e9971f..fc9ddd4635 100644 --- a/src/goalServices/createOrUpdateGoals.test.js +++ b/src/goalServices/createOrUpdateGoals.test.js @@ -293,274 +293,4 @@ describe('createOrUpdateGoals', () => { expect(newGoal.grant.id).toBe(grants[1].id); expect(newGoal.grant.regionId).toBe(1); }); - - it('you can change an objectives status', async () => { - const basicGoal = { - recipientId: recipient.id, - regionId: 1, - name: 'This is some serious goal text for an objective that will have its status updated', - status: 'Draft', - }; - - const updatedGoals = await createOrUpdateGoals([ - { - ...basicGoal, - isNew: true, - grantId: grants[1].id, - objectives: [ - { - id: 'new-0', - status: 'Not Started', - title: 'This is an objective', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals).toHaveLength(1); - const [updatedGoal] = updatedGoals; - expect(updatedGoal.objectives).toHaveLength(1); - const [updatedObjective] = updatedGoal.objectives; - expect(updatedObjective.status).toBe('Not Started'); - - const updatedGoals2 = await createOrUpdateGoals([ - { - ...updatedGoal.dataValues, - recipientId: recipient.id, - grantId: grants[1].id, - ids: [updatedGoal.id], - objectives: [ - { - title: updatedObjective.title, - id: [updatedObjective.id], - status: 'Complete', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals2).toHaveLength(1); - const [updatedGoal2] = updatedGoals2; - expect(updatedGoal2.objectives).toHaveLength(1); - const [updatedObjective2] = updatedGoal2.objectives; - expect(updatedObjective2.status).toBe('Complete'); - }); - - it('you can change an objectives status For objective on approved AR', async () => { - const basicGoal = { - recipientId: recipient.id, - regionId: 1, - name: 'This is some serious goal text for an objective that will have its status updated, but different', - status: 'Draft', - }; - - const updatedGoals = await createOrUpdateGoals([ - { - ...basicGoal, - isNew: true, - grantId: grants[1].id, - objectives: [ - { - id: 'new-0', - status: 'Not Started', - title: 'This is a different objective ', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals).toHaveLength(1); - const [updatedGoal] = updatedGoals; - expect(updatedGoal.objectives).toHaveLength(1); - const [updatedObjective] = updatedGoal.objectives; - expect(updatedObjective.status).toBe('Not Started'); - - await Objective.update({ - title: 'This is a different objective ', - }, { where: { id: updatedObjective.id } }); - - const updatedGoals2 = await createOrUpdateGoals([ - { - ...updatedGoal.dataValues, - recipientId: recipient.id, - grantId: grants[1].id, - ids: [updatedGoal.id], - objectives: [ - { - title: updatedObjective.title, - id: [updatedObjective.id], - status: 'In Progress', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals2).toHaveLength(1); - const [updatedGoal2] = updatedGoals2; - expect(updatedGoal2.objectives).toHaveLength(1); - const [updatedObjective2] = updatedGoal2.objectives; - expect(updatedObjective2.status).toBe('In Progress'); - - await Objective.update({ - onAR: true, - onApprovedAR: true, - title: 'This is a different objective ', - }, { where: { id: updatedObjective.id } }); - - const updatedGoals3 = await createOrUpdateGoals([ - { - ...updatedGoal.dataValues, - recipientId: recipient.id, - grantId: grants[1].id, - ids: [updatedGoal.id], - objectives: [ - { - title: updatedObjective.title, - id: [updatedObjective.id], - status: 'Complete', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals3).toHaveLength(1); - const [updatedGoal3] = updatedGoals3; - expect(updatedGoal3.objectives).toHaveLength(1); - const [updatedObjective3] = updatedGoal3.objectives; - expect(updatedObjective3.status).toBe('Complete'); - }); - - it('you can change an objectives status to suspended and collect a reason', async () => { - const basicGoal = { - recipientId: recipient.id, - regionId: 1, - name: 'This is some serious goal text for an objective that will have its status updated, but different', - status: 'Draft', - }; - - const updatedGoals = await createOrUpdateGoals([ - { - ...basicGoal, - isNew: true, - grantId: grants[1].id, - objectives: [ - { - id: 'new-0', - status: 'Not Started', - title: 'This is a different objective ', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals).toHaveLength(1); - const [updatedGoal] = updatedGoals; - expect(updatedGoal.objectives).toHaveLength(1); - const [updatedObjective] = updatedGoal.objectives; - expect(updatedObjective.status).toBe('Not Started'); - - const updatedGoals2 = await createOrUpdateGoals([ - { - ...updatedGoal.dataValues, - recipientId: recipient.id, - grantId: grants[1].id, - ids: [updatedGoal.id], - objectives: [ - { - title: updatedObjective.title, - id: [updatedObjective.id], - status: OBJECTIVE_STATUS.SUSPENDED, - supportType: 'Maintaining', - closeSuspendReason: 'Recipient request', - closeSuspendContext: 'Yeah, they just asked', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], - }, - ], - }, - ]); - - expect(updatedGoals2).toHaveLength(1); - const [updatedGoal2] = updatedGoals2; - expect(updatedGoal2.objectives).toHaveLength(1); - const [updatedObjective2] = updatedGoal2.objectives; - expect(updatedObjective2.id).toBe(updatedObjective.id); - expect(updatedObjective2.status).toBe(OBJECTIVE_STATUS.SUSPENDED); - expect(updatedObjective2.closeSuspendReason).toBe('Recipient request'); - expect(updatedObjective2.closeSuspendContext).toBe('Yeah, they just asked'); - }); }); diff --git a/src/goalServices/goalByIdAndRecipient.test.js b/src/goalServices/goalByIdAndRecipient.test.js deleted file mode 100644 index 6572600e4b..0000000000 --- a/src/goalServices/goalByIdAndRecipient.test.js +++ /dev/null @@ -1,375 +0,0 @@ -import { Op } from 'sequelize'; -import { REPORT_STATUSES } from '@ttahub/common'; -import faker from '@faker-js/faker'; -import db, { - Recipient, - Grant, - Goal, - ActivityReportObjective, - ActivityReportGoal, - Objective, - Topic, - ObjectiveTopic, - ObjectiveResource, - ObjectiveFile, - ActivityReport, - ActivityReportObjectiveFile, - ActivityReportObjectiveResource, - ActivityReportObjectiveTopic, - File, - Resource, -} from '../models'; -import { createReport, destroyReport } from '../testUtils'; -import { processObjectiveForResourcesById } from '../services/resource'; -import { saveGoalsForReport } from './goals'; -import goalsByIdAndRecipient from './goalsByIdAndRecipient'; -import { FILE_STATUSES } from '../constants'; - -describe('goalById', () => { - let grantRecipient; - let grantForReport; - let report; - let goalOnActivityReport; - let otherGoal; - let objective; - let file; - let file2; - let topic; - let topic2; - - beforeAll(async () => { - grantRecipient = await Recipient.create({ - id: faker.datatype.number({ min: 64000 }), - name: faker.random.alphaNumeric(6), - uei: faker.datatype.string(12), - }); - - grantForReport = await Grant.create({ - number: grantRecipient.id, - recipientId: grantRecipient.id, - programSpecialistName: faker.name.firstName(), - regionId: 1, - id: faker.datatype.number({ min: 64000 }), - startDate: new Date(), - endDate: new Date(), - }); - - goalOnActivityReport = await Goal.create({ - name: 'Goal on activity report', - status: 'In Progress', - timeframe: '12 months', - grantId: grantForReport.id, - isFromSmartsheetTtaPlan: false, - id: faker.datatype.number({ min: 64000 }), - rtrOrder: 1, - }); - - otherGoal = await Goal.create({ - name: 'other goal', - status: 'In Progress', - grantId: grantForReport.id, - isFromSmartsheetTtaPlan: false, - id: faker.datatype.number({ min: 64000 }), - rtrOrder: 2, - }); - - objective = await Objective.create({ - goalId: goalOnActivityReport.id, - title: 'objective test', - status: 'Not Started', - }); - - topic = await Topic.findOne(); - topic2 = await Topic.findOne({ - where: { - id: { - [Op.notIn]: [topic.id], - }, - }, - }); - - await ObjectiveTopic.create({ - topicId: topic.id, - objectiveId: objective.id, - }); - - await ObjectiveTopic.create({ - topicId: topic2.id, - objectiveId: objective.id, - }); - - await processObjectiveForResourcesById(objective.id, ['http://www.google.com', 'http://www.google1.com']); - - file = await File.create({ - originalFileName: 'gibbery-pibbery.txt', - key: 'gibbery-pibbery.key', - status: FILE_STATUSES.UPLOADED, - fileSize: 1234, - }); - - file2 = await File.create({ - originalFileName: 'gibbery-pibbery2.txt', - key: 'gibbery-pibbery2.key', - status: FILE_STATUSES.UPLOADED, - fileSize: 1234, - }); - - await ObjectiveFile.create({ - objectiveId: objective.id, - fileId: file.id, - }); - - await ObjectiveFile.create({ - objectiveId: objective.id, - fileId: file2.id, - }); - - report = await createReport({ - regionId: 1, - activityRecipients: [ - { grantId: grantForReport.id }, - ], - calculatedStatus: REPORT_STATUSES.SUBMITTED, - submittedStatus: REPORT_STATUSES.SUBMITTED, - }); - }); - - afterAll(async () => { - const aro = await ActivityReportObjective.findAll({ - where: { - activityReportId: report.id, - }, - }); - - const aroIds = aro.map((a) => a.id); - - await ActivityReportObjectiveTopic.destroy({ - where: { - activityReportObjectiveId: aroIds, - }, - hookMetadata: { objectiveId: objective.id }, - individualHooks: true, - }); - - await ActivityReportObjectiveResource.destroy({ - where: { - activityReportObjectiveId: aroIds, - }, - hookMetadata: { objectiveId: objective.id }, - individualHooks: true, - }); - - await ActivityReportObjectiveFile.destroy({ - where: { - activityReportObjectiveId: aroIds, - }, - hookMetadata: { objectiveId: objective.id }, - individualHooks: true, - }); - - await ActivityReportObjective.destroy({ - where: { - id: aroIds, - }, - individualHooks: true, - }); - - await ObjectiveTopic.destroy({ - where: { - objectiveId: objective.id, - }, - individualHooks: true, - }); - - await ObjectiveFile.destroy({ - where: { - objectiveId: objective.id, - }, - individualHooks: true, - }); - - await File.destroy({ - where: { - id: [file.id, file2.id], - }, - individualHooks: true, - }); - - await ObjectiveResource.destroy({ - where: { - objectiveId: objective.id, - }, - individualHooks: true, - }); - - await Resource.destroy({ - where: { url: ['http://www.google.com', 'http://www.google1.com'] }, - individualHooks: true, - }); - - await Objective.destroy({ - where: { - goalId: goalOnActivityReport.id, - }, - individualHooks: true, - force: true, - }); - - await ActivityReportGoal.destroy({ - where: { - activityReportId: report.id, - }, - individualHooks: true, - }); - - await destroyReport(report); - - await Goal.destroy({ - where: { - id: [goalOnActivityReport.id, otherGoal.id], - }, - individualHooks: true, - force: true, - }); - - await Grant.destroy({ - where: { - id: grantForReport.id, - }, - individualHooks: true, - }); - - await Recipient.destroy({ - where: { - id: grantRecipient.id, - }, - individualHooks: true, - }); - - await db.sequelize.close(); - }); - - it('retrieves a goal with associated data', async () => { - const [goal] = await goalsByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); - // seems to be something with the aliasing attributes that requires - // them to be accessed in this way - expect(goal.dataValues.name).toBe('Goal on activity report'); - expect(goal.objectives.length).toBe(1); - - const [obj] = goal.objectives; - - expect(obj.activityReports.length).toBe(0); - - expect(obj.topics.length).toBe(2); - expect(obj.topics.map((t) => `${t.onAnyReport}`).sort()).toEqual(['false', 'false']); - expect(obj.topics.map((t) => `${t.isOnApprovedReport}`).sort()).toEqual(['false', 'false']); - - expect(obj.resources.length).toBe(2); - expect(obj.resources.map((r) => `${r.onAnyReport}`).sort()).toEqual(['false', 'false']); - expect(obj.resources.map((r) => `${r.isOnApprovedReport}`).sort()).toEqual(['false', 'false']); - - expect(obj.files.length).toBe(2); - expect(obj.files.map((f) => `${f.onAnyReport}`).sort()).toEqual(['false', 'false']); - expect(obj.files.map((r) => `${r.isOnApprovedReport}`).sort()).toEqual(['false', 'false']); - }); - - it('lets us know when the associated data is on an activity report', async () => { - await saveGoalsForReport([ - { - id: goalOnActivityReport.id, - isNew: false, - grantIds: [grantForReport.id], - createdVia: 'rtr', - status: 'Not Started', - name: goalOnActivityReport.name, - goalIds: [goalOnActivityReport.id], - objectives: [ - { - id: objective.id, - isNew: false, - ttaProvided: '

asdfadsfasdlfkm

', - ActivityReportObjective: {}, - title: objective.title, - status: objective.status, - resources: [ - { value: 'http://www.google.com' }, - ], - topics: [ - { id: topic.id }, - ], - files: [ - { id: file.id }, - ], - }, - ], - }, - ], report); - - const [goal] = await goalsByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); - // seems to be something with the aliasing attributes that requires - // them to be accessed in this way - expect(goal.dataValues.name).toBe('Goal on activity report'); - expect(goal.objectives.length).toBe(1); - expect(goal.grant.id).toBe(grantForReport.id); - - const [obj] = goal.objectives; - - expect(obj.activityReports.length).toBe(1); - expect(obj.activityReports[0].id).toBe(report.id); - - expect(obj.topics.length).toBe(2); - expect(obj.topics.map((t) => `${t.onAnyReport}`).sort()).toEqual(['false', 'true']); - expect(obj.topics.map((t) => `${t.isOnApprovedReport}`).sort()).toEqual(['false', 'false']); - - expect(obj.resources.length).toBe(2); - expect(obj.resources.map((r) => `${r.onAnyReport}`).sort()).toEqual(['false', 'true']); - expect(obj.resources.map((r) => `${r.isOnApprovedReport}`).sort()).toEqual(['false', 'false']); - - expect(obj.files.length).toBe(2); - expect(obj.files.map((f) => `${f.onAnyReport}`).sort()).toEqual(['false', 'true']); - expect(obj.files.map((r) => `${r.isOnApprovedReport}`).sort()).toEqual(['false', 'false']); - }); - - it('lets us know when the associated data is on an approved activity report', async () => { - await ActivityReport.update({ - submittedStatus: 'approved', - calculatedStatus: 'approved', - }, { - where: { - id: report.id, - }, - individualHooks: true, - }); - const [goal] = await goalsByIdAndRecipient(goalOnActivityReport.id, grantRecipient.id); - expect(goal.dataValues.name).toBe('Goal on activity report'); - expect(goal.objectives.length).toBe(1); - expect(goal.grant.id).toBe(grantForReport.id); - - const [obj] = goal.objectives; - - expect(obj.activityReports.length).toBe(1); - expect(obj.activityReports[0].id).toBe(report.id); - - expect(obj.topics.length).toBe(2); - expect(obj.topics.map((t) => `${t.onAnyReport}`).sort()).toEqual(['false', 'true']); - expect(obj.topics.map((t) => `${t.isOnApprovedReport}`).sort()).toEqual(['false', 'true']); - - expect(obj.resources.length).toBe(2); - expect(obj.resources.map((r) => `${r.onAnyReport}`).sort()).toEqual(['false', 'true']); - expect(obj.resources.map((r) => `${r.isOnApprovedReport}`).sort()).toEqual(['false', 'true']); - - expect(obj.files.length).toBe(2); - expect(obj.files.map((f) => `${f.onAnyReport}`).sort()).toEqual(['false', 'true']); - expect(obj.files.map((r) => `${r.isOnApprovedReport}`).sort()).toEqual(['false', 'true']); - }); - - it('returns the goals in the correct order', async () => { - const goals = await goalsByIdAndRecipient( - [otherGoal.id, goalOnActivityReport.id], - grantRecipient.id, - ); - expect(goals.length).toBe(2); - expect(goals[0].id).toBe(goalOnActivityReport.id); - expect(goals[1].id).toBe(otherGoal.id); - }); -}); diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index ca1f700ed5..4bc5d14dc2 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -168,6 +168,7 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) 'goalTemplateId', [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], 'rtrOrder', + 'createdVia', ], order: [['rtrOrder', 'asc']], where: { From 067a31c0a157c5d6ab7ac341da135c3401181aa8 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 13:14:31 -0400 Subject: [PATCH 17/78] Update backend tests --- src/goalServices/createOrUpdateGoals.test.js | 82 +------------------- src/routes/goals/handlers.test.js | 2 + src/routes/recipient/handlers.test.js | 4 +- 3 files changed, 4 insertions(+), 84 deletions(-) diff --git a/src/goalServices/createOrUpdateGoals.test.js b/src/goalServices/createOrUpdateGoals.test.js index fc9ddd4635..7748fc4fb3 100644 --- a/src/goalServices/createOrUpdateGoals.test.js +++ b/src/goalServices/createOrUpdateGoals.test.js @@ -1,18 +1,12 @@ /* eslint-disable jest/no-disabled-tests */ -import { Op } from 'sequelize'; import faker from '@faker-js/faker'; import { GOAL_SOURCES } from '@ttahub/common'; -import { OBJECTIVE_STATUS } from '../constants'; import { createOrUpdateGoals } from './goals'; import db, { Goal, Grant, Recipient, - Topic, Objective, - ObjectiveResource, - ObjectiveTopic, - Resource, } from '../models'; import { processObjectiveForResourcesById } from '../services/resource'; @@ -22,7 +16,6 @@ describe('createOrUpdateGoals', () => { }); let goal; - let topic; let objective; let recipient; let newGoals; @@ -58,51 +51,23 @@ describe('createOrUpdateGoals', () => { grantId: grants[0].id, source: GOAL_SOURCES[0], }); - topic = await Topic.findOne({ where: { mapsTo: { [Op.eq]: null } } }); objective = await Objective.create({ goalId: goal.id, title: 'This is some serious goal text', status: 'Not Started', - supportType: 'Maintaining', }); await Objective.create({ goalId: goal.id, title: 'This objective will be deleted', status: 'Not Started', - supportType: 'Maintaining', }); await processObjectiveForResourcesById(objective.id, [fakeUrl]); }); afterAll(async () => { - const urlResource = await Resource.findOne({ - where: { - url: fakeUrl, - }, - }); - - await ObjectiveResource.destroy({ - where: { - resourceId: urlResource.id, - }, - individualHooks: true, - }); - - await Resource.destroy({ - where: { url: fakeUrl }, - individualHooks: true, - }); - - await ObjectiveTopic.destroy({ - where: { - objectiveId: objective.id, - }, - individualHooks: true, - }); - const goals = await Goal.findAll({ where: { grantId: grants.map((g) => g.id), @@ -165,30 +130,12 @@ describe('createOrUpdateGoals', () => { id: objective.id, status: 'Not Started', title: 'This is an objective', - supportType: 'Maintaining', - resources: [ - { - value: fakeUrl, - }, - ], - topics: [ - { - id: topic.id, - }, - ], }, { id: 'new-0', isNew: true, status: 'Not Started', title: 'This is another objective', - supportType: 'Maintaining', - resources: [], - topics: [ - { - id: topic.id, - }, - ], }, ], }, @@ -246,7 +193,7 @@ describe('createOrUpdateGoals', () => { expect(order).toStrictEqual([1, 2]); const objectiveOnTheGoalWithCreatedVias = await Objective.findAll({ - attributes: ['id', 'createdVia', 'supportType'], + attributes: ['id', 'createdVia'], where: { id: objectivesOnUpdatedGoal.map((obj) => obj.id), }, @@ -255,38 +202,11 @@ describe('createOrUpdateGoals', () => { const objectiveCreatedVias = objectiveOnTheGoalWithCreatedVias.map((obj) => obj.createdVia); expect(objectiveCreatedVias).toStrictEqual([null, 'rtr']); - const objectiveSupportTypes = objectiveOnTheGoalWithCreatedVias.map((obj) => obj.supportType); - expect(objectiveSupportTypes).toStrictEqual(['Maintaining', 'Maintaining']); - const objectiveOnUpdatedGoal = await Objective.findByPk(objective.id, { raw: true }); expect(objectiveOnUpdatedGoal.id).toBe(objective.id); expect(objectiveOnUpdatedGoal.title).toBe('This is an objective'); expect(objectiveOnUpdatedGoal.status).toBe(objective.status); - const objectiveTopics = await ObjectiveTopic.findAll({ - where: { - objectiveId: objective.id, - }, - raw: true, - }); - - expect(objectiveTopics.length).toBe(1); - expect(objectiveTopics[0].topicId).toBe(topic.id); - - const resource = await ObjectiveResource.findAll({ - where: { - objectiveId: objective.id, - }, - include: [{ - attributes: ['url'], - model: Resource, - as: 'resource', - }], - }); - - expect(resource.length).toBe(1); - expect(resource[0].resource.dataValues.url).toBe(fakeUrl); - const newGoal = newGoals.find((g) => g.id !== goal.id); expect(newGoal.status).toBe('Draft'); expect(newGoal.name).toBe('This is some serious goal text'); diff --git a/src/routes/goals/handlers.test.js b/src/routes/goals/handlers.test.js index 196b277a10..e037da474e 100644 --- a/src/routes/goals/handlers.test.js +++ b/src/routes/goals/handlers.test.js @@ -185,6 +185,7 @@ describe('createGoals', () => { goals: [{ goalId: 2, recipientId: 2, + regionId: 2, }], }, session: { @@ -213,6 +214,7 @@ describe('createGoals', () => { goals: [{ goalId: 2, recipientId: 2, + regionId: 2, }], }, session: { diff --git a/src/routes/recipient/handlers.test.js b/src/routes/recipient/handlers.test.js index fedc247947..905d6d84ea 100644 --- a/src/routes/recipient/handlers.test.js +++ b/src/routes/recipient/handlers.test.js @@ -50,9 +50,7 @@ jest.mock('../../services/recipient', () => ({ allArUserIdsByRecipientAndRegion: jest.fn(), })); -jest.mock('../../goalServices/goals', () => ({ - goalsByIdAndRecipient: jest.fn(), -})); +jest.mock('../../goalServices/goalsByIdAndRecipient'); jest.mock('../../services/accessValidation'); From ba91175ca37e1cdc3cd30e76e8edd997b8eacdec Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 15:57:00 -0400 Subject: [PATCH 18/78] Update e2e tests --- .gitignore | 2 ++ tests/e2e/activity-report.spec.ts | 8 ++++++++ tests/e2e/recipient-record.spec.ts | 4 ---- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 95ebf66dde..82fa02f30c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ playwright tests/**/report/*.html tests/api/report tests/e2e/report +tests/e2e/test-results +tests/api/test-results # Ignore /doc => /docs symbolic link created for adr-tools /doc diff --git a/tests/e2e/activity-report.spec.ts b/tests/e2e/activity-report.spec.ts index 2bf6cba9f1..31178906c3 100644 --- a/tests/e2e/activity-report.spec.ts +++ b/tests/e2e/activity-report.spec.ts @@ -216,6 +216,7 @@ test.describe('Activity Report', () => { await supportType.selectOption('Implementing'); await page.getByRole('button', { name: 'Save draft' }).click(); + await page.waitForTimeout(5000); // navigate away await page.getByRole('button', { name: 'Supporting attachments' }).click(); @@ -447,6 +448,8 @@ test.describe('Activity Report', () => { await expect(page.getByText('g1', { exact: true })).toBeVisible(); await expect(page.getByText('g1o1')).toBeVisible(); + await page.getByRole('link', { name: 'Back to RTTAPA' }).click(); + // Check g2 await page.getByText('g2', { exact: true }).locator('..').locator('..').locator('..') .getByRole('button', { name: 'Actions for goal' }) @@ -529,6 +532,11 @@ test.describe('Activity Report', () => { await page.getByRole('textbox', { name: /TTA provided for objective/i }).focus(); await page.keyboard.type('This is a TTA provided for objective'); + await page.getByText('Topics *').click() + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await blur(page); + const supportType = page.getByRole('combobox', { name: /Support type/i }); await supportType.selectOption('Implementing'); diff --git a/tests/e2e/recipient-record.spec.ts b/tests/e2e/recipient-record.spec.ts index 2ac67d7e0f..9b6f358d58 100644 --- a/tests/e2e/recipient-record.spec.ts +++ b/tests/e2e/recipient-record.spec.ts @@ -37,8 +37,6 @@ test.describe('Recipient record', () => { await page.getByRole('button', { name: 'Add new objective' }).click(); await page.getByLabel('TTA objective *').fill('A new objective'); - // first click blurs - await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Submit goal' }).click(); @@ -80,8 +78,6 @@ test.describe('Recipient record', () => { await page.getByRole('button', { name: 'Save draft' }).click(); await page.getByRole('button', { name: 'Save and continue' }).click(); - // first click blurs - await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Submit goal' }).click(); From 816de2a8013b7fdb7df5fd3c1f893df0d2244e19 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 15 May 2024 16:20:34 -0400 Subject: [PATCH 19/78] Fix another e2e interaction --- tests/e2e/recipient-record.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/recipient-record.spec.ts b/tests/e2e/recipient-record.spec.ts index 9b6f358d58..c79ea51f1d 100644 --- a/tests/e2e/recipient-record.spec.ts +++ b/tests/e2e/recipient-record.spec.ts @@ -76,8 +76,6 @@ test.describe('Recipient record', () => { await page.getByRole('button', { name: 'Add new objective' }).click(); await page.getByLabel('TTA objective *').fill('A new objective for this second goal'); await page.getByRole('button', { name: 'Save draft' }).click(); - await page.getByRole('button', { name: 'Save and continue' }).click(); - await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Submit goal' }).click(); From 189dd44172623ccb890104de8a2ce232dba012e1 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Thu, 16 May 2024 10:15:52 -0400 Subject: [PATCH 20/78] Add new column for ActivityReportObjectives --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 1 + ...re-column-to-activity-report-objectives.js | 23 +++++++++++++++++++ src/models/activityReportObjective.js | 4 ++++ 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/migrations/20240516140827-add-created-here-column-to-activity-report-objectives.js diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index f6ff55bdef..16e5d5af0c 100644 --- a/docs/logical_data_model.encoded +++ b/docs/logical_data_model.encoded @@ -1 +1 @@ -xLrjR-GsalwkNo5uFoGxw5biaii1P-mTrkFnP0Stup5iJnQR831eYUvcHY9xISeJ9vV_VY2f5vAY959IUrx2JtPJL5NfOP4iLf75lpCEAA_A8adJBr9mJr2UxYcvBM39qlU8xrA0jmNCquoIzoozWv0dQShU8Rm759HfWJ3a3tkO4iftn7YK5O2nzBSaJTFq6Q4vfAUa83JzqpVV_EV9V_rLAx_UeUmTXeobNoTf-hiKvLIy3LcIrdHECexk4N-uX1dQ8uWv-or9dwGeJuzJX3dSGfeUft_RGWmfu33_doJTKe3vIxF2vTcBiykpyzEpg_JeE_6S7Vq5vKbA-0xICymHVnXvuqrR2y7wnISfQ2NR4ph6xyIesBwVKIW4Fk7fSSfH2Ha7vLvXBtwcln8Cq-CKa_TV_bdKh_QvV_tVayJ6Jp0_sUyfkOTq6iJbtrSnI4VUUiiWVHmiqXmMSAfsacB2nJiS_iAJb770AvOUfn1NvH2QfwGem2sXW9C_5g83UBn01NmC7EvG0KVUacA4uE3x_KFV_Hq1nGi4Y_mIiZt1R0O8WeGt4A9o-u5R0efhMEuBmA4KueAISZyw_hdVE0naAQqngRlMZp-DeNW96QbezgW0qfhdoDkjM8U1I16NJT4CEGecyP_yUJC2rOtn-cz9Ya6fS9VbYUHqHqLXJTAVsj_-x-dJiMOQRidf5YGKRH46nKJB6MsrTEJO1l1QjuspWZ1uLR4znFafmQ8hj9ED_kmI0aPmC_6a4EVFWPoN19CaHKn-6ImUpdB4eVYKMq8A2CGhTw1Wiiubvs0FMY928RgPJRPS-woTwEu1pNQ0Vldrjz_-MTEPvhs1JllczcSe1Jeb5PqpyBK4l1kpVQDhASbxX40jt8US6rkfvqsIQy3qiKsRhOJpH_qrOFa4n_y-Shr9G1_-UI7-S711oJtAOJum3qnHTKm6_LScclgQx57sOTgP2zOZxolHFaW7y9yiVO3eV_hIQPFCudhOHHsWRuGFmP3dgzdpxC0qoAgz0E9QU3gQgh8_jJIrRZKnLSuVvS8Rx1CSquQ7ihFJL_zxsZv-os2Uf23F4KS4W-ASSE4kobHRYsKyThtGZeVkiSz_o9iOwQPRjm2ClrKOFdlmMyw7BFmft2A2HnjUL7YaNlea5Tuc97jQ9yxNYQRXjL-lxcDc_xEQkUrrGFcn9erkK6ILMERcLuO_fz6MRcGG811wIOgkd-UmGFUGFho6V4VodcXvHG5N7vR339MW-FARpLT_Tgbhj_Jjs_EbK1fPvMT6yArG3xojmDR6GhnSm2t8bCgOiZXrNkEAg_8MqZTt5u1obA6zy7zs3PAaUx250Y5OYo5AZ9gs97jAjXIKeirSZsapkFOV_Bk2Z4ibi4PQuOyse67LROcOq1shyUG3pg6990Tq2_a5sOi65mhkO2zD0ES33Xpw9vxS8yehK3olbS6bfUruh_i3XIg5Z_Gw1UzHtt13jiC06JNsfNY7jNLqiQIYJA9HWZsx9YEhE8jW8_yzA53-OXInx-igSqUzUWbjwkmJqG_sO0iuV8DVGoexRRSSvxI21t9MQo2NPOLH9bN-5TdwhYupwxtHnRDPzIDQJ_P4y_XosamuerDa4smwbEjTrCi11zsu7_xjVWwNoKwro3LYk9QUf7insJFEtk2t-u80ftlgi1tYx1vYhiR52rGGJryaWtAY6MdXArL3eznY1k_7jSaMQPOtCYIeUQZkPd_8EL2ssId475BP1pmw8BIep44E6vn7A9WYFaeapPJjt3rnX5hHGc0NgS6syPi4T6j3imY5uUFzuQTlBo_Fpgzlhb--FhkwFdbzuHhRo7ji8kvUgWH0us3RDwRt9BC_h4NCUwhqx4jYEhn2lqBpQzx11qVpD-uS8SE0XfWHwE2-AWQFqFXqPXbOeqR1W2qMCgPzQZi42eJVFGFPkrQ_3BI38y11m_y7Y_8IVD8ijY-xwRn-OfKiZWY7ne1383oaJymDacxpc625MaDU_hIbKU6fEB0pyYiAhr292a0HV_YEachlPtA5wx_5Pk9Lg_Q1t9pmbwoeSfY_l3id_2Gl4NOqykyZO4q3THSQ99-tmbaN5pMu_mEmbrYkpi50hBhd7RJ0afFWwGxWxUhZmK5jGFZ-oXrHUD8vnvf5qrgsOFr8kdOSdYpRvqcAt2sZqGAkASIp8PfNFIUfy46Nvg9hcFRMNegeeMk6QxotQu2gTkwgz-x4vnjXe1t26fRxnkNOk5nhhQzBfLFLYMiN6MrWE32wn6Re4zERtKRXHcl3AjGCc8l79n4gSm8pDSNhy_h4o-tytgldQfhf9Zb44AgjXmXkOpBDucW7s4xlzBMavwPxIYz-JN9qXrPhH1dLgwCk4oqzEjvwR6-VhUpA5NW1ACJykm_kUpgypLnByZq2Ub3FOw7NttNtWfBogWIKins4_9g3Ym9WVV4plkcESlTkXQMRyIJovpAwzmhXT-kUZV1locqdHl1C6-RULVBrFbDQCzy5V5le07Kuczem6ni8bxaB0SOLA5vKE4Tu-vfWlkkHp7BawbbrOxWUTNrYk2sb7a2tHqi-kzh33842CNvM0bHCLN8IGbZ6JEKyC2cHNjdQybF7iAT4wbLp2gM7hqV6Vn5ZFoMvy_rHIrmi5J-8EszgfMsihJyZXvvqPBW76uVz6JIgXc5w84lRp_VWpzuA1FgD_OXTLBS5olI9bEq0XVa_aHoqpDDNBwL__pdWmDTtho82ByO3rI_RIi6W6qxwz3ZzukNpi_FhbozUdZt_ulZuI9hVbjGtwfqZVvHad1tVaIwiOLYx56Q4vgUaKbjrwYeisy4Iz1LR-rWFpvD_W5yXFOboU92SlCSYadp2XV6kRrjAZzE_rYl-338sNC6_aUOs4D-_66qZ2_qr-rqExn16kfz1njiW1hS578Y_t_7z5UGSuQtnZjM5XGRbrGpVYL2Rru2zQr_S0zw7uZL01QJcferR4HNZxarIsinjxvgQ6xbivnOePdwvIS9scZr9VoQtZXrtJyGkDE4SMMoiKSYgWhSRxrYL2MtMHaGTCSmX1ZY3Fh1nYhPRIDjTfcqjlLTQDDlU2JZS4lgWGJvZLujSSki-C_zvuKh1S5SLJYG8HfnGXBVokFXLLPQ08kWzLHjB5TTShIsfZlvofBrpf1O3zmY-Vvcx2NJ2NSfw3PNchQEW2KOk44-3Qh_rzT1x7bm3Z552oZTq2p3wrTxq-ktcFxpjBLWIjJVYIkshxv4n9ZQ46QTLDlu6bID0iprpsWAN23qFMdDs3Ve2oIMLjrruf-fFQqfDk56LzhsZLcKXdARodQfegAruxwYR1KScw9uqAZhGK6lUFmhR4o4frOxpPXgnd7KVWuuJ1gUNjncX54fzV0v1_WfXkDopKIkm1_VMLwIVuroeoVz0JFpsiWMNgtAu2Yhv7aOqyH77uZj3ffRbnw72VigMGmvfYJ0mWbj6VyI0YeCDzvscEU9iAogVthWhsiLzNJkKItBykxoCaaDqZ9aBzqz5arVHmBMLpXua7qtbhZZ0EQ3vunabhERefRIDU9gYrYz_OiRm8-hcPpSi7dGyZ0FXehPvr74ZBaFYCiInObrnbBDNp8SVuzJoeluSRp_KuaNz9-RDKlMkKGa_-4p_x25ODvfwa_8_ni2jjSQA7SVIXSVgDv-FhQD_pzgcJW-hQEa4jupV2U750-AghIhsRTVR2vec-znMJFQ6EyiZfQahgNX6z9rrAgyjvo3RbIPCWQ41rO26ZHYWSVq45WuTDDR1wuvqlSNuuSUw4AV3gku2n4Lms_pK9Rtauri886vwQFrwqRP2kOZapj9V_Mn8szZpJdX_RAUpmxT7MO8jJR8my9s7t5lLVGhyXLznk0yvJu7hTyQQhgxoQPsZw87HFqD1kEiCnSIByln4huxT18-H-SrRf_Hiv-hdzPoj6Xafzs9uSSvyMdROnz-wEYzfuUUpXNL6QjxGHBeVOLx8_kVxxXKDsUruk0z_b9CnVP12PeTv7__P7x-VaPItcLsyCkyEvzhj7TCygCf_XET6U_ncgVEY3_iap9zXdC9LrUT4wN3bmPRpJX2zJhDz34cXPnm1_rZErrvRX-kDyWzmmjQ1QR0-1SrgXz6V3s00-9qxzZinh9ypdfEmV8sklU1Yhr3xzD2jrV6QcTnyzZdeEqGPFwrNU3vrN-8BSLnI7iUPhdrE-9Nmclh9KN-RrpZXAREewYsogEmjiQZiA_3WuV2PEUcxpgrhzqhkPe-akFFMQnTYOwhCiUaNemcR05b6Hhq8L-BvHtpHseKwXNm1AICOmsAfHzAcPzmK0SsiRg5Vs3iDMv_I4mnxAI1LnlLbHwZWtPsQ0oPZFD1vn5iDHYvbbdtUzM1FuHtwnT4txrp5JVTq7-wksyALrU0GkJVcUwIwA9sLVjwDK9iYVlIqlEF9lo2WeIVaH-u7_L13srWs1JfwxK45aLKcZgr-SNmiVeDCvC-_1_G1uQqEmDPpxCHZUMGlLOR-CqeDJ-tGoSnrbx2b-we5ieVjNGlca_e-WMSaVsZF73Rf9Ps5pkGB5KKZUFXx4lpFD9S0uJdxZsZHlYKj39KUJtrJg-qbvC3ClzkK-4KfIrH2frgwhGoHRyvxbHLoBS77i3RtlSR1bpjYNI8HSvPTCE9uc_DLKJXdCzjOwBvVsKK7i1rpo3AAzivUlR9bsK_TVWZ_AEjncPTD_Z1wt6TO7wX5UDjjdlcZO-ATldvEplerqwdFpf7UVJmghp92eSmJ3hBTggESS1LzPTcn11HVs-dVQnlenhRMc92iTx5ISFiY3ayAw9Tlun1xJdnIPZr9IyYOkk-ftO2aiiWtwKgQxKzjifBkmsv4rMR7eKS2ghdfKArazFU08Y3DGJD1ZxSP4BZpvP2Jul3SzxP9fhkoNVFXjpS95dK6c3Vta5AKmxNxIDMnc7YbIKLpg4JKzw2eO7zxXCxQHsFbItrL3CZJ0xeyBxV3LqE4j-JtRQfXjHxuGACT9--WovRZhgM4R_EroSC3y9W6O4TAU7dWUn9tJ5x3HmU6CB5yUqMsQXculgV6xUSgmBOB3QV7wSdbvSc_VlofcLSaU3Uorf58VYG-uQJYEqAb1zcH7gdvIDUTX4wdq8vWUExCSca4gZL9H30Qu7kPbGinZZBM79Ix4JR5JIdkNRiXzJ3MX4P1ePxcWErk2Q2vo8S444PuUwFsXJFjMHFSrjdA0BOiQ7IhkKDkgUzrwLrqY2dpqXrHch5PTEvTMp-SFAzNSHwWT4i-Gdi-X1zENg22G-gtDR0giNCPAxU5ZzwIzCF9gnFxub4FBvSmls-q6hAiB6u0wnNTFlIAEY0dfWXHznV9fggKQiWYFT3JOMWPpHY-lw7a_OsvKY15xYYjbsKt36qTBV7At5bQnpPfX6aKfe4ThX6gFbIMzoL2r8Tex3wfY9SRBuUdFaBbXYf2D0hHyCxvYDO8Md5SzySg3bPXUDcUnGZM25WHfr6SOj_pWYd2Oy6ZB_6ik404o0P87iMC0Da0bMM5KIQTzQ_8DbfC7Y15W7e0nLM90vRdprzvG987V5XkVLX6oXBW_8Q3RS_tB2fB4DjF6_ncTRpb1kLG7a_fRtTKpvrnCtMjouqpk3A1Zxqn9Je2F40QJO0I8ND15W6o08gpl0CKkUyinap60OeXW2c4ubf-19mhLA82KCGVr7M6SSBtmYbYvC987JNP0RoDdSq5K9Tx1HRv1INYBiA8qp1EOral8besMd6Uym0fWkC09u9YWsAbn8_uEF29gFHG6Rw1FIBK8XILPn2QY8g5ok1rxX6U3fu8pI0q4HOBbf0hrVzmo8HSdVw7WsVoFlM8audJm4tW4IOdJ8vhfYDI8qYvxqIEa4r35bLi4g-eLJueEIutYaWD8HN7gY2iuY0r2LH4Q10Xzvsv4IKJfQ4BUIDEBfoYCdc052UwGAXYlvl2AG8q2D30NQaH9ZTC4I8BehXpFn2Y28yDp_3DgH4W7Q08Z0NCrBsd4IKBfNYWSGoVk0TW39quF4yXD70jQ4J9YLBK9r-aOb8UBvWYGeC8HGKQWoSYOoFZlj0mWYF0SLwW6A6Hq0pUdXZnqV29UcFX4dE43oEpZGED2vQuTd03uxWaC2BJ81D1iK2nhnsCg4XKfYuoZ32vOPguwp32veOjGcH3D79fOaH88TI_Sp4Y8v29reyB5SSTYn6O1fZXAneZHXP64Hi7DiCNGqQC6emYA1Oe_LiE8ou25_TbXn4RXOrzVUCV6fVaY_I4CP8XEVQo94daEZqz-keeVVhqnp_T5Y1xjzaN0E4Dtw7i7OQC_VVNFprzAgjeYJ-4NEslJNg72fYeC-U-nEniL-bnkXL56_MdUv8a8nSQMjp4pQamjzZzbgP7-2ZjtPAH7eixEysoJGe6cIME-ye2-4zTf9SUMxn5bOsZp7njTQbQLx6tGEqRoT2zFxC5ihVYzMHgAso67jHl7b2sfaU4IYRVEw1SmoqvBTjZjAbQUSuGJhreIJPFe2QErfD2PzvKMeMZSNUjYstpR3LkveWvzg6zsgl9uEuZcvHsJrVZfSR0gU-KjLRZgVMBASPTrTMu3_Tqc_bGlmxXtMwlJuzJjeSP0WLYl2qLZLht6AtC52FfNSaqSoPNxnxNLvlRJpQo_0KbPU-THHBqBWaGrejLjZ6i2y5X6SjUdaTfCLc5ApDrdnT8Z-khOAm7JkRMdKb1pUoub4eQRMRvfbUYXfF6P5ZHziZaVvEcHZZdZaMdi8fLezZYrmp2ZeyWLj6c_goaTs5A01_d9JvYJIFAD-gF_NXLXevZTSFi7PKN2UC8KOcnKIzbjHJE9LVuF7edrrtwxBcCRi-OrHbO5ciefw6Eh5YuEsyeZvxyn2YebwYpgHed5NJgneEPN0R2vvq6hKlS6NGMp5pbpThUSHj5u7pfklIZdZQjHnjN8Kk6BZDOTms-bqGRpU-MsIjmd-3duXHAtYcX0hbvjAbPhiEeVwYWnsuxMdqLANJxTvXvP_ikuTdmO2RnPBddfQ6yIGaLxaLG37bSf8qD64DR37C7eM1XXgPl9OvxzWxbCMGqRtgcyppQlQwbY9iTAvIehLbAM5EJLXm9dKFVVBXxGxQOT5kz6XnbWzdeT_2qNyBqqbbqvVW8ACIrwp7ReIAVXuQnFczdxxeF1rFhxxSHywSeUOFXHlrt_Puewh983XNlIT9pU_ip7Kg6AnOD0u_wplHL-9ivh0p-vgC1_ejTTNMamE6X2wO_EF8f5WD3NoWNY-fs9XNrmh48-gZ_Qu3da6elHMZy20ifscTPb6rZ52sbpJgQcfoXjkYW8sNJzwUmcVHDP3Swm8aUHlCdQrrAO0NWaFcqvX5EtwIzqNYmnCfL7Z9oM6yUfvoYxHPIc3zxs7G_4e-qmxC6B3lTip5azTA5RleFspSVHyGsQR1BKAjACz7AuTMTL7U9GAqcttK47G3Lkhq-_z4s2s-jnVAqUQq6T4WztEXm7K0A9_SfhYkfQAiHRAp732d4IaACTH2_dh6I84CjuMIjfqXTfGcs7K6VDbZCFy3oArrS4sU2pxMDu8xqPj0qNb6IqP7ABSl8GBLKciacicKnNrXT4fS8REEKAXd9RBf00kTOOJuQwAzhIZ60WvElaMDohTbgTBXkAwugXNkoR4ejsAsiVwl7Wh1HwXSKcY-JdmpuW3oxiFYvnRM9CLhraowm7xuPG7UKuRfiq_xNESmS3kCLFS-zkCoa-tcHFFxneSr0o6Xb_deh-CYmKhiCP63o5ABUphME1BP_hySjMNCBcy6jyzjFRs4RyU1h-uDcYJgzQMFZth-nd5zlYy6cjzjW8OzU1zjOaTw5z9qTIEFXw33pEaglW-YgQdM8sHr1ESyrRPGUT5t1zCH5Ixsof2NsuFJDpcLczZQTITVHavxxSP7difaXBteUJ3MZskL9ww5Sms6wwNbE4mte-Hil17xjthp-pWisZyj0e4bZv0mqpUFLB7nrwcgLEukUCv3JqaxgR3Apvmb5JT2m9m8sWxOPjUuewnREBBeTg3N11j5GIawRhA5A-iPNE0SjcPX6Uj-Lpg3P6EUL-Bj9TwI4YaZIpGPhdQYWzV_ZvoYMytLEQ4FC7Rl-bqlWmtWPls5ijulH7DfTOlBecz5pUBcMK1sV_X9g3ey9VI3hQC3eUiJ1pO4VAU3IKrsKg7s5rfDWsbrkg9OeROk2gRKP1UgHfZvl9YWcAgtGx66GYTVDKT2r8tLvKBpUZpWK5b_6T78RP7EYGTyLkJhFmTsHJXcSjPDQQTOx8CN5FrF7eOAv3g_QQtv3A_K5d0OvpAHizD6QgDP9zKmCWNtLJd8XTK0MRMJ_jPydoenOuqYO8D_iG6tB5zAtdG4w3zC9i8oFF89KyPqu3b80nj_kSt78MiJvQMtvXZ9mBMCQ5lqrJ2XrF-UnWil7PHpAtxKjKd7hRukSGzlUdDVHDfw4qtRjewc9zxVHX58VQmMy0kg3JZB5Oi0_5ih5dAOLQNsM3gNNPR4IXv4eLAUkM5iQJD0sIZQTQJABbcFGxCWk7Z5TkMDQ3lVMWwH96BBmPE5zJQP_kcqMY_QL9ZZpVKk_SJer9HEP3_1H-hl6at3kuDdCaOq9oypF64WVu4_QOTypdvEskc_Jcvjz48_r3YYjmaO_dtGW5bwuhSlIj99OoAdaPSWj8MWTkB1zQ0i86pjeLYKz48hw9-8xKZN7DypoNk6gyXXkTzs3ZjPbvANKpeFNS_KXypdL7Q0JjUjQsY-hanyCKQxBnl1cNkyegRQxdesvCH_tx3NpkTjOSWW-m6wavlG1zb4XN0VCvuvMXLWzj2UirsXM5V1q72oUg-_s-cCXSNiCrCYt9t33SHZTZr4VA5r0tePv7hdH4zkizU_8qVbg-62igEbq0RaFpCvjT-P_5dOkYu53eSkQtunCpIKzCA_qYQz4D9oO5K4oZ_VCdt_gE5omzjZYA9tfxIkRImyFRnjR2dyJx73l1bfAdVXj_vflKeynM5F-cMI7vPr9q58CZLmRXVKVaLKCf8vcoDN3LWLqIR7iAwaU_PdxROf84wYPIqLzMvBy7UAyAela_m40 \ No newline at end of file +xLt_RzqsalzTVuNW_Q5jyBBOjjS3pjWxhEFOQN29OzXE5zkYC6Y9rcCZYMz9ogdRw_z-8Aal94L9fAHdEob_iXz5pOm-79B36NBu3ye0OLMHHt7yHGpkC4hZ7S4tEIne_16nRGpAB8Tfd13yaSQt4B8eZka7LEu00KMSenAo-nsCCM5Rh3rASa1f_7iKnt7y0fCKacESOnB_vTjl__Fel_wcb5zjKVAXX9J6tqHn_8SGvPY_3MaKrtIE4eRk4Bk_W0dQ8LWo-diKFujH6X_6g6GmWoG-ZF-UmH8Im63wFr6S4L2ortg5YvFJaukJi-EJo_ZW5NzE3_wAqYZ6R0l9ISaGknzvurrR2y7wnMSnQ2NROJB6xqYhsBwV4CW5FcBnOSnG0Ia3nKvYpt_6l-4IneTnYJz--HMlN-rIz_f_H9697sT-jDiHV0V9D8ZBlw-Ya8oyzPv2yZ1On2dCuLJjA2unu-ym-2kBOie0gsXy6CEoAeRGFCPv3cum0Gp_kH0TmECKvV0XSBX5EHnvIuZXWuFhzmzzzoiCybC4y_G2qXqkwGmGE0bd87ZbzWCp3F8hMEnomA0GufpWOZSw_jdVs0zaCQangRlMJp-FeNW1AQv8yg00afhdoDkjMFk1I18NJT4CEH8c_f_uUJq2rRNs-kzHN892uYxA8y7fbl32cgGxjR__t_FdOyqqt9BJBOW9ob09yecMqzfgwSYHUV1QjusJ75DuMT4TBDDZNFAkq4uswhCB22WkcuadXcNw4eXvYJE9OaJ5by7y_YH53F9FkS4uXu2_SWjoAkfSC0Pse2N25QARsKJBjS_QYUuEa6GBo9VVVFVTLuhEDEyDTDftDxz08T0nhiYSWQybuDoOxHjTImdU8mHguHvpRcobdZT9hW7JKvisMmddZ_fhmF89Zlzzv7gJW3hyivp_OU2Su3kKmdHW7fYYQfaC-gzCDFKLtg7aSTgPSTOZxqlH7gK3k4-MBa3qFttfj8bdSTtleWwGTzo7eCdpzUnvjlt2Oodqzv30RcuTd2PfjUD0NN5FI4DAzBUMfzBj9gRI-QEWVuD-WcFUy51tSlpcVu_Rn-yfJACOf2bY29SWFyECkvCIgGjycL_QNcZ7I_lOw3uaJeo4qwsR0yhUImnVtBijztqS0mHc29UZ3SyhF50lVXPpRnGIFR4Jvvj8qV3QxrVtCRt_6StSzZeW_LoJHZd8auelWzDBpT_JQ4itSd0GCBd0UTVFKvYZEqZktqAsnMbFjBGYWCaFIs622f1_-UtcwwziwjejZVlsVi6LHbJvXUBoXgj7cLKWCx7WB-Sm0ubK6LqMgyuhNCkgk87atUqv80mbQCVzers3HF4EP90Z16inU8bMqvHaHt16GC7RpFQOD8Dl_y6_AqYPK09ZfEN-QWqQMjqKY7BTCHfFF60CwaWoG3B8pl6EDpXNS0jxOI9O7p1WqJ_-v0uHLe7yPQqCDYrjntlLNyXLoN-aLoauZ_gQcx8T1l3eOa28jrzRI1LBACea6WNOqIiyiiep23Rotu4SfQUZYFrGLvaxwjLBR5rdd_5-i0CPOF1T-HQI36hBTKOgk2TdjW9oKLGbIeJAly6Pw-epCTuRNLphLP_GUhCdciENasR26PqYcs3Klbppl5uA6EZ6VVzlTtF8J7QgHQuJpVNqBDxnoPuo-gvylyiniBglzjiXJkvWoUIuU8voxEYhf51CD2CjV2bhA9HRpE3Tk3QP8itoLle4FVVLtSmFEGFIDZk69OD4z0673WWDAdEGApNE8nHq4HyaqgRBTcQVE7ojQ2wmZTHWsxXD0ggreLY4tF1n_l3ZznSN9sTNrrUltvvSNXszFRUDRVXZgIDkNwfuG6DXspUcTpWnFwn5p7cgTEnBV3gyHBz1_Mi-m7jBypVk7Yx3W8Re4Mhllie73z_uTM8OMAD6mO0jvZEcVMex279KVdW1aW-jVcbeUqU8lOR_Hui2u_AeMMnVg1upySGeMHmnzeqH-q2uJv-O7IJLvj71yhM6dFnfIwF6Kt5YQsIO5rwY0kM087R_Yk1gvsPo-Uk_mMRYLQFsdjoSyDSahrAOlhuzm-meBL1fQUG_Hi2Q1khMDuW-RuMpBYvoSFyUqDUbM9s3WLXsppjeW2KcmUCjABFLnuEzse3muwKzel2iSuurYwQrRC7waN3jM3nPfiyJvwbTHgq5R5E8OqCqhpfEKlw3Babvrz3jhPqGKK5N3zSuR-S0LUtSbU_Tyi_Nma1RL2fOxXgNO-DohRM-BXLELIkkNUIqWM7Fw6AJeKlCRtOR-Hkj3IjVCs0k7fz4gCmApDGKhyxh4o-tytkldgrfffja7q6ejXx2PHkJQHP7Mq2tQgUl9JtJtLDgyLkSebkqMYEAg5uTTPbewT7nrc7x-snbbwx0Sq1WoLuzkkzf_pPpBSaF2AX1FOU5NdzNtYd1nMK12ADR2DaL0-SvADN5p_YcFCBikncMRjOJo9-9wDuRL7vOz-o4UrDcdHZUCwsVUrVAr_jCOSsyylWstm5gVpQrPZOs42vp5uAoh41-MfKfAhEtk6JR7iaaHRQULJU6xL7N9uRRKkW1j7kqnBFB7KmGo15b9mL0-LGbAo5KYfYfc0Sc9L8h6rk2wXZsb4YzQbQXrF3roFZFO-p7EBM-Fsg9ywNYexZpfbRQsjhwCtBe2KU6VR2ns9zUeweVfdkojDry3-xiZK2WszYFw7brawD4suct1GIc_qHYqD9FPxqM_lsNW05NtxsC29oE1wfVjvMyGTUSzF5X_CNhboTdLwzFNv-y_EBmy42OtuRKr_AT0tUKH9ozlCLTM4EpTWbCC4cFSIMtwjHLQBA51UWhjlAn3fudzm2_H7eIPVyWEVvOyaZo4UF6kxfjAJwC_rQl-1D8wN05zqQQsq1iTphQHWRwQ_Qx5DwY4gb-1X5lWoMuoU50_FsMxIuXOwZCj7UCBimrAArc-4wushm5z4FxuWwmFXHl0SeXqJTjtF2e6BDlab9cRdFNrDp8PJktG3xpYqqIfjFiMVmBlh7ik7l6Tw5nvyXYOur0MkMitNX7guHhip8YgeYL39QWJA0BR8oWB8EatMNQrb9xfK6pxPs0mGoJUmNo6RjQn99RzPcvpmkN4eRRAhSYWKl4C45b9ewJNrP50GY2pbErjbInpTNAakhWhmVAEmToCN27uCVNgPr09zo5gDTGQTuwy9fWn0BnsAlsUrSFlUF1Da0fmRXuHp9Gej_CJQ_VRVx3sTw21PPx8oxblleU6MrYGoLbf4h6to1Y16YsPqvRu0AXbrvhpgv1Jv5OKZdRXRUo_hIcr8GRLMdVEsXL5i9fAgzaHKrj-Ni7tImuCa7tf97HWOPQy_w1tvu8GQbsd3VJYE6iUmewZYcSNjucc10ezF4b1EaRLCBbderQWHsujxuYzPpdGat-7b7-syaMNAxA_YeevRiVqSG778jlDPbQbaz3XTsKBOKCaWALG0jkQVsn18GVRBZhDCqIPrjH-TJ6NT4kxklQlboIuz_bP9JiesFCNBX_AfgyYWQkhNJc8FbWAdV9G9a2aZwSKK9hZavAsuYdA6hzyYTc33-WRdvsm-9UnyCm42vgcmSTDy8o92n3B2lM5KSvSyMy_p5EBY_gpt7sGorUr7zYtYnPxmeLyD5d-XVtthhHr9kK_o46RgKrLkmuXYuyLB_vV6mTzNlMDdLwM0LBnxjbt4u8BnuGLsrLlc-xtbxGDDddjc8oDzmP7Yb5NN754gpkh9DvRna3oRRGOV8C3SW6r6n43uth9x2mwA2s3bvtfEijnlS_rlau7LPr5o36Wzlwfoxf9Kzl8O2Oxg5rwqLPXL8UoPsbl-hPaBQnvvtmVcgdi_stHnc2BKsoCF2T-znRrNqA_9LTChZpEKzHrk-CDLrTvTCwHz43et-60ZFR6Og95yNvYLuTimaU8lERiqwfsT3bpwivMpKINEpuy6AT-PJiiG-_TNLUKiFFPmlhZDIzlebsds3UoFxdk-uLzTdjkByFVv2JCNsGV6Q7UH__sH-_dv6qjvbTd3Bp3kVYxHtJtAlAFy5pepr-izHvqOTz4kRFC4x-gkfoedIuyc3BEIUGNgVPdaBIw1b7m9_A-Tehut2zLlu89smjc7hhazUijX_zUGG3mEtSiTk9OVMSyPo4vcjqxGKNV_dQf_jkhOxNpEJdiyz1to92sN6zmdEf-xeyvySbnN6SwTfdc5rohwcR7FMtEOqJZZBpgiuYyyhE4lFAhW8F7WwVcPE-wzogTg_aRdP8YZjllNREDwQA79jkCHg_2P0cUDIBSCcVF-6BsCxbAUHpI2WkIvWlzKYQzBIR9A2UrStt2tkdOUjJUfnXBmMapBY-x0WLlBsLSy1aZ0TQZtZBmSYcpABFsnxi2Bn5lrhwvgsjkEcUziFTTLlOiXhi8_V6V8zK5wKpqi_R4MfJn8-UbhSy-HS4nFU4_CZzeOyrj8rbKo2ZrnvK41bb_DZg5uU7_3T8UFxyEq3kKPcf06IEOsSvbaVsGcReFo54yzGEdSnSTmbRsAzQm3lOthMWFwdl4tY1q_lsp66JNj9QQ4s-K14qWeUx9y7zJ7C1K3bd_n79sxU4XI7JuyblQhMzW0pe-NUR4hSePO4AQ5LBkvN1yyqvhx9yhYLu2cjprsiSNBaZsYKIqMVP1IEEipbVvRbrDZEDXUxxfLsq1zinXJGXQ6VjrIjRbltKxVlmZz8UbtdPvFUZnts6zO5QL6RTvfa_EoREwUldvAnVCvtwx1pftSUZuZB118eyuI3PhZhASLVHruOTYq7-ZPd-lRQ1harhZNB9UXShURSlOl0eXsxvDlQnvyGbPTQpk82KolkUsWr8CejyawwasVxK9YlfFkoQLsrs73eae9gR3jL8Hdy720B27DGJz3YR0PuxN_OvAGxFVU-RPBehspMFlxs9iwWpmBoxWuIW5AxTHwgEnSGhJIgQVIMYlWDv1VlR8rJMBXehNkgheaUU7j3bURaPlXh2loL_Q5CDo_N08urs37-0DbkEsPOIhitN9W_tm6CQW2qkuCMUxqdSKd4D7naOmlZoxGtEv39mVK-DtS-LWwmB3SU7yT75nT6_VlgvcLS4UDUYrW5FViG_uQXYMqo41zcI7gdvIDkTX5Qdq1QNyCoQvL8BL2kIy60qAEz4LYv46KfeSr3kcjiKrmM_T-qQryDO4ne5kNoQ0hgzmO2aF1yIG2KgiskvBvmgpvhWjifM1B15GQDRpHroItslI--YGrIQb_Q8qepDedFlslhnw6MzYdC0fLloOTBp8VnuT8fo1edRri2gAAtBMB4jVFIMf1-FtXoP5uywUBcu-Nsjr11bHNuDQA_evhvNr04vCKNmkhyADbKbLKCKgO6U3aF3Q2xXnsk2FrxhAmMSfvRIUbbsJL5cQOat-ixIEhP90aoZC0tiS0rGzQ4ol2y1eZv4O_T91RpiU5ayzG4gErG1eb68XdVE1xH6q0hZkZjMOR3omylqB0QmHi0AE8tY5FgU4qm17WCUTObdn0K2G2P0uYXc0CW4g2Yg4YN2MVy6Py5YSW0f0DG3AAnABB2yVFd60PBKuyDIuyCIN1IWJAyPo7g-PrvPWDX-sk0to-CjroY5udXAVxkZV6wDcybhNQwjmPK9VCoDAS4Hu03IQ0AK09aDiW2G0L2OvIsWY7rd8MOo3b080KmX5DRo8-1SeWBbW21sBwyp31E-4qu18nj6ygZ95fHPw6msWBhSAp1FpmdcTn46d8Pn6ijw4T0oqGhpd0D80HmBE04K4nGZ9Nx0nu9FGAM7ol0Dx06X5g6WF8FG15GCL0QlSm_mTF04Q0QX2R0OiFXSgVwdGyRdwlG_7JoJzwa7d7IS1cu23p1POR9SCnsG6a7AVYToW6mQig1YatW5hN91oNAvKK1g2AamLdbX5GQfIQ02GeC8sU_C3IZfA0rTo1jmik4KbCa1f33HUK8L-jqKJW6WGO06xaeDCDbX2X0P48MT-vuG1NXCU8PlJWq0gG05O0nWfkuz3IWPA8K3ZMFuX3q0O-Y4uNaAeO5hGGDALfI2FhaY5PBoEC4K41f4A2ZG63m16HCP-uM60HmEZlK2nG2DW6RmziI0Zu9Fq1i3bv0ZV1YPB0mqDbZ2si4EZEMIm81CWqm0nG35l7Ome29GcRh8CC1aXsdYhiC0cnss0949qSYbYG4XYL3_piM034ScM3ysL2ntB0PW6c26hsWC61aO1Mmis0nV31amQZ0Ae1YWz6yv3BXmNEoN7GPi93RczOr_QboYB-8ZId62nDXBeoWIw_JuuEk3-Fdpd_voauFamsQN0rHks1Ob7qf8wNz_y_FpvwAAalY3ozN-siItA8UHF7FkIipCTXKVnXalsiW_tPCe6hdGqE36Rqw5gS7kzpW-A0_YtYqPyI6_irClsyHWa5bakQ_CW__KHNhneS6RLzbOolJNLfUgTHKx2zI-4GtT-pDR87ChNa-cji9cw47zvXvbQtg4hsJYlJtADToIqx8zH9lAbUSCuUIBXcJp16hYQ3qfLEPDfLKuQdVNsjWsprRZfe-OOn_QgxsQ3FO-ieb9ztGrNd8CdFfEKMirdag_c6BSLLsD-t1FDvaFzMiVtkts-lWmfZkO890ZojfItgXrZr7AT36YtfLCCsTo-UvXVRMv-MGZoay9BdhlB9AWTqa0jrwCiercNN0EorZsyZmAYyaeN9cf_Rn0V5XV1MCyi38txapoQcRBfLJ2QDFBDxqIEPmq9iEAjaSY_fiqDVmvTomwXPMi6iNclsOGjdm4DeeszMT5lGjJFFXOB_7XQnfUlf5_pCUhChGSgnjcRwcyI1XBY8gFZMefhQMmBhdyvz5RiUvIPyzbT7l6hCh0i5XzEGrrPCd2tdbzUVJanrf1lKITJLKxgQ1IrnxCv38GFkyqQ5tYrg0pPsOhRzRsbjieFETBswKVzRHfFDhO2caqT9d1kclmdI3QQ7wtp5w1-mG_6oTVybO99SZDeqtDSXr6_KC5FNRPQFLLf53jts7cdUsxX6V3W-h4e-MUbuRo9iQ4knT1FkHn4JOse1Hb9KmjXBo56Pg-fJZssTkNnv3HlEcPplDezxgM9AvtB5-YfcP9ONLDMd4aj0vzy-7k3TbyqctrQd2KzMQZti7JVWbLIsVH5UCZ8B2iNO_95kNfF3IqzNaxUzTzs9jQVxyDdpzvoRiIRkH_Ln-nf2wAn53n4KSzjxSxqw5ak7pHCF2exqnVXR-UmqhZhpyQuB_OLLT72YmUEc_wWoEVP34mzFboCQXcCwd85mvvq4V-NmCyXrHxAKBZHrX8qJpB8cqRhMWfRThHLEJqjKK77IcRlZ-5pgIl8xjH0OxKC9exMUDI0YC0X_IdDGz_-ZhjZSA3HaQkSf-HmtZpC6O4PHaaC7rnl-b-H0vfXsSDM6QwPst8wgKBtVGVjcy-Z8Xjqc2Re4QLPg8LmwizgUuIWLfDlkq8MW2gTNjz_gDjvjzQY-LfyreDw91yk57XseCKLkvJN5TIqLOZw5YF6LA8bOGOwo1kFMCbGOPQmifQJP6wIXDa7K6NDbZCtyFoAsnS8sk2pxMDu8xqPj0qNb6IqP7ABil8GBLKciacCcNHNrXT4fi8T6ELAXh9RBf00kjOeJyQwAzhIZ60WvMlbcDohTbgTBXkAwugXNsoR4eDs8siVwl7Wagjr2qeD5ycDndAFVBimk9d1jScncZKZxh0llzc0DLHLjPbc_Q_pc5cSHYlw7dknMKctS-Bv76F3sq6GaOhyrTRmKM6bTfb80EJen3rTgvr933zUprkqPfRs3HkdTz-UWlRY0TVsHyqIzJfJHiR-_wDvVnwMmmslTq62tdqEjZ6YFGkf6_jG0eFIuUPrr5w7KHLLQD3pEu8qdckQAFmeAiAfYOkMkgLnIsnUwjlTYuplRUfJBgE7lNSZuiybyqCUTxpOCePrIoFMW_b6WxJJSzp6Yb0pzzumV5hz-RrTrxGULm60aqA8skWQHwlPk6hKfUht5dqh86SbdPKPvEPFKz8Qfo6EH7G6RJrg7L7MRLmPj7jGAq9DeZwKhBQP0rLt3U-_3aeoSGKqhkkT0RDnZYl-DzDlCK5b4IYRZPOwqG5gVyVFqUndAzrGXjYRjdrlrm25yRB-0rZcor6SsXtYSwZRqRFuMLRGdLy-akeEJmcz06lem6Zwn4BDmj-E8TBJtKHBFSL6a-yQNUveLgYf2tofjPc5AX7clgy6QSOoRH2iuT1ob_NHq7RZjJbGV5wF-9GM7mPqybjaCs91dLNvEey1tT7McMmrevgfLdlW1JZ_qmTX_BcEhngh_aDhTGNS1haC9EoraTheaedLJCnUFPLCyc5q05Hj17zrtmUIJDYZIDXd7op0xKjNqZVT0QeF7Gdm38-uWXInRRyE4WEA7k_I_aXQnxd9xNbwyl0jenfMFJNCBxKtPp74YuNbx4lVzksICcjloro3crpSrv7MtlYJzksZwOctjz6uqjzh1NA5L0VT9995WF-CLyk-ZGjIQkpj2-zBeiLFun4e3nrHTjIRuAcKRBfJ9DPj2o3PaTsyOXfontJShWp7aHDmf259mllQZBzqsspMBIlDC6P7rhwZTEfAPd8V50Un7yeF35Z5fxD7DcOi5J_Z87q0UwaBVOzTJbfg_esdRcP1_5exu3IAcpKyqbxOk64sxeiTIk9-fnANeRS4eB6WGlJXxQ0iBQ3PbNI0w6eVoAsnLLpV4ClxnMkAe_cTbSzw6XTHavARJruFLSV4fTJtHuwKRkkXVsoEld14EMwR3HlvjMMu2hRzcpCZVwuPw_fp-t6aaNm0NOZDQSFi8yAOJjWFdUqoldgeJrXlKQpoe2dvM3nN7qtrnyBYPjZe4A-FO7hZyFeVepwGEa2yYRCyyYDdjYchNz7ZCwNmmTZUKsh2yX-O7PkkrFtix1pMGmS2btM_7faQYxfWNcbJtmbelx4Z0YIVhnd_lnJn-6yjSSKH-fCRr_PN7hbyBQnfV0tmGxxRg2brB7Rzg_v9lKOWpNbdqbsMDwP10t5KCYrKLZt4Ld6GEfeXbWzP3AWY_DbNqdKwC_SRvD4ca3DN2hoswBe7x1CgoAF_Xy0 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index aae36a5b78..f1a2cb734e 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -137,6 +137,7 @@ class ActivityReportObjectives{ arOrder : integer : 1 closeSuspendContext : text closeSuspendReason : enum + objectiveCreatedHere : boolean status : varchar(255) supportType : enum title : text diff --git a/src/migrations/20240516140827-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240516140827-add-created-here-column-to-activity-report-objectives.js new file mode 100644 index 0000000000..19ea2d7741 --- /dev/null +++ b/src/migrations/20240516140827-add-created-here-column-to-activity-report-objectives.js @@ -0,0 +1,23 @@ +const { prepMigration } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + await queryInterface.addColumn('ActivityReportObjectives', 'objectiveCreatedHere', { + type: Sequelize.BOOLEAN, + allowNull: true, + }, { transaction }); + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + return queryInterface.removeColumn('ActivityReportObjectives', 'objectiveCreatedHere', { transaction }); + }); + }, +}; diff --git a/src/models/activityReportObjective.js b/src/models/activityReportObjective.js index 1fce379712..2a1d0777fa 100644 --- a/src/models/activityReportObjective.js +++ b/src/models/activityReportObjective.js @@ -76,6 +76,10 @@ export default (sequelize, DataTypes) => { type: DataTypes.ENUM(SUPPORT_TYPES), allowNull: true, }, + objectiveCreatedHere: { + type: DataTypes.BOOLEAN, + allowNull: true, + }, originalObjectiveId: { type: DataTypes.INTEGER, allowNull: true, From 292926be65f6d100d5faca126addb41933c31164 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Thu, 16 May 2024 13:54:10 -0400 Subject: [PATCH 21/78] Simplify objective frontend --- .../Pages/components/GoalForm.js | 3 +- .../Pages/components/Objective.js | 38 ++-- .../Pages/components/ObjectiveTitle.js | 57 ++---- .../Pages/components/__tests__/GoalPicker.js | 2 + .../Pages/components/__tests__/Objective.js | 12 +- .../components/__tests__/ObjectiveTitle.js | 94 --------- .../Pages/components/__tests__/Objectives.js | 30 +-- .../Pages/components/__tests__/OtherEntity.js | 2 + .../Pages/components/constants.js | 1 + src/goalServices/getGoalsForReport.test.js | 4 +- src/goalServices/getGoalsForReport.ts | 179 +++++++++++++++++ src/goalServices/goals.js | 182 +----------------- src/goalServices/reduceGoals.ts | 40 ++-- src/goalServices/types.ts | 175 +++++++++++++++++ src/goalServices/wasGoalPreviouslyClosed.ts | 2 +- 15 files changed, 433 insertions(+), 388 deletions(-) delete mode 100644 frontend/src/pages/ActivityReport/Pages/components/__tests__/ObjectiveTitle.js create mode 100644 src/goalServices/getGoalsForReport.ts create mode 100644 src/goalServices/types.ts diff --git a/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js b/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js index bcac3f0e1b..2f4fedea8d 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js +++ b/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js @@ -157,7 +157,6 @@ export default function GoalForm({ permissions={[ !(goal.onApprovedAR), !isCurated, - status !== 'Closed', ]} label="Recipient's goal" value={goalText} @@ -183,8 +182,8 @@ export default function GoalForm({ { @@ -327,18 +333,25 @@ export default function Objective({ options={options} onRemove={onRemove} /> - + + + - { - if (isOnApprovedReport) { - return true; - } - - if (parentGoal && parentGoal.status === 'Closed') { - return true; - } - - if (initialObjectiveStatus === 'Complete' || initialObjectiveStatus === 'Suspended') { - return true; - } - - return false; - }, [isOnApprovedReport, initialObjectiveStatus, parentGoal]); - return ( - ); } ObjectiveTitle.propTypes = { error: PropTypes.node.isRequired, - isOnApprovedReport: PropTypes.bool.isRequired, title: PropTypes.string.isRequired, validateObjectiveTitle: PropTypes.func.isRequired, onChangeTitle: PropTypes.func.isRequired, inputName: PropTypes.string, isLoading: PropTypes.bool, - parentGoal: PropTypes.shape({ - id: PropTypes.number, - status: PropTypes.string, - }).isRequired, - initialObjectiveStatus: PropTypes.string.isRequired, }; ObjectiveTitle.defaultProps = { diff --git a/frontend/src/pages/ActivityReport/Pages/components/__tests__/GoalPicker.js b/frontend/src/pages/ActivityReport/Pages/components/__tests__/GoalPicker.js index 883aeff968..f68846210a 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/__tests__/GoalPicker.js +++ b/frontend/src/pages/ActivityReport/Pages/components/__tests__/GoalPicker.js @@ -124,6 +124,7 @@ describe('GoalPicker', () => { title: 'Objective 1', resources: [], ttaProvided: '', + objectiveCreatedHere: true, }], goalIds: [], }; @@ -167,6 +168,7 @@ describe('GoalPicker', () => { title: 'Objective 1', resources: [], ttaProvided: '', + objectiveCreatedHere: true, }], goalIds: [], }; diff --git a/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objective.js b/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objective.js index 82b19e80d9..b701ba4697 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objective.js +++ b/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objective.js @@ -11,6 +11,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import Objective from '../Objective'; import AppLoadingContext from '../../../../../AppLoadingContext'; import UserContext from '../../../../../UserContext'; +import { mockRSSData } from '../../../../../testHelpers'; const defaultObjective = { id: 1, @@ -20,6 +21,7 @@ const defaultObjective = { ttaProvided: '

  • What

', status: 'Not started', ids: [1], + objectiveCreatedHere: true, }; const mockData = (files) => ({ @@ -98,6 +100,7 @@ const RenderObjective = ({ title: '', courses: [], supportType: '', + objectiveCreatedHere: true, }, { courses: [], @@ -109,6 +112,7 @@ const RenderObjective = ({ files: [], status: 'Complete', title: 'Existing objective', + objectiveCreatedHere: false, }]} index={1} remove={onRemove} @@ -136,12 +140,8 @@ const RenderObjective = ({ describe('Objective', () => { afterEach(() => fetchMock.restore()); beforeEach(async () => { - fetchMock.get('/api/feeds/item?tag=ttahub-topic', ` - Whats New - - Confluence Syndication Feed - https://acf-ohs.atlassian.net/wiki`); - + fetchMock.get('/api/feeds/item?tag=ttahub-topic', mockRSSData()); + fetchMock.get('/api/feeds/item?tag=ttahub-tta-support-type', mockRSSData()); fetchMock.get('/api/courses', [{ id: 1, name: 'Course 1' }, { id: 2, name: 'Course 2' }]); }); diff --git a/frontend/src/pages/ActivityReport/Pages/components/__tests__/ObjectiveTitle.js b/frontend/src/pages/ActivityReport/Pages/components/__tests__/ObjectiveTitle.js deleted file mode 100644 index 7f69b0ab83..0000000000 --- a/frontend/src/pages/ActivityReport/Pages/components/__tests__/ObjectiveTitle.js +++ /dev/null @@ -1,94 +0,0 @@ -/* eslint-disable react/prop-types */ -import '@testing-library/jest-dom'; -import React from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; -import { - render, screen, -} from '@testing-library/react'; -import ObjectiveTitle from '../ObjectiveTitle'; - -const RenderObjectiveTitle = ({ - goalId = 12, - collaborators = [], - ...rest -}) => { - let goalForEditing = null; - - if (goalId) { - goalForEditing = { - id: goalId, - objectives: [], - }; - } - - const hookForm = useForm({ - mode: 'onBlur', - defaultValues: { - collaborators, - author: { - role: 'Central office', - }, - goalForEditing, - }, - }); - - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - - } - isOnApprovedReport - isOnReport - title="Objective title" - validateObjectiveTitle={jest.fn()} - onChangeTitle={jest.fn()} - status="Complete" - initialObjectiveStatus="In Progress" - parentGoal={{ status: 'Open' }} - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} - /> - - - ); -}; -const readonlyStateMap = [ - { isOnApprovedReport: true }, - { status: 'Complete', isOnApprovedReport: true }, - { status: 'Suspended' }, - { status: 'Not Started', isOnReport: true, isOnApprovedReport: true }, - { status: 'In Progress', isOnReport: true, isOnApprovedReport: true }, - { initialObjectiveStatus: 'Complete', isOnApprovedReport: false }, - { initialObjectiveStatus: 'Suspended', isOnApprovedReport: false }, - { parentGoal: { status: 'Closed' }, isOnApprovedReport: false }, -]; - -const writableStateMap = [ - { status: 'Not Started', isOnReport: false, isOnApprovedReport: false }, - { status: 'In Progress', isOnReport: false, isOnApprovedReport: false }, - { status: 'Complete', isOnApprovedReport: false }, - { status: 'Suspended', isOnApprovedReport: false }, - { initialObjectiveStatus: 'Not Started', isOnApprovedReport: false }, - { parentGoal: { status: 'Open' }, isOnApprovedReport: false }, -]; - -describe('ObjectiveTitle', () => { - it('shows the read only view', async () => { - render(); - expect(await screen.findByText('Objective title')).toBeVisible(); - }); - - test.each(readonlyStateMap)('should be readonly when %o', async (state) => { - // eslint-disable-next-line react/jsx-props-no-spreading - render(); - expect(await screen.findByText('Objective title')).toBeVisible(); - expect(screen.getByTestId('readonly-objective-text')).toBeVisible(); - }); - - test.each(writableStateMap)('should be writable when %o', async (state) => { - // eslint-disable-next-line react/jsx-props-no-spreading - render(); - expect(await screen.findByText('Objective title')).toBeVisible(); - expect(screen.queryByTestId('readonly-objective-text')).toBeNull(); - }); -}); diff --git a/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objectives.js b/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objectives.js index d5f0b40089..a8a88ea4ab 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objectives.js +++ b/frontend/src/pages/ActivityReport/Pages/components/__tests__/Objectives.js @@ -101,6 +101,7 @@ describe('Objectives', () => { topics: [], status: 'Not Started', id: 3, + objectiveCreatedHere: false, }, { id: 4, @@ -113,6 +114,7 @@ describe('Objectives', () => { resources: [], topics: [], status: 'Not Started', + objectiveCreatedHere: false, }]; render(); let select = await screen.findByLabelText(/Select TTA objective/i); @@ -143,6 +145,7 @@ describe('Objectives', () => { topics: [], status: 'In Progress', id: 3, + objectiveCreatedHere: false, }, { id: 4, @@ -155,6 +158,7 @@ describe('Objectives', () => { resources: [], topics: [], status: 'Not Started', + objectiveCreatedHere: false, }]; render(); let select = await screen.findByLabelText(/Select TTA objective/i); @@ -192,6 +196,7 @@ describe('Objectives', () => { resources: [], topics: [], status: 'Not Started', + objectiveCreatedHere: true, }]; render(); expect(screen.queryByText(/objective status/i)).toBeNull(); @@ -211,6 +216,7 @@ describe('Objectives', () => { resources: [], topics: [], status: 'Not Started', + objectiveCreatedHere: false, }]; render(); expect(screen.queryByText(/objective status/i)).toBeNull(); @@ -234,29 +240,6 @@ describe('Objectives', () => { expect(screen.queryByRole('button', { name: /Add new objective/i })).toBeNull(); }); - it('is on approved reports hides options', async () => { - const objectiveOptions = [{ - value: 3, - label: 'Test objective', - title: 'Test objective', - ttaProvided: '

hello

', - onAR: true, - onApprovedAR: true, - resources: [], - topics: [], - status: 'Not Started', - }]; - render(); - const select = await screen.findByLabelText(/Select TTA objective/i); - expect(screen.queryByText(/objective status/i)).toBeNull(); - await selectEvent.select(select, ['Test objective']); - const role = await screen.findByText(/Test objective/i, { ignore: 'div' }); - expect(role.tagName).toBe('P'); - - // TTA provided remains editable. - expect(await screen.findByRole('textbox', { name: /tta provided for objective, required/i })).toBeVisible(); - }); - it('handles a "new" goal', async () => { const objectiveOptions = [{ value: 3, @@ -268,6 +251,7 @@ describe('Objectives', () => { resources: [], topics: [], status: 'Not Started', + objectiveCreatedHere: false, }]; render(); diff --git a/frontend/src/pages/ActivityReport/Pages/components/__tests__/OtherEntity.js b/frontend/src/pages/ActivityReport/Pages/components/__tests__/OtherEntity.js index 4b3519816d..89297acb26 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/__tests__/OtherEntity.js +++ b/frontend/src/pages/ActivityReport/Pages/components/__tests__/OtherEntity.js @@ -43,6 +43,7 @@ const objectives = [ status: 'In Progress', topics: [], resources: [], + objectiveCreatedHere: true, }, { title: 'title two', @@ -50,6 +51,7 @@ const objectives = [ status: 'In Progress', topics: [], resources: [], + objectiveCreatedHere: true, }, ]; diff --git a/frontend/src/pages/ActivityReport/Pages/components/constants.js b/frontend/src/pages/ActivityReport/Pages/components/constants.js index 407e501bb4..40a04056c5 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/constants.js +++ b/frontend/src/pages/ActivityReport/Pages/components/constants.js @@ -19,6 +19,7 @@ export const NEW_OBJECTIVE = () => ({ isNew: true, closeSuspendReason: null, closeSuspendContext: null, + objectiveCreatedHere: true, }); export const OBJECTIVE_PROP = PropTypes.shape({ diff --git a/src/goalServices/getGoalsForReport.test.js b/src/goalServices/getGoalsForReport.test.js index a699dca309..f5b2d5dc41 100644 --- a/src/goalServices/getGoalsForReport.test.js +++ b/src/goalServices/getGoalsForReport.test.js @@ -1,8 +1,6 @@ import faker from '@faker-js/faker'; import { REPORT_STATUSES, SUPPORT_TYPES } from '@ttahub/common'; -import { - getGoalsForReport, -} from './goals'; +import getGoalsForReport from './getGoalsForReport'; import { Goal, Objective, diff --git a/src/goalServices/getGoalsForReport.ts b/src/goalServices/getGoalsForReport.ts new file mode 100644 index 0000000000..f033eea3c1 --- /dev/null +++ b/src/goalServices/getGoalsForReport.ts @@ -0,0 +1,179 @@ +import { Op } from 'sequelize'; +import db from '../models'; +import { + SOURCE_FIELD, + CREATION_METHOD, +} from '../constants'; +import { reduceGoals } from './reduceGoals'; +import { + IGoalModelInstance, +} from './types'; + +const { + Goal, + GoalTemplate, + Grant, + Objective, + ActivityReportObjective, + ActivityReportObjectiveTopic, + ActivityReportObjectiveFile, + ActivityReportObjectiveResource, + ActivityReportObjectiveCourse, + sequelize, + Resource, + ActivityReportGoal, + Topic, + Course, + File, +} = db; + +export default async function getGoalsForReport(reportId: number) { + const goals = await Goal.findAll({ + attributes: { + exclude: ['rtrOrder', 'isRttapa', 'isFromSmartsheetTtaPlan', 'timeframe'], + include: [ + [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], + [sequelize.literal(`( + SELECT + jsonb_agg( DISTINCT jsonb_build_object( + 'promptId', gtfp.id , + 'ordinal', gtfp.ordinal, + 'title', gtfp.title, + 'prompt', gtfp.prompt, + 'hint', gtfp.hint, + 'caution', gtfp.caution, + 'fieldType', gtfp."fieldType", + 'options', gtfp.options, + 'validations', gtfp.validations, + 'response', gfr.response, + 'reportResponse', argfr.response + )) + FROM "GoalTemplateFieldPrompts" gtfp + LEFT JOIN "GoalFieldResponses" gfr + ON gtfp.id = gfr."goalTemplateFieldPromptId" + AND gfr."goalId" = "Goal".id + LEFT JOIN "ActivityReportGoalFieldResponses" argfr + ON gtfp.id = argfr."goalTemplateFieldPromptId" + AND argfr."activityReportGoalId" = "activityReportGoals".id + WHERE "goalTemplate".id = gtfp."goalTemplateId" + GROUP BY TRUE + )`), 'prompts'], + ], + }, + include: [ + { + model: GoalTemplate, + as: 'goalTemplate', + attributes: [], + required: false, + }, + { + model: ActivityReportGoal, + as: 'activityReportGoals', + where: { + activityReportId: reportId, + }, + required: true, + }, + { + model: Grant, + as: 'grant', + required: true, + }, + { + separate: true, + model: Objective, + as: 'objectives', + include: [ + { + required: true, + model: ActivityReportObjective, + as: 'activityReportObjectives', + where: { + activityReportId: reportId, + }, + include: [ + { + separate: true, + model: ActivityReportObjectiveTopic, + as: 'activityReportObjectiveTopics', + required: false, + include: [ + { + model: Topic, + as: 'topic', + }, + ], + }, + { + separate: true, + model: ActivityReportObjectiveFile, + as: 'activityReportObjectiveFiles', + required: false, + include: [ + { + model: File, + as: 'file', + }, + ], + }, + { + separate: true, + model: ActivityReportObjectiveCourse, + as: 'activityReportObjectiveCourses', + required: false, + include: [ + { + model: Course, + as: 'course', + }, + ], + }, + { + separate: true, + model: ActivityReportObjectiveResource, + as: 'activityReportObjectiveResources', + required: false, + attributes: [['id', 'key']], + include: [ + { + model: Resource, + as: 'resource', + attributes: [['url', 'value']], + }, + ], + where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.REPORTOBJECTIVE.RESOURCE] } }, + }, + ], + }, + { + model: Topic, + as: 'topics', + }, + { + model: Resource, + as: 'resources', + attributes: [['url', 'value']], + through: { + attributes: [], + where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, + required: false, + }, + required: false, + }, + { + model: File, + as: 'files', + }, + ], + }, + ], + order: [ + [[sequelize.col('activityReportGoals.createdAt'), 'asc']], + ], + }) as IGoalModelInstance[]; + + // dedupe the goals & objectives + const forReport = true; + return reduceGoals(goals, forReport); +} diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index 7f821df973..f4528a4550 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -20,10 +20,6 @@ import { ObjectiveFile, ObjectiveTopic, ActivityReportObjective, - ActivityReportObjectiveTopic, - ActivityReportObjectiveFile, - ActivityReportObjectiveResource, - ActivityReportObjectiveCourse, sequelize, Resource, ActivityReport, @@ -63,6 +59,7 @@ import goalsByIdAndRecipient, { OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, } from './goalsByIdAndRecipient'; import { reduceGoals } from './reduceGoals'; +import getGoalsForReport from './getGoalsForReport'; const namespace = 'SERVICE:GOALS'; const logContext = { @@ -1020,42 +1017,27 @@ async function removeUnusedGoalsCreatedViaAr(goalsToRemove, reportId) { return Promise.resolve(); } -async function removeObjectives(objectivesToRemove, reportId) { +async function removeObjectives(objectivesToRemove) { if (!objectivesToRemove.length) { return Promise.resolve(); } // TODO - when we have an "onAnyReport" flag, we can use that here instead of two SQL statements - const objectivesToPossiblyDestroy = await Objective.findAll({ + const objectivesToDestroy = await Objective.findAll({ where: { createdVia: 'activityReport', id: objectivesToRemove, onApprovedAR: false, + onAR: false, }, - include: [ - { - model: ActivityReport, - as: 'activityReports', - required: false, - where: { - id: { - [Op.not]: reportId, - }, - }, - }, - ], }); - // see TODO above, but this can be removed when we have an "onAnyReport" flag - const objectivesToDefinitelyDestroy = objectivesToPossiblyDestroy - .filter((o) => !o.activityReports.length); - - if (!objectivesToDefinitelyDestroy.length) { + if (!objectivesToDestroy.length) { return Promise.resolve(); } // Objectives to destroy. - const objectivesIdsToDestroy = objectivesToDefinitelyDestroy.map((o) => o.id); + const objectivesIdsToDestroy = objectivesToDestroy.map((o) => o.id); // cleanup any ObjectiveFiles that are no longer needed await ObjectiveFile.destroy({ @@ -1076,7 +1058,7 @@ async function removeObjectives(objectivesToRemove, reportId) { // Delete objective. return Objective.destroy({ where: { - id: objectivesToDefinitelyDestroy.map((o) => o.id), + id: objectivesToDestroy.map((o) => o.id), }, }); } @@ -1592,156 +1574,6 @@ export async function updateGoalStatusById( }))); } -export async function getGoalsForReport(reportId) { - const goals = await Goal.findAll({ - attributes: { - include: [ - [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], - [sequelize.literal(`( - SELECT - jsonb_agg( DISTINCT jsonb_build_object( - 'promptId', gtfp.id , - 'ordinal', gtfp.ordinal, - 'title', gtfp.title, - 'prompt', gtfp.prompt, - 'hint', gtfp.hint, - 'caution', gtfp.caution, - 'fieldType', gtfp."fieldType", - 'options', gtfp.options, - 'validations', gtfp.validations, - 'response', gfr.response, - 'reportResponse', argfr.response - )) - FROM "GoalTemplateFieldPrompts" gtfp - LEFT JOIN "GoalFieldResponses" gfr - ON gtfp.id = gfr."goalTemplateFieldPromptId" - AND gfr."goalId" = "Goal".id - LEFT JOIN "ActivityReportGoalFieldResponses" argfr - ON gtfp.id = argfr."goalTemplateFieldPromptId" - AND argfr."activityReportGoalId" = "activityReportGoals".id - WHERE "goalTemplate".id = gtfp."goalTemplateId" - GROUP BY TRUE - )`), 'prompts'], - ], - }, - include: [ - { - model: GoalTemplate, - as: 'goalTemplate', - attributes: [], - required: false, - }, - { - model: ActivityReportGoal, - as: 'activityReportGoals', - where: { - activityReportId: reportId, - }, - required: true, - }, - { - model: Grant, - as: 'grant', - required: true, - }, - { - separate: true, - model: Objective, - as: 'objectives', - include: [ - { - required: true, - model: ActivityReportObjective, - as: 'activityReportObjectives', - where: { - activityReportId: reportId, - }, - include: [ - { - separate: true, - model: ActivityReportObjectiveTopic, - as: 'activityReportObjectiveTopics', - required: false, - include: [ - { - model: Topic, - as: 'topic', - }, - ], - }, - { - separate: true, - model: ActivityReportObjectiveFile, - as: 'activityReportObjectiveFiles', - required: false, - include: [ - { - model: File, - as: 'file', - }, - ], - }, - { - separate: true, - model: ActivityReportObjectiveCourse, - as: 'activityReportObjectiveCourses', - required: false, - include: [ - { - model: Course, - as: 'course', - }, - ], - }, - { - separate: true, - model: ActivityReportObjectiveResource, - as: 'activityReportObjectiveResources', - required: false, - attributes: [['id', 'key']], - include: [ - { - model: Resource, - as: 'resource', - attributes: [['url', 'value']], - }, - ], - where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.REPORTOBJECTIVE.RESOURCE] } }, - }, - ], - }, - { - model: Topic, - as: 'topics', - }, - { - model: Resource, - as: 'resources', - attributes: [['url', 'value']], - through: { - attributes: [], - where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, - required: false, - }, - required: false, - }, - { - model: File, - as: 'files', - }, - ], - }, - ], - order: [ - [[sequelize.col('activityReportGoals.createdAt'), 'asc']], - ], - }); - - // dedupe the goals & objectives - const forReport = true; - return reduceGoals(goals, forReport); -} - export async function createOrUpdateGoalsForActivityReport(goals, reportId) { const activityReportId = parseInt(reportId, DECIMAL_BASE); const report = await ActivityReport.findByPk(activityReportId); diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index 87fd470d8d..5c6284c66c 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -2,26 +2,11 @@ import { uniq, uniqBy } from 'lodash'; import moment from 'moment'; import { auditLogger } from '../logger'; import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; - -interface IPrompt { - dataValues?: { - promptId: number; - }; - responses?: { - response: string[]; - }[]; - promptId?: number; - ordinal: number; - title: string; - prompt: string; - hint: string; - fieldType: string; - options: string; - validations: string; - response: string[]; - reportResponse: string[]; - allGoalsHavePromptResponse: boolean; -} +import { + IGoalModelInstance, + IPromptModelInstance, + IGoal, +} from './types'; interface IAR { id: number @@ -268,8 +253,8 @@ export function reduceObjectivesForActivityReport(newObjectives, currentObjectiv */ function reducePrompts( forReport: boolean, - newPrompts: IPrompt[] = [], - promptsToReduce: IPrompt[] = [], + newPrompts: IPromptModelInstance[] = [], + promptsToReduce: IPromptModelInstance[] = [], ) { return newPrompts ?.reduce((previousPrompts, currentPrompt) => { @@ -353,16 +338,15 @@ function reducePrompts( * @param {Object[]} goals * @returns {Object[]} array of deduped goals */ -export function reduceGoals(goals, forReport = false) { +export function reduceGoals(goals: IGoalModelInstance[], forReport = false) { const objectivesReducer = forReport ? reduceObjectivesForActivityReport : reduceObjectives; - const where = (g, currentValue) => (forReport + const where = (g: IGoalModelInstance, currentValue) => (forReport ? g.name === currentValue.dataValues.name - && g.status === currentValue.dataValues.status : g.name === currentValue.dataValues.name && g.status === currentValue.dataValues.status); - function getGoalCollaboratorDetails(collabType: string, dataValues) { + function getGoalCollaboratorDetails(collabType: string, dataValues: IGoal) { // eslint-disable-next-line max-len const collaborator = dataValues.goalCollaborators?.find((gc) => gc.collaboratorType.name === collabType); return { @@ -452,11 +436,13 @@ export function reduceGoals(goals, forReport = false) { if (!forReport) { source = { [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, + } as { + [key: string]: string; }; promptsIfNotForReport = { [currentValue.grant.numberWithProgramTypes]: prompts, } as { - [key: string]: IPrompt[]; + [key: string]: IPromptModelInstance[]; }; } diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts new file mode 100644 index 0000000000..056a4106bd --- /dev/null +++ b/src/goalServices/types.ts @@ -0,0 +1,175 @@ +interface IGrant { + id: number; + regionId: number; + status: string; + startDate: string; + endDate: string; + oldGrantId: number; + recipientId: number; + numberWithProgramTypes: string; + number: string; + name: string; + recipient: { + name: string; + id: number; + dataValues?: { + name: string; + id: number; + } + } +} + +interface IGrantModelInstance extends IGrant { + dataValues?: IGrant +} + +interface IPrompt { + responses?: { + response: string[]; + }[]; + promptId?: number; + ordinal: number; + title: string; + prompt: string; + hint: string; + fieldType: string; + options: string; + validations: string; + response: string[]; + reportResponse: string[]; + allGoalsHavePromptResponse: boolean; +} + +interface IPromptModelInstance extends IPrompt { + dataValues?: IPrompt; +} + +interface ICourse { + name: string; +} + +interface ITopic { + name: string; +} + +interface IResource { + value: string; +} + +interface IFile { + originalFileName: string; + url: { + url: string; + } +} + +interface IActivityReportGoal { + id: number; + goalId: number; + activityReportId: number; + createdAt: Date; + updatedAt: Date; + name: string; + status: string; + endDate: string; + isActivelyEdited: boolean; + source: string; +} + +interface IActivityReportObjective { + id: number; + objectiveId: number; + activityReportId: number; + createdAt: Date; + updatedAt: Date; + name: string; + status: string; + endDate: string; + isActivelyEdited: boolean; + source: string; + arOrder: number; + activityReportObjectiveTopics: { + topic: ITopic; + }[]; + activityReportObjectiveResources: { + key: number; + resource: IResource; + }[]; + activityReportObjectiveFiles: { + file: IFile; + }[]; + activityReportObjectiveCourses: { + course: ICourse; + }[]; +} + +interface IObjective { + id: number; + goalId: number; + title: string; + status: string; + onApprovedAR: boolean; + onAR: boolean; + activityReportObjectives: IActivityReportObjective[]; + topics: ITopic[]; + resources: IResource[]; + files: IFile[]; +} + +interface IGoal { + id: number; + name: string; + endDate: string; + isCurated: boolean; + grantId: number; + createdVia: string; + source: string | { + [key: string]: string, + }; + onAR: boolean; + onApprovedAR: boolean; + prompts: IPromptModelInstance[]; + activityReportGoals: IActivityReportGoal[]; + objectives: IObjective[]; + grant: IGrantModelInstance; + status: string; + goalNumber: string; + statusChanges?: { oldStatus: string }[] + goalCollaborators?: { + collaboratorType: { + name: string; + }; + user: { + name: string; + userRoles: { + role: { + name: string; + } + }[] + } + }[]; + collaborators?: { + [key: string]: string; + }[]; +} + +interface IGoalModelInstance extends IGoal { + dataValues?: IGoal +} + +export { + IGrant, + IPrompt, + ICourse, + ITopic, + IResource, + IFile, + IActivityReportGoal, + IActivityReportObjective, + IObjective, + IGoal, + // -- model version of the above -- // + IGoalModelInstance, + IGrantModelInstance, + IPromptModelInstance, +}; diff --git a/src/goalServices/wasGoalPreviouslyClosed.ts b/src/goalServices/wasGoalPreviouslyClosed.ts index 35eca93744..408a4fedcd 100644 --- a/src/goalServices/wasGoalPreviouslyClosed.ts +++ b/src/goalServices/wasGoalPreviouslyClosed.ts @@ -1,7 +1,7 @@ import { GOAL_STATUS } from '../constants'; export default function wasGoalPreviouslyClosed( - goal: { statusChanges: { oldStatus: string }[] }, + goal: { statusChanges?: { oldStatus: string }[] }, ) { if (goal.statusChanges) { return goal.statusChanges.some((statusChange) => statusChange.oldStatus === GOAL_STATUS.CLOSED); From 1c371905ceec903da2650d6fa497e1a47d068826 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Thu, 16 May 2024 15:54:17 -0400 Subject: [PATCH 22/78] Simplify all form controls --- .../src/components/FileUploader/FileTable.js | 30 ++-- .../src/components/GoalForm/ObjectiveFiles.js | 96 ++++------- .../components/GoalForm/ObjectiveTopics.js | 77 +-------- .../components/GoalForm/ResourceRepeater.js | 159 ++++++------------ .../src/components/GoalForm/UnusedData.js | 24 --- .../src/components/GoalForm/UnusedData.scss | 5 - .../GoalForm/__tests__/ObjectiveFiles.js | 68 -------- .../GoalForm/__tests__/ObjectiveTopics.js | 50 +----- .../GoalForm/__tests__/ResourceRepeater.js | 75 ++------- .../GoalForm/__tests__/UnusedData.js | 31 ---- .../src/components/ObjectiveCourseSelect.js | 6 +- .../__tests__/ObjectiveCourseSelect.js | 28 --- .../Pages/components/Objective.js | 38 ++--- src/goalServices/goals.js | 4 + src/services/reportCache.js | 2 + 15 files changed, 136 insertions(+), 557 deletions(-) delete mode 100644 frontend/src/components/GoalForm/UnusedData.js delete mode 100644 frontend/src/components/GoalForm/UnusedData.scss delete mode 100644 frontend/src/components/GoalForm/__tests__/UnusedData.js diff --git a/frontend/src/components/FileUploader/FileTable.js b/frontend/src/components/FileUploader/FileTable.js index 49accc33aa..6b7ceae980 100644 --- a/frontend/src/components/FileUploader/FileTable.js +++ b/frontend/src/components/FileUploader/FileTable.js @@ -80,23 +80,19 @@ const FileTable = ({ onFileRemoved, files }) => { {getStatus(file.status)} - {file.showDelete - ? ( - - ) - : null } + ))} diff --git a/frontend/src/components/GoalForm/ObjectiveFiles.js b/frontend/src/components/GoalForm/ObjectiveFiles.js index 8666236573..75060f3bfb 100644 --- a/frontend/src/components/GoalForm/ObjectiveFiles.js +++ b/frontend/src/components/GoalForm/ObjectiveFiles.js @@ -1,11 +1,9 @@ import React, { useState, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; -import { v4 as uuid } from 'uuid'; import { Label, Radio, Fieldset, FormGroup, ErrorMessage, Alert, } from '@trussworks/react-uswds'; import QuestionTooltip from './QuestionTooltip'; -import UnusedData from './UnusedData'; import ObjectiveFileUploader from '../FileUploader/ObjectiveFileUploader'; import './ObjectiveFiles.scss'; @@ -13,18 +11,14 @@ export default function ObjectiveFiles({ objective, files, onChangeFiles, - goalStatus, - isOnReport, onUploadFiles, index, inputName, onBlur, reportId, label, - userCanEdit, forceObjectiveSave, selectedObjectiveId, - editingFromActivityReport, }) { const objectiveId = objective.id; const hasFiles = useMemo(() => files && files.length > 0, [files]); @@ -34,39 +28,12 @@ export default function ObjectiveFiles({ () => (hasFiles && files.some((file) => file.onAnyReport)), [hasFiles, files], ); - const readOnly = useMemo(() => !editingFromActivityReport - && ((goalStatus === 'Not Started' && isOnReport) || goalStatus === 'Closed' || !userCanEdit), - [goalStatus, isOnReport, userCanEdit, editingFromActivityReport]); - useEffect(() => { if (!useFiles && hasFiles) { setUseFiles(true); } }, [useFiles, hasFiles]); - if (readOnly) { - if (!hasFiles) { - return null; - } - - return ( - <> -

- Resource files -

-
    - {files.map((file) => ( - file.onAnyReport || goalStatus === 'Not Started' ? ( -
  • - {file.originalFileName} -
  • - ) : - ))} -
- - ); - } - const showSaveDraftInfo = forceObjectiveSave && (!selectedObjectiveId || !(typeof selectedObjectiveId === 'number')); @@ -120,35 +87,35 @@ export default function ObjectiveFiles({ ) } { - useFiles && !showSaveDraftInfo - ? ( - <> - - - Example file types: .docx, .pdf, .ppt (max size 30 MB) - {fileError - && ( - - {fileError} - - )} - - - - ) - : null + useFiles && !showSaveDraftInfo + ? ( + <> + + + Example file types: .docx, .pdf, .ppt (max size 30 MB) + {fileError + && ( + + {fileError} + + )} + + + + ) + : null } @@ -194,17 +161,13 @@ ObjectiveFiles.propTypes = { }), })), onChangeFiles: PropTypes.func.isRequired, - goalStatus: PropTypes.string.isRequired, - isOnReport: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]).isRequired, onUploadFiles: PropTypes.func.isRequired, index: PropTypes.number.isRequired, inputName: PropTypes.string, onBlur: PropTypes.func, reportId: PropTypes.number, - userCanEdit: PropTypes.bool.isRequired, forceObjectiveSave: PropTypes.bool, selectedObjectiveId: PropTypes.number, - editingFromActivityReport: PropTypes.bool, }; ObjectiveFiles.defaultProps = { @@ -215,5 +178,4 @@ ObjectiveFiles.defaultProps = { forceObjectiveSave: true, selectedObjectiveId: undefined, label: "Do you plan to use any TTA resources that aren't available as a link?", - editingFromActivityReport: false, }; diff --git a/frontend/src/components/GoalForm/ObjectiveTopics.js b/frontend/src/components/GoalForm/ObjectiveTopics.js index 4bec5ce70f..5f7424d038 100644 --- a/frontend/src/components/GoalForm/ObjectiveTopics.js +++ b/frontend/src/components/GoalForm/ObjectiveTopics.js @@ -1,17 +1,15 @@ -import React, { useMemo, useRef } from 'react'; -import { v4 as uuid } from 'uuid'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { FormGroup, Label, } from '@trussworks/react-uswds'; import Select from 'react-select'; import selectOptionsReset from '../selectOptionsReset'; -import UnusedData from './UnusedData'; import Drawer from '../Drawer'; import Req from '../Req'; import ContentFromFeedByTag from '../ContentFromFeedByTag'; -import './ObjectiveTopics.scss'; import DrawerTriggerButton from '../DrawerTriggerButton'; +import './ObjectiveTopics.scss'; export default function ObjectiveTopics({ error, @@ -19,71 +17,14 @@ export default function ObjectiveTopics({ validateObjectiveTopics, topics, onChangeTopics, - goalStatus, inputName, isLoading, - isOnReport, - userCanEdit, - editingFromActivityReport, }) { const drawerTriggerRef = useRef(null); - const readOnly = useMemo(() => !editingFromActivityReport - && ((goalStatus === 'Not Started' && isOnReport) || goalStatus === 'Closed' || !userCanEdit), - [goalStatus, isOnReport, userCanEdit, editingFromActivityReport]); - - if (readOnly && !topics.length) { - return null; - } - if (readOnly && topics.length) { - return ( - <> -

- Topics -

-
    - {topics.map((topic) => ( - topic.onAnyReport || goalStatus === 'Not Started' ? ( -
  • - {topic.name} -
  • - ) : - ))} -
- - ); - } - - const { editableTopics, fixedTopics } = topics.reduce((acc, topic) => { - if (!userCanEdit || topic.onAnyReport) { - acc.fixedTopics.push(topic); - } else { - acc.editableTopics.push(topic); - } - - return acc; - }, { editableTopics: [], fixedTopics: [] }); - - const savedTopicIds = fixedTopics ? fixedTopics.map(({ value }) => value) : []; - topicOptions.sort((a, b) => a.name.localeCompare(b.name)); - const filteredOptions = topicOptions.filter((option) => !savedTopicIds.includes(option.id)); - const onTopicsChange = (newTopics) => { - // We need to combine the new and fixed topics. - onChangeTopics([...newTopics, ...fixedTopics]); - }; return ( <> - { fixedTopics && fixedTopics.length - ? ( - <> -

Topics

-
    - {fixedTopics.map((topic) => (
  • {topic.name}
  • ))} -
- - ) - : null} option.name} @@ -145,17 +86,9 @@ ObjectiveTopics.propTypes = { onChangeTopics: PropTypes.func.isRequired, inputName: PropTypes.string, isLoading: PropTypes.bool, - goalStatus: PropTypes.string.isRequired, - isOnReport: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.number, - ]).isRequired, - userCanEdit: PropTypes.bool.isRequired, - editingFromActivityReport: PropTypes.bool, }; ObjectiveTopics.defaultProps = { inputName: 'topics', isLoading: false, - editingFromActivityReport: false, }; diff --git a/frontend/src/components/GoalForm/ResourceRepeater.js b/frontend/src/components/GoalForm/ResourceRepeater.js index b8ed1e1a13..e976dcd2cb 100644 --- a/frontend/src/components/GoalForm/ResourceRepeater.js +++ b/frontend/src/components/GoalForm/ResourceRepeater.js @@ -9,7 +9,6 @@ import { faTrash } from '@fortawesome/free-solid-svg-icons'; import PlusButton from './PlusButton'; import QuestionTooltip from './QuestionTooltip'; import URLInput from '../URLInput'; -import UnusedData from './UnusedData'; import colors from '../../colors'; import './ResourceRepeater.scss'; import { OBJECTIVE_LINK_ERROR } from './constants'; @@ -19,56 +18,17 @@ export default function ResourceRepeater({ setResources, error, validateResources, - isOnReport, - isLoading, - goalStatus, - userCanEdit, - editingFromActivityReport, toolTipText, validateOnRemove, + isLoading, }) { - const readOnly = !editingFromActivityReport - && ((goalStatus === 'Not Started' && isOnReport) || goalStatus === 'Closed' || !userCanEdit); - - if (readOnly) { - const onlyResourcesWithValues = resources.filter((resource) => resource.value); - if (!onlyResourcesWithValues.length) { - return null; - } - - return ( - <> -

Resource links

-
    - {onlyResourcesWithValues.map((resource) => ( - resource.onAnyReport || goalStatus === 'Not Started' ? ( -
  • - {resource.value} -
  • - ) : - ))} -
- - ); - } - - const { editableResources, fixedResources } = resources.reduce((acc, resource) => { - if (resource.onAnyReport || !userCanEdit) { - acc.fixedResources.push(resource); - } else { - acc.editableResources.push(resource); - } - - return acc; - }, { editableResources: [], fixedResources: [] }); - const addResource = () => { - const newResources = [...editableResources, { key: uuidv4(), value: '' }]; + const newResources = [...resources, { key: uuidv4(), value: '' }]; setResources(newResources); }; const removeResource = (i) => { - const newResources = [...editableResources]; + const newResources = [...resources]; newResources.splice(i, 1); setResources(newResources); @@ -83,7 +43,7 @@ export default function ResourceRepeater({ }; const updateResource = (value, i) => { - const newResources = [...editableResources]; + const newResources = [...resources]; const toUpdate = { ...newResources[i], value }; newResources.splice(i, 1, toUpdate); setResources(newResources); @@ -91,67 +51,54 @@ export default function ResourceRepeater({ return ( <> - { fixedResources.length ? ( - <> -

Link to TTA resource

- - - ) : null } - - { userCanEdit ? ( - -
-
- - {!fixedResources.length ? 'Did you use any other TTA resources that are available as a link?' : 'Add resource link'} - +
+
+ + Did you use any other TTA resources that are available as a link? + + + + Enter one resource per field. To enter more resources, select “Add new resource” + +
+ {error.props.children ? OBJECTIVE_LINK_ERROR : null} +
+ { resources.map((r, i) => ( +
+ + updateResource(value, i)} + value={r.value} + disabled={isLoading} /> - - - Enter one resource per field. To enter more resources, select “Add new resource” - -
- {error.props.children ? OBJECTIVE_LINK_ERROR : null} -
- { editableResources.map((r, i) => ( -
- - updateResource(value, i)} - value={r.value} - disabled={isLoading} - /> - { resources.length > 1 ? ( - - ) : null} -
- ))} -
+ { resources.length > 1 ? ( + + ) : null} +
+ ))} +
-
- -
+
+
- - ) : null } + + ); } @@ -164,21 +111,13 @@ ResourceRepeater.propTypes = { setResources: PropTypes.func.isRequired, error: PropTypes.node.isRequired, validateResources: PropTypes.func.isRequired, - isOnReport: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.number, - ]).isRequired, isLoading: PropTypes.bool, - goalStatus: PropTypes.string.isRequired, - userCanEdit: PropTypes.bool.isRequired, - editingFromActivityReport: PropTypes.bool, toolTipText: PropTypes.string, validateOnRemove: PropTypes.func, }; ResourceRepeater.defaultProps = { isLoading: false, - editingFromActivityReport: false, toolTipText: 'Copy & paste web address of TTA resource used for this objective. Usually an ECLKC page.', validateOnRemove: null, }; diff --git a/frontend/src/components/GoalForm/UnusedData.js b/frontend/src/components/GoalForm/UnusedData.js deleted file mode 100644 index 488935737b..0000000000 --- a/frontend/src/components/GoalForm/UnusedData.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faBan } from '@fortawesome/free-solid-svg-icons'; -import colors from '../../colors'; -import './UnusedData.scss'; - -export default function UnusedData({ value, isLink }) { - return ( -
  • - - {isLink ? {value} : value} -
  • - ); -} - -UnusedData.propTypes = { - value: PropTypes.string.isRequired, - isLink: PropTypes.bool, -}; - -UnusedData.defaultProps = { - isLink: false, -}; diff --git a/frontend/src/components/GoalForm/UnusedData.scss b/frontend/src/components/GoalForm/UnusedData.scss deleted file mode 100644 index befde042df..0000000000 --- a/frontend/src/components/GoalForm/UnusedData.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use '../../colors.scss' as *; - -.ttahub-objective-list-item--unused-data { - color: $base-dark; -} \ No newline at end of file diff --git a/frontend/src/components/GoalForm/__tests__/ObjectiveFiles.js b/frontend/src/components/GoalForm/__tests__/ObjectiveFiles.js index 12f932a5c8..04ad7e10d4 100644 --- a/frontend/src/components/GoalForm/__tests__/ObjectiveFiles.js +++ b/frontend/src/components/GoalForm/__tests__/ObjectiveFiles.js @@ -8,74 +8,6 @@ import userEvent from '@testing-library/user-event'; import ObjectiveFiles from '../ObjectiveFiles'; describe('ObjectiveFiles', () => { - it('shows the read only view when objective and goal are closed', async () => { - render(); - expect(await screen.findByText('Resource files')).toBeVisible(); - expect(screen.getByText(/testfile1\.txt/i)).toBeVisible(); - expect(screen.getByText(/testfile2\.txt/i)).toBeVisible(); - }); - - it('shows the read only view when goal is not started and on an AR', async () => { - render(); - expect(await screen.findByText('Resource files')).toBeVisible(); - expect(screen.getByText(/testfile1\.txt/i)).toBeVisible(); - expect(screen.getByText(/testfile2\.txt/i)).toBeVisible(); - }); - - it('shows the read only when a user can\'t edit', async () => { - render(); - expect(await screen.findByText('Resource files')).toBeVisible(); - expect(screen.getByText(/testfile1\.txt/i)).toBeVisible(); - expect(screen.getByText(/testfile2\.txt/i)).toBeVisible(); - }); - it('shows files in not read only mode', async () => { render( { { id: 1, name: 'Dancing but too fast', - isOnApprovedReport: true, - onAnyReport: true, }, { id: 2, name: 'Dancing but too slow', - isOnApprovedReport: false, - onAnyReport: false, }, ]; @@ -53,47 +49,11 @@ describe('ObjectiveTopics', () => { it('displays the correct label', async () => { renderObjectiveTopics(); const label = screen.queryAllByText(/topics/i); - // we expect a result of 3 elements - // 1) existing topics, which have a

    header reading "Topics" - // 2) the

    diff --git a/frontend/src/components/__tests__/ObjectiveCourseSelect.js b/frontend/src/components/__tests__/ObjectiveCourseSelect.js index 94ba06a401..a765b63b2f 100644 --- a/frontend/src/components/__tests__/ObjectiveCourseSelect.js +++ b/frontend/src/components/__tests__/ObjectiveCourseSelect.js @@ -97,32 +97,4 @@ describe('ObjectiveCourseSelect', () => { await selectEvent.clearAll(select); expect(onChange).toHaveBeenCalled(); }); - - it('handles fetch error', async () => { - fetchMock.get('/api/courses', 404); - const onChange = jest.fn(); - await act(() => waitFor(() => { - renderObjectiveCourseSelect( - onChange, - [{ id: 6, name: 'Ongoing Assessment (BTS-P)' }], - ); - })); - - const select = screen.queryByText(/iPD course name/i); - expect(select).toBeNull(); - }); - - it('handles no options', async () => { - fetchMock.get('/api/courses', []); - const onChange = jest.fn(); - await act(() => waitFor(() => { - renderObjectiveCourseSelect( - onChange, - [{ id: 6, name: 'Ongoing Assessment (BTS-P)' }], - ); - })); - - const select = screen.queryByText(/iPD course name/i); - expect(select).toBeNull(); - }); }); diff --git a/frontend/src/pages/ActivityReport/Pages/components/Objective.js b/frontend/src/pages/ActivityReport/Pages/components/Objective.js index 2f80efab75..fce51db5af 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/Objective.js +++ b/frontend/src/pages/ActivityReport/Pages/components/Objective.js @@ -56,9 +56,8 @@ export default function Objective({ const [statusForCalculations, setStatusForCalculations] = useState(initialObjectiveStatus); const { getValues, setError, clearErrors } = useFormContext(); const { setAppLoadingText, setIsAppLoading } = useContext(AppLoadingContext); - const { objectiveCreatedHere } = initialObjective; - const [createdHere, setCreatedHere] = useState(objectiveCreatedHere); + const [onApprovedAR, setOnApprovedAR] = useState(initialObjective.onApprovedAR); /** * add controllers for all the controlled fields @@ -228,7 +227,16 @@ export default function Objective({ rules: { required: true }, defaultValue: objective.closeSuspendContext || '', }); - const isOnApprovedReport = objective.onApprovedAR; + + const { + field: { + value: createdHere, + onChange: onChangeCreatedHere, + }, + } = useController({ + name: `${fieldArrayName}[${index}].createdHere`, + defaultValue: objectiveCreatedHere || null, + }); const isOnReport = objective.onAR; @@ -257,7 +265,11 @@ export default function Objective({ onChangeUseIpdCourses(newObjective.courses && newObjective.courses.length); onChangeIpdCourses(newObjective.courses); - setCreatedHere(newObjective.objectiveCreatedHere); + // was objective created on this report? + onChangeCreatedHere(newObjective.objectiveCreatedHere); + + // keep track of whether the objective is on an approved report + setOnApprovedAR(newObjective.onApprovedAR); }; const onUploadFile = async (files, _objective, setUploadError) => { @@ -290,14 +302,6 @@ export default function Objective({ } }; - let savedTopics = []; - let savedResources = []; - - if (isOnApprovedReport) { - savedTopics = objective.topics; - savedResources = objective.resources; - } - const resourcesForRepeater = objectiveResources && objectiveResources.length ? objectiveResources : [{ key: uuidv4(), value: '' }]; const onRemove = () => remove(index); @@ -339,6 +343,7 @@ export default function Objective({ permissions={[ createdHere, statusForCalculations !== 'Complete' && statusForCalculations !== 'Suspended', + !onApprovedAR, ]} > { supportType, closeSuspendContext, closeSuspendReason, + objectiveCreatedHere, } = metadata; const objectiveId = objective.dataValues @@ -301,6 +302,7 @@ const cacheObjectiveMetadata = async (objective, reportId, metadata) => { arOrder: order + 1, closeSuspendContext: closeSuspendContext || null, closeSuspendReason: closeSuspendReason || null, + objectiveCreatedHere, }, { where: { id: activityReportObjectiveId }, individualHooks: true, From cb2733f558022682ae25e71ab593b4cafafcb95c Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 17 May 2024 09:19:43 -0400 Subject: [PATCH 23/78] Correct references to moved file --- src/goalServices/mergeGoals.test.js | 2 +- src/goalServices/setActivityReportGoalAsActivelyEdited.test.js | 2 +- src/services/activityReports.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/goalServices/mergeGoals.test.js b/src/goalServices/mergeGoals.test.js index fa5685bff1..2d467dd546 100644 --- a/src/goalServices/mergeGoals.test.js +++ b/src/goalServices/mergeGoals.test.js @@ -25,8 +25,8 @@ import db, { } from '../models'; import { mergeGoals, - getGoalsForReport, } from './goals'; +import getGoalsForReport from './getGoalsForReport'; import { createReport, destroyReport, createGoalTemplate } from '../testUtils'; import { createSimilarityGroup } from '../services/goalSimilarityGroup'; import { diff --git a/src/goalServices/setActivityReportGoalAsActivelyEdited.test.js b/src/goalServices/setActivityReportGoalAsActivelyEdited.test.js index ed160fc6f7..927bb8c8f3 100644 --- a/src/goalServices/setActivityReportGoalAsActivelyEdited.test.js +++ b/src/goalServices/setActivityReportGoalAsActivelyEdited.test.js @@ -2,8 +2,8 @@ import faker from '@faker-js/faker'; import { REPORT_STATUSES } from '@ttahub/common'; import { setActivityReportGoalAsActivelyEdited, - getGoalsForReport, } from './goals'; +import getGoalsForReport from './getGoalsForReport'; import { Goal, ActivityReport, diff --git a/src/services/activityReports.js b/src/services/activityReports.js index 2c2591a30c..c51fe9044c 100644 --- a/src/services/activityReports.js +++ b/src/services/activityReports.js @@ -36,8 +36,8 @@ import { removeUnusedGoalsObjectivesFromReport, saveGoalsForReport, removeRemovedRecipientsGoals, - getGoalsForReport, } from '../goalServices/goals'; +import getGoalsForReport from '../goalServices/getGoalsForReport'; import { getObjectivesByReportId, saveObjectivesForReport } from './objectives'; export async function batchQuery(query, limit) { From c577afb48173f9f16d29ea677ef88a4c0f000753 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 17 May 2024 10:53:04 -0400 Subject: [PATCH 24/78] Complete feature --- .../components/GoalForm/ResourceRepeater.js | 2 +- src/goalServices/getGoalsForReport.ts | 13 +- src/goalServices/goals.js | 160 ------------------ src/goalServices/reduceGoals.ts | 59 +++---- src/goalServices/types.ts | 24 ++- src/routes/goals/handlers.js | 4 +- src/routes/goals/handlers.test.js | 15 +- 7 files changed, 72 insertions(+), 205 deletions(-) diff --git a/frontend/src/components/GoalForm/ResourceRepeater.js b/frontend/src/components/GoalForm/ResourceRepeater.js index e976dcd2cb..f90c56a3a0 100644 --- a/frontend/src/components/GoalForm/ResourceRepeater.js +++ b/frontend/src/components/GoalForm/ResourceRepeater.js @@ -54,7 +54,7 @@ export default function ResourceRepeater({
    - + Did you use any other TTA resources that are available as a link? ; + + if (goalIds.length) { + where = { + id: goalIds, + }; + } + const goals = await Goal.findAll({ + where, attributes: { exclude: ['rtrOrder', 'isRttapa', 'isFromSmartsheetTtaPlan', 'timeframe'], include: [ diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index 04e7ad3b96..308cd279d4 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -213,166 +213,6 @@ export async function saveObjectiveAssociations( }; } -/** - * - * @param {number} id - * @returns {Promise{Object}} - */ -export async function goalsByIdsAndActivityReport(id, activityReportId) { - const goals = await Goal.findAll({ - attributes: [ - 'endDate', - 'status', - ['id', 'value'], - ['name', 'label'], - 'id', - 'name', - ], - where: { - id, - }, - include: [ - { - model: Grant, - as: 'grant', - }, - { - model: Objective, - as: 'objectives', - where: { - [Op.and]: [ - { - title: { - [Op.ne]: '', - }, - }, - { - status: { - [Op.notIn]: [OBJECTIVE_STATUS.COMPLETE, OBJECTIVE_STATUS.SUSPENDED], - }, - }, - ], - }, - attributes: [ - 'id', - ['title', 'label'], - 'title', - 'status', - 'goalId', - 'supportType', - 'onApprovedAR', - 'onAR', - ], - required: false, - include: [ - { - model: Resource, - as: 'resources', - attributes: [ - ['url', 'value'], - ['id', 'key'], - ], - required: false, - through: { - attributes: [], - where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, - required: false, - }, - }, - { - model: ActivityReportObjective, - as: 'activityReportObjectives', - attributes: [ - 'ttaProvided', - 'closeSuspendReason', - 'closeSuspendContext', - ], - required: false, - where: { - activityReportId, - }, - }, - { - model: File, - as: 'files', - }, - { - model: Topic, - as: 'topics', - required: false, - }, - { - model: Course, - as: 'courses', - required: false, - }, - { - model: ActivityReport, - as: 'activityReports', - where: { - calculatedStatus: { - [Op.not]: REPORT_STATUSES.DELETED, - }, - }, - required: false, - }, - ], - }, - { - model: GoalTemplateFieldPrompt, - as: 'prompts', - attributes: [ - 'id', - 'ordinal', - 'title', - 'prompt', - 'hint', - 'fieldType', - 'options', - 'validations', - ], - required: false, - include: [ - { - model: GoalFieldResponse, - as: 'responses', - attributes: [ - 'response', - ], - required: false, - where: { goalId: id }, - }, - { - model: ActivityReportGoalFieldResponse, - as: 'reportResponses', - attributes: ['response'], - required: false, - include: [{ - model: ActivityReportGoal, - as: 'activityReportGoal', - attributes: ['activityReportId', ['id', 'activityReportGoalId']], - required: true, - where: { goalId: id, activityReportId }, - }], - }, - ], - }, - ], - }); - - const reducedGoals = reduceGoals(goals); - - // sort reduced goals by rtr order - reducedGoals.sort((a, b) => { - if (a.rtrOrder < b.rtrOrder) { - return -1; - } - return 1; - }); - - return reducedGoals; -} - /** * * @param {*} goalId diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index 5c6284c66c..eaab527d88 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -6,30 +6,15 @@ import { IGoalModelInstance, IPromptModelInstance, IGoal, + IObjectiveModelInstance, + IFile, + ITopic, + IResource, + ICourse, } from './types'; -interface IAR { - id: number -} - -interface IObjectiveModel { - getDataValue?: (key: string) => number | string | boolean | null; - dataValues?: { - id: number; - value: number; - title: string; - status: string; - otherEntityId: number; - }; - id?: number; - title?: string; - status?: string; - otherEntityId?: number; - activityReports?: IAR[]; -} - // this is the reducer called when not getting objectives for a report, IE, the RTR table -export function reduceObjectives(newObjectives: IObjectiveModel[], currentObjectives = []) { +export function reduceObjectives(newObjectives: IObjectiveModelInstance[], currentObjectives = []) { // objectives = accumulator // we pass in the existing objectives as the accumulator const objectivesToSort = newObjectives.reduce((objectives, objective) => { @@ -93,10 +78,11 @@ export function reduceObjectives(newObjectives: IObjectiveModel[], currentObject * @param {Object} [exists={}] - The existing relation object. * @returns {Array} - The reduced relation array. */ +type IAcceptableModelParameter = ITopic | IResource | ICourse; const reduceRelationThroughActivityReportObjectives = ( - objective, - join, - relation, + objective: IObjectiveModelInstance, + join: string, + relation: string, exists = {}, uniqueBy = 'id', ) => { @@ -106,13 +92,16 @@ const reduceRelationThroughActivityReportObjectives = ( ...(objective.activityReportObjectives && objective.activityReportObjectives.length > 0 ? objective.activityReportObjectives[0][join] - .map((t) => t[relation].dataValues) - .filter((t) => t) + .map((t: IAcceptableModelParameter) => t[relation].dataValues) + .filter((t: IAcceptableModelParameter) => t) : []), - ], (e) => e[uniqueBy]); + ], (e: string) => e[uniqueBy]); }; -export function reduceObjectivesForActivityReport(newObjectives, currentObjectives = []) { +export function reduceObjectivesForActivityReport( + newObjectives: IObjectiveModelInstance[], + currentObjectives = [], +) { const objectivesToSort = newObjectives.reduce((objectives, objective) => { // check the activity report objective status const objectiveStatus = objective.activityReportObjectives @@ -123,12 +112,19 @@ export function reduceObjectivesForActivityReport(newObjectives, currentObjectiv const objectiveSupportType = objective.activityReportObjectives && objective.activityReportObjectives[0] && objective.activityReportObjectives[0].supportType - ? objective.activityReportObjectives[0].supportType : objective.supportType; + ? objective.activityReportObjectives[0].supportType : null; + + const objectiveCreatedHere = objective.activityReportObjectives + && objective.activityReportObjectives[0] + && objective.activityReportObjectives[0].objectiveCreatedHere + ? objective.activityReportObjectives[0].objectiveCreatedHere : null; // objectives represent the accumulator in the find below // objective is the objective as it is returned from the API const exists = objectives.find((o) => ( - o.title.trim() === objective.title.trim() && o.status === objectiveStatus + o.title.trim() === objective.title.trim() + && o.status === objectiveStatus + && o.objectiveCreatedHere === objectiveCreatedHere )); if (exists) { @@ -165,7 +161,7 @@ export function reduceObjectivesForActivityReport(newObjectives, currentObjectiv ? objective.activityReportObjectives[0].activityReportObjectiveFiles .map((f) => ({ ...f.file.dataValues, url: f.file.url })) : []), - ], (e) => e.key); + ], (e: IFile) => e.key); return objectives; } @@ -203,6 +199,7 @@ export function reduceObjectivesForActivityReport(newObjectives, currentObjectiv arOrder, closeSuspendContext, closeSuspendReason, + objectiveCreatedHere, // for the associated models, we need to return not the direct associations // but those associated through an activity report since those reflect the state diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index 056a4106bd..9d689ec7aa 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -57,12 +57,17 @@ interface IResource { } interface IFile { + key: string; originalFileName: string; url: { url: string; } } +interface IFileModelInstance extends IFile { + dataValues?: IFile +} + interface IActivityReportGoal { id: number; goalId: number; @@ -88,6 +93,11 @@ interface IActivityReportObjective { isActivelyEdited: boolean; source: string; arOrder: number; + objectiveCreatedHere: boolean | null; + supportType: string; + ttaProvided: string; + closeSuspendReason: string; + closeSuspendContext: string; activityReportObjectiveTopics: { topic: ITopic; }[]; @@ -96,7 +106,7 @@ interface IActivityReportObjective { resource: IResource; }[]; activityReportObjectiveFiles: { - file: IFile; + file: IFileModelInstance; }[]; activityReportObjectiveCourses: { course: ICourse; @@ -105,7 +115,7 @@ interface IActivityReportObjective { interface IObjective { id: number; - goalId: number; + goalId: number | null; title: string; status: string; onApprovedAR: boolean; @@ -114,6 +124,15 @@ interface IObjective { topics: ITopic[]; resources: IResource[]; files: IFile[]; + otherEntityId: number | null; + activityReports?: { + id: number + }[]; +} + +interface IObjectiveModelInstance extends IObjective { + dataValues?: IObjective + getDataValue?: (key: string) => number | string | boolean | null; } interface IGoal { @@ -172,4 +191,5 @@ export { IGoalModelInstance, IGrantModelInstance, IPromptModelInstance, + IObjectiveModelInstance, }; diff --git a/src/routes/goals/handlers.js b/src/routes/goals/handlers.js index 5be7201e6b..96946d9775 100644 --- a/src/routes/goals/handlers.js +++ b/src/routes/goals/handlers.js @@ -4,7 +4,6 @@ import { updateGoalStatusById, createOrUpdateGoalsForActivityReport, createOrUpdateGoals, - goalsByIdsAndActivityReport, goalByIdWithActivityReportsAndRegions, destroyGoal, mergeGoals, @@ -18,6 +17,7 @@ import Goal from '../../policies/goals'; import { userById } from '../../services/users'; import { currentUserId } from '../../services/currentUser'; import { validateMergeGoalPermissions } from '../utils'; +import getGoalsForReport from '../../goalServices/getGoalsForReport'; const namespace = 'SERVICE:GOALS'; @@ -240,7 +240,7 @@ export async function retrieveGoalsByIds(req, res) { } const gIds = goalIds.map((g) => parseInt(g, 10)); - const retrievedGoal = await goalsByIdsAndActivityReport(gIds, reportId); + const retrievedGoal = await getGoalsForReport(reportId, gIds); if (!retrievedGoal || !retrievedGoal.length) { res.sendStatus(404); diff --git a/src/routes/goals/handlers.test.js b/src/routes/goals/handlers.test.js index e037da474e..d3ed3c2d75 100644 --- a/src/routes/goals/handlers.test.js +++ b/src/routes/goals/handlers.test.js @@ -26,10 +26,10 @@ import { destroyGoal, goalByIdWithActivityReportsAndRegions, createOrUpdateGoalsForActivityReport, - goalsByIdsAndActivityReport, mergeGoals, getGoalIdsBySimilarity, } from '../../goalServices/goals'; +import getGoalsForReport from '../../goalServices/getGoalsForReport'; import nudge from '../../goalServices/nudge'; import { currentUserId } from '../../services/currentUser'; import { validateMergeGoalPermissions } from '../utils'; @@ -46,6 +46,8 @@ jest.mock('../../services/currentUser', () => ({ currentUserId: jest.fn(), })); +jest.mock('../../goalServices/getGoalsForReport'); + jest.mock('../../goalServices/goals', () => ({ updateGoalStatusById: jest.fn(), createOrUpdateGoals: jest.fn(), @@ -53,7 +55,6 @@ jest.mock('../../goalServices/goals', () => ({ goalByIdAndRecipient: jest.fn(), destroyGoal: jest.fn(), createOrUpdateGoalsForActivityReport: jest.fn(), - goalsByIdsAndActivityReport: jest.fn(), goalRegionsById: jest.fn(), mergeGoals: jest.fn(), getGoalIdsBySimilarity: jest.fn(), @@ -742,7 +743,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - goalsByIdsAndActivityReport.mockResolvedValueOnce([ + getGoalsForReport.mockResolvedValueOnce([ { id: 1, name: 'Goal 1', @@ -816,7 +817,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - goalsByIdsAndActivityReport.mockResolvedValueOnce([ + getGoalsForReport.mockResolvedValueOnce([ { id: 1, name: 'Goal 1', @@ -894,7 +895,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - goalsByIdsAndActivityReport.mockResolvedValueOnce([]); + getGoalsForReport.mockResolvedValueOnce([]); await retrieveGoalsByIds(req, mockResponse, true); expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); @@ -925,7 +926,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - goalsByIdsAndActivityReport.mockResolvedValueOnce(null); + getGoalsForReport.mockResolvedValueOnce(null); await retrieveGoalsByIds(req, mockResponse); expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); @@ -956,7 +957,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - goalsByIdsAndActivityReport.mockImplementationOnce(() => { + getGoalsForReport.mockImplementationOnce(() => { throw new Error('a test error for the goals handler'); }); From ae07dc03b096d072e5fe80cdeefcb5e6f96375cb Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 17 May 2024 12:00:21 -0400 Subject: [PATCH 25/78] more fiddling with types --- src/goalServices/goalsByIdAndRecipient.ts | 137 ++------------ src/goalServices/reduceGoals.ts | 53 ++++-- src/goalServices/types.ts | 220 ++++++++++++++++------ 3 files changed, 213 insertions(+), 197 deletions(-) diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index 4bc5d14dc2..323f183dae 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -2,6 +2,15 @@ import db from '../models'; import { CREATION_METHOD } from '../constants'; import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; import { reduceGoals } from './reduceGoals'; +import { + IActivityReportObjectivesFromDB, + ITopicModelInstance, + IResourceModelInstance, + IFileModelInstance, + IGoalForRTRForm, + IReducedObjective, + IGoalForRTRQueryWithReducedObjectives, +} from './types'; const { Goal, @@ -38,122 +47,6 @@ export const OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR = [ 'rtrOrder', ]; -interface ITopicFromDB { - name: string; - toJSON: () => { name: string }; -} - -interface IResourceFromDB { - url: string; - title: string; - toJSON: () => { url: string; title: string } -} - -interface IFileFromDB { - originalFileName: string; - key: string; - toJSON: () => { originalFileName: string; key: string } -} - -interface IActivityReportObjectivesFromDB { - topics: ITopicFromDB[]; - resources: IResourceFromDB[]; - files: IFileFromDB[]; -} - -interface IObjective { - id: number; - title: string; - status: string; - goalId: number; - onApprovedAR: boolean; - onAR: boolean; - rtrOrder: number; - activityReportObjectives: IActivityReportObjectivesFromDB[]; -} - -interface IObjectiveFromDB extends IObjective { - toJSON: () => IObjective; -} - -type IReducedObjective = Omit & { - topics: { - name: string - }[]; - resources: { - url: string; - title: string; - }[]; - files: { - originalFileName: string; - key: string; - }[]; -}; - -interface IGoal { - id: number; - endDate: string; - name: string; - status: string; - regionId: number; - recipientId: number; - goalNumber: string; - createdVia: string; - goalTemplateId: number; - source: string; - onAR: boolean; - onApprovedAR: boolean; - isCurated: boolean; - rtrOrder: number; - statusChanges: { oldStatus: string }[]; - objectives: IObjectiveFromDB[]; - goalCollaborators: { - id: number; - collaboratorType: { name: string }; - user: { - name: string; - userRoles: { - role: { name: string }; - }[]; - }; - }[]; - grant: { - id: number; - number: string; - regionId: number; - recipientId: number; - numberWithProgramTypes: string; - programs: { programType: string }[]; - }; - goalTemplateFieldPrompts: { - promptId: number; - ordinal: number; - title: string; - prompt: string; - hint: string; - fieldType: string; - options: string; - validations: string; - responses: { response: string }[]; - reportResponses: { - response: string; - activityReportGoal: { - activityReportId: number; - activityReportGoalId: number; - }; - }[]; - }[]; -} - -interface IGoalForForm extends IGoal { - toJSON: () => IGoal; -} - -type IGoalWithReducedObjectives = Omit & { - isReopenedGoal: boolean; - objectives: IReducedObjective[]; -}; - const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) => ({ attributes: [ 'endDate', @@ -341,14 +234,16 @@ function extractObjectiveAssociationsFromActivityReportObjectives( activityReportObjectives: IActivityReportObjectivesFromDB[], associationName: 'topics' | 'resources' | 'files' | 'courses', ) { - return activityReportObjectives.map((aro) => aro[associationName].map((a: - ITopicFromDB | IResourceFromDB | IFileFromDB) => a.toJSON())).flat(); + return activityReportObjectives.map((aro) => aro[associationName].map(( + a: ITopicModelInstance | IResourceModelInstance | IFileModelInstance, + ) => a.toJSON())).flat(); } export default async function goalsByIdAndRecipient(ids: number | number[], recipientId: number) { - const goals = await Goal.findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalForForm[]; + const goals = await Goal + .findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalForRTRForm[]; - const reformattedGoals = goals.map((goal: IGoalForForm) => ({ + const reformattedGoals = goals.map((goal: IGoalForRTRForm) => ({ ...goal, isReopenedGoal: wasGoalPreviouslyClosed(goal), objectives: goal.objectives @@ -371,7 +266,7 @@ export default async function goalsByIdAndRecipient(ids: number | number[], reci 'files', ), } as unknown as IReducedObjective)), // Convert to 'unknown' first - })) as IGoalWithReducedObjectives[]; + })) as IGoalForRTRQueryWithReducedObjectives[]; return reduceGoals(reformattedGoals); } diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index eaab527d88..fca58775fe 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -11,25 +11,37 @@ import { ITopic, IResource, ICourse, + IGoalForRTRQueryWithReducedObjectives, + IReducedObjective, } from './types'; +type ObjectivesForReducer = IObjectiveModelInstance[] | IReducedObjective[]; + // this is the reducer called when not getting objectives for a report, IE, the RTR table -export function reduceObjectives(newObjectives: IObjectiveModelInstance[], currentObjectives = []) { +export function reduceObjectives( + newObjectives: ObjectivesForReducer, + currentObjectives = [], +) { // objectives = accumulator // we pass in the existing objectives as the accumulator - const objectivesToSort = newObjectives.reduce((objectives, objective) => { + const objectivesToSort = newObjectives.reduce(( + objectives: ObjectivesForReducer, + objective, + ) => { const exists = objectives.find((o) => ( o.title.trim() === objective.title.trim() && o.status === objective.status - )); - // eslint-disable-next-line no-nested-ternary - const id = objective.getDataValue - ? objective.getDataValue('id') - ? objective.getDataValue('id') - : objective.getDataValue('value') - : objective.id; - const otherEntityId = objective.getDataValue - ? objective.getDataValue('otherEntityId') - : objective.otherEntityId; + )) as IReducedObjective | undefined; + + const { + id, + otherEntityId, + title, + status, + topics, + resources, + files, + courses, + } = objective; if (exists) { exists.ids = [...exists.ids, id]; @@ -45,10 +57,14 @@ export function reduceObjectives(newObjectives: IObjectiveModelInstance[], curre } return [...objectives, { - ...(objective.dataValues - ? objective.dataValues - : objective), - title: objective.title.trim(), + id, + otherEntityId, + title, + status, + topics, + resources, + files, + courses, value: id, ids: [id], // Make sure we pass back a list of recipient ids for subsequent saves. @@ -335,7 +351,10 @@ function reducePrompts( * @param {Object[]} goals * @returns {Object[]} array of deduped goals */ -export function reduceGoals(goals: IGoalModelInstance[], forReport = false) { +export function reduceGoals( + goals: IGoalModelInstance[] | IGoalForRTRQueryWithReducedObjectives[], + forReport = false, +) { const objectivesReducer = forReport ? reduceObjectivesForActivityReport : reduceObjectives; const where = (g: IGoalModelInstance, currentValue) => (forReport diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index 9d689ec7aa..e91b72ad49 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -1,49 +1,3 @@ -interface IGrant { - id: number; - regionId: number; - status: string; - startDate: string; - endDate: string; - oldGrantId: number; - recipientId: number; - numberWithProgramTypes: string; - number: string; - name: string; - recipient: { - name: string; - id: number; - dataValues?: { - name: string; - id: number; - } - } -} - -interface IGrantModelInstance extends IGrant { - dataValues?: IGrant -} - -interface IPrompt { - responses?: { - response: string[]; - }[]; - promptId?: number; - ordinal: number; - title: string; - prompt: string; - hint: string; - fieldType: string; - options: string; - validations: string; - response: string[]; - reportResponse: string[]; - allGoalsHavePromptResponse: boolean; -} - -interface IPromptModelInstance extends IPrompt { - dataValues?: IPrompt; -} - interface ICourse { name: string; } @@ -64,21 +18,28 @@ interface IFile { } } +interface IPromptModelInstance extends IPrompt { + dataValues?: IPrompt; + toJSON?: () => IPrompt; +} + +interface ITopicModelInstance extends ITopic { + dataValues?: ITopic; + toJSON?: () => ITopic; +} interface IFileModelInstance extends IFile { dataValues?: IFile + toJSON?: () => IFile; } -interface IActivityReportGoal { - id: number; - goalId: number; - activityReportId: number; - createdAt: Date; - updatedAt: Date; - name: string; - status: string; - endDate: string; - isActivelyEdited: boolean; - source: string; +interface IResourceModelInstance extends IResource { + dataValues?: IResource; + toJSON?: () => IResource; +} + +interface ICourseModelInstance extends ICourse { + dataValues?: ICourse; + toJSON?: () => ICourse; } interface IActivityReportObjective { @@ -113,17 +74,79 @@ interface IActivityReportObjective { }[]; } +interface IActivityReportObjectivesFromDB extends IActivityReportObjective { + toJSON: () => IActivityReportObjective; + dataValues?: IActivityReportObjective; +} + +interface IGrant { + id: number; + regionId: number; + status: string; + startDate: string; + endDate: string; + oldGrantId: number; + recipientId: number; + numberWithProgramTypes: string; + number: string; + name: string; + recipient: { + name: string; + id: number; + dataValues?: { + name: string; + id: number; + } + } +} + +interface IGrantModelInstance extends IGrant { + dataValues?: IGrant +} + +interface IPrompt { + responses?: { + response: string[]; + }[]; + promptId?: number; + ordinal: number; + title: string; + prompt: string; + hint: string; + fieldType: string; + options: string; + validations: string; + response: string[]; + reportResponse: string[]; + allGoalsHavePromptResponse: boolean; +} + +interface IActivityReportGoal { + id: number; + goalId: number; + activityReportId: number; + createdAt: Date; + updatedAt: Date; + name: string; + status: string; + endDate: string; + isActivelyEdited: boolean; + source: string; +} + interface IObjective { id: number; - goalId: number | null; title: string; status: string; + goalId: number; onApprovedAR: boolean; onAR: boolean; - activityReportObjectives: IActivityReportObjective[]; + rtrOrder: number; + activityReportObjectives: IActivityReportObjectivesFromDB[]; topics: ITopic[]; resources: IResource[]; files: IFile[]; + courses: ICourse[]; otherEntityId: number | null; activityReports?: { id: number @@ -133,8 +156,78 @@ interface IObjective { interface IObjectiveModelInstance extends IObjective { dataValues?: IObjective getDataValue?: (key: string) => number | string | boolean | null; + toJSON?: () => IObjective; +} + +type IReducedObjective = Omit & { + topics: ITopic[]; + resources: IResource[]; + files: IFile[]; + courses: ICourse[]; + ids: number[]; + recipientIds?: number[]; + otherEntityId?: number; +}; + +interface IGoalForRTRQuery { + id: number; + endDate: string; + name: string; + status: string; + regionId: number; + recipientId: number; + goalNumber: string; + createdVia: string; + goalTemplateId: number; + source: string; + onAR: boolean; + onApprovedAR: boolean; + isCurated: boolean; + rtrOrder: number; + statusChanges: { oldStatus: string }[] + objectives: IObjectiveModelInstance[]; + goalCollaborators: { + id: number; + collaboratorType: { name: string }; + user: { + name: string; + userRoles: { + role: { name: string }; + }[]; + }; + }[]; + grant: IGrantModelInstance; + prompts: IPromptModelInstance[]; + goalTemplateFieldPrompts: { + promptId: number; + ordinal: number; + title: string; + prompt: string; + hint: string; + fieldType: string; + options: string; + validations: string; + responses: { response: string }[]; + reportResponses: { + response: string; + activityReportGoal: { + activityReportId: number; + activityReportGoalId: number; + }; + }[]; + }[]; } +interface IGoalForRTRForm extends IGoalForRTRQuery { + toJSON: () => IGoalForRTRQuery; + dataValues: IGoalForRTRQuery; +} + +type IGoalForRTRQueryWithReducedObjectives = Omit & { + isReopenedGoal: boolean; + objectives: IReducedObjective[]; +}; + interface IGoal { id: number; name: string; @@ -191,5 +284,14 @@ export { IGoalModelInstance, IGrantModelInstance, IPromptModelInstance, + ICourseModelInstance, + ITopicModelInstance, + IResourceModelInstance, + IFileModelInstance, IObjectiveModelInstance, + IActivityReportObjectivesFromDB, + // -- for the rtr query, slightly distinct types are used -- // + IGoalForRTRQueryWithReducedObjectives, + IGoalForRTRForm, + IReducedObjective, }; From a5b9b4f82cad8544f0d6a6cb0c5edb4707d91fde Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 17 May 2024 16:05:55 -0400 Subject: [PATCH 26/78] More type finagling --- src/goalServices/reduceGoals.ts | 9 ++++++--- src/goalServices/types.ts | 8 +++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index fca58775fe..9a7ed1684a 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -362,7 +362,10 @@ export function reduceGoals( : g.name === currentValue.dataValues.name && g.status === currentValue.dataValues.status); - function getGoalCollaboratorDetails(collabType: string, dataValues: IGoal) { + function getGoalCollaboratorDetails( + collabType: string, + dataValues: IGoal | IGoalForRTRQueryWithReducedObjectives, + ) { // eslint-disable-next-line max-len const collaborator = dataValues.goalCollaborators?.find((gc) => gc.collaboratorType.name === collabType); return { @@ -400,8 +403,8 @@ export function reduceGoals( ...existingGoal.collaborators, { goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, - ...getGoalCollaboratorDetails('Creator', currentValue.dataValues), - ...getGoalCollaboratorDetails('Linker', currentValue.dataValues), + ...getGoalCollaboratorDetails('Creator', currentValue.dataValues as IGoal), + ...getGoalCollaboratorDetails('Linker', currentValue.dataValues as IGoal), }, ], 'goalCreatorName'); diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index e91b72ad49..9549a6ba01 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -142,7 +142,7 @@ interface IObjective { onApprovedAR: boolean; onAR: boolean; rtrOrder: number; - activityReportObjectives: IActivityReportObjectivesFromDB[]; + activityReportObjectives?: IActivityReportObjectivesFromDB[]; topics: ITopic[]; resources: IResource[]; files: IFile[]; @@ -186,6 +186,9 @@ interface IGoalForRTRQuery { rtrOrder: number; statusChanges: { oldStatus: string }[] objectives: IObjectiveModelInstance[]; + collaborators?:{ + [key: string]: string; + }[]; goalCollaborators: { id: number; collaboratorType: { name: string }; @@ -226,6 +229,8 @@ interface IGoalForRTRForm extends IGoalForRTRQuery { type IGoalForRTRQueryWithReducedObjectives = Omit & { isReopenedGoal: boolean; objectives: IReducedObjective[]; + toJSON: () => IGoalForRTRQueryWithReducedObjectives; + dataValues: IGoalForRTRQueryWithReducedObjectives; }; interface IGoal { @@ -267,6 +272,7 @@ interface IGoal { interface IGoalModelInstance extends IGoal { dataValues?: IGoal + toJSON?: () => IGoal; } export { From f3c71510bf0511644078e5085a8a64e3e796cc56 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 10:43:17 -0400 Subject: [PATCH 27/78] Finish typescript conversion; time to iron out bugs --- src/goalServices/getGoalsForReport.ts | 140 ++++++++----- src/goalServices/goals.js | 4 - src/goalServices/goalsByIdAndRecipient.ts | 20 +- src/goalServices/reduceGoals.ts | 205 ++++++++++--------- src/goalServices/types.ts | 236 +++++++++++----------- 5 files changed, 319 insertions(+), 286 deletions(-) diff --git a/src/goalServices/getGoalsForReport.ts b/src/goalServices/getGoalsForReport.ts index f74c204f8d..aba71fe8f9 100644 --- a/src/goalServices/getGoalsForReport.ts +++ b/src/goalServices/getGoalsForReport.ts @@ -14,11 +14,15 @@ const { GoalTemplate, Grant, Objective, + GoalStatusChange, ActivityReportObjective, ActivityReportObjectiveTopic, ActivityReportObjectiveFile, ActivityReportObjectiveResource, ActivityReportObjectiveCourse, + GoalTemplateFieldPrompt, + GoalFieldResponse, + ActivityReportGoalFieldResponse, sequelize, Resource, ActivityReportGoal, @@ -38,38 +42,93 @@ export default async function getGoalsForReport(reportId: number, goalIds: numbe const goals = await Goal.findAll({ where, - attributes: { - exclude: ['rtrOrder', 'isRttapa', 'isFromSmartsheetTtaPlan', 'timeframe'], - include: [ - [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], - [sequelize.literal(`( - SELECT - jsonb_agg( DISTINCT jsonb_build_object( - 'promptId', gtfp.id , - 'ordinal', gtfp.ordinal, - 'title', gtfp.title, - 'prompt', gtfp.prompt, - 'hint', gtfp.hint, - 'caution', gtfp.caution, - 'fieldType', gtfp."fieldType", - 'options', gtfp.options, - 'validations', gtfp.validations, - 'response', gfr.response, - 'reportResponse', argfr.response - )) - FROM "GoalTemplateFieldPrompts" gtfp - LEFT JOIN "GoalFieldResponses" gfr - ON gtfp.id = gfr."goalTemplateFieldPromptId" - AND gfr."goalId" = "Goal".id - LEFT JOIN "ActivityReportGoalFieldResponses" argfr - ON gtfp.id = argfr."goalTemplateFieldPromptId" - AND argfr."activityReportGoalId" = "activityReportGoals".id - WHERE "goalTemplate".id = gtfp."goalTemplateId" - GROUP BY TRUE - )`), 'prompts'], - ], - }, + attributes: [ + 'id', + 'name', + 'endDate', + 'status', + 'grantId', + 'createdVia', + 'source', + 'onAR', + 'onApprovedAR', + 'goalNumber', + 'goalTemplateId', + 'rtrOrder', + [sequelize.col('grant.regionId'), 'regionId'], + [sequelize.col('grant.recipient.id'), 'recipientId'], + [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], + // [sequelize.literal(`( + // SELECT + // jsonb_agg( DISTINCT jsonb_build_object( + // 'promptId', gtfp.id , + // 'ordinal', gtfp.ordinal, + // 'title', gtfp.title, + // 'prompt', gtfp.prompt, + // 'hint', gtfp.hint, + // 'caution', gtfp.caution, + // 'fieldType', gtfp."fieldType", + // 'options', gtfp.options, + // 'validations', gtfp.validations, + // 'response', gfr.response, + // 'reportResponse', argfr.response + // )) + // FROM "GoalTemplateFieldPrompts" gtfp + // LEFT JOIN "GoalFieldResponses" gfr + // ON gtfp.id = gfr."goalTemplateFieldPromptId" + // AND gfr."goalId" = "Goal".id + // LEFT JOIN "ActivityReportGoalFieldResponses" argfr + // ON gtfp.id = argfr."goalTemplateFieldPromptId" + // AND argfr."activityReportGoalId" = "activityReportGoals".id + // WHERE "goalTemplate".id = gtfp."goalTemplateId" + // GROUP BY TRUE + // )`), 'prompts'], + ], include: [ + { + model: GoalTemplateFieldPrompt, + as: 'prompts', + attributes: [ + ['id', 'promptId'], + 'ordinal', + 'title', + 'prompt', + 'hint', + 'fieldType', + 'options', + 'validations', + ], + required: false, + include: [ + { + model: GoalFieldResponse, + as: 'responses', + attributes: ['response'], + required: false, + }, + { + model: ActivityReportGoalFieldResponse, + as: 'reportResponses', + attributes: ['response'], + required: false, + include: [{ + model: ActivityReportGoal, + as: 'activityReportGoal', + attributes: ['activityReportId', ['id', 'activityReportGoalId']], + required: true, + where: { + reportId, + }, + }], + }, + ], + }, + { + model: GoalStatusChange, + as: 'statusChanges', + attributes: ['oldStatus'], + required: false, + }, { model: GoalTemplate, as: 'goalTemplate', @@ -155,25 +214,6 @@ export default async function getGoalsForReport(reportId: number, goalIds: numbe }, ], }, - { - model: Topic, - as: 'topics', - }, - { - model: Resource, - as: 'resources', - attributes: [['url', 'value']], - through: { - attributes: [], - where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, - required: false, - }, - required: false, - }, - { - model: File, - as: 'files', - }, ], }, ], diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index 308cd279d4..c1319e88f9 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -12,7 +12,6 @@ import { GoalTemplate, GoalResource, GoalStatusChange, - GoalTemplateFieldPrompt, Grant, Objective, ObjectiveCourse, @@ -25,9 +24,7 @@ import { ActivityReport, ActivityReportGoal, ActivityRecipient, - ActivityReportGoalFieldResponse, Topic, - Course, File, } from '../models'; import { @@ -58,7 +55,6 @@ import changeGoalStatus from './changeGoalStatus'; import goalsByIdAndRecipient, { OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, } from './goalsByIdAndRecipient'; -import { reduceGoals } from './reduceGoals'; import getGoalsForReport from './getGoalsForReport'; const namespace = 'SERVICE:GOALS'; diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index 323f183dae..07853b2d28 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -3,13 +3,12 @@ import { CREATION_METHOD } from '../constants'; import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; import { reduceGoals } from './reduceGoals'; import { - IActivityReportObjectivesFromDB, ITopicModelInstance, IResourceModelInstance, IFileModelInstance, - IGoalForRTRForm, - IReducedObjective, - IGoalForRTRQueryWithReducedObjectives, + IGoalModelInstance, + IObjectiveModelInstance, + IActivityReportObjectivesModelInstance, } from './types'; const { @@ -62,6 +61,7 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], 'rtrOrder', 'createdVia', + 'goalTemplateId', ], order: [['rtrOrder', 'asc']], where: { @@ -231,7 +231,7 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) }); function extractObjectiveAssociationsFromActivityReportObjectives( - activityReportObjectives: IActivityReportObjectivesFromDB[], + activityReportObjectives: IActivityReportObjectivesModelInstance[], associationName: 'topics' | 'resources' | 'files' | 'courses', ) { return activityReportObjectives.map((aro) => aro[associationName].map(( @@ -241,13 +241,13 @@ function extractObjectiveAssociationsFromActivityReportObjectives( export default async function goalsByIdAndRecipient(ids: number | number[], recipientId: number) { const goals = await Goal - .findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalForRTRForm[]; + .findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalModelInstance[]; - const reformattedGoals = goals.map((goal: IGoalForRTRForm) => ({ + const reformattedGoals = goals.map((goal) => ({ ...goal, isReopenedGoal: wasGoalPreviouslyClosed(goal), objectives: goal.objectives - .map((objective) => ({ + .map((objective: IObjectiveModelInstance) => ({ ...objective.toJSON(), topics: extractObjectiveAssociationsFromActivityReportObjectives( objective.activityReportObjectives, @@ -265,8 +265,8 @@ export default async function goalsByIdAndRecipient(ids: number | number[], reci objective.activityReportObjectives, 'files', ), - } as unknown as IReducedObjective)), // Convert to 'unknown' first - })) as IGoalForRTRQueryWithReducedObjectives[]; + })), // Convert to 'unknown' first + })); return reduceGoals(reformattedGoals); } diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index 9a7ed1684a..dd39ea8520 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -4,28 +4,26 @@ import { auditLogger } from '../logger'; import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; import { IGoalModelInstance, - IPromptModelInstance, IGoal, IObjectiveModelInstance, IFile, ITopic, IResource, ICourse, - IGoalForRTRQueryWithReducedObjectives, + IReducedGoal, IReducedObjective, + IPrompt, } from './types'; -type ObjectivesForReducer = IObjectiveModelInstance[] | IReducedObjective[]; - // this is the reducer called when not getting objectives for a report, IE, the RTR table export function reduceObjectives( - newObjectives: ObjectivesForReducer, - currentObjectives = [], + newObjectives: IObjectiveModelInstance[], + currentObjectives: IReducedObjective[], ) { // objectives = accumulator // we pass in the existing objectives as the accumulator const objectivesToSort = newObjectives.reduce(( - objectives: ObjectivesForReducer, + objectives: IReducedObjective[], objective, ) => { const exists = objectives.find((o) => ( @@ -56,7 +54,7 @@ export function reduceObjectives( return objectives; } - return [...objectives, { + const newObjective = { id, otherEntityId, title, @@ -67,12 +65,21 @@ export function reduceObjectives( courses, value: id, ids: [id], + goalId: objective.goalId, + onApprovedAR: objective.onApprovedAR, + onAR: objective.onAR, + rtrOrder: objective.rtrOrder, // Make sure we pass back a list of recipient ids for subsequent saves. recipientIds: otherEntityId ? [otherEntityId] : [], isNew: false, - }]; + } as IReducedObjective; + + return [ + ...objectives, + newObjective, + ]; }, currentObjectives); objectivesToSort.sort((o1, o2) => { @@ -259,15 +266,13 @@ export function reduceObjectivesForActivityReport( /** * - * @param {Boolean} forReport * @param {Array} newPrompts * @param {Array} promptsToReduce * @returns Array of reduced prompts */ function reducePrompts( - forReport: boolean, - newPrompts: IPromptModelInstance[] = [], - promptsToReduce: IPromptModelInstance[] = [], + newPrompts: IPrompt[] = [], + promptsToReduce: IPrompt[] = [], ) { return newPrompts ?.reduce((previousPrompts, currentPrompt) => { @@ -276,32 +281,24 @@ function reducePrompts( const existingPrompt = previousPrompts.find((pp) => pp.promptId === currentPrompt.promptId); if (existingPrompt) { - if (!forReport) { - existingPrompt.response = uniq( - [...existingPrompt.response, ...currentPrompt.responses.flatMap((r) => r.response)], - ); - } + existingPrompt.response = uniq( + [ + ...existingPrompt.response, + ...(currentPrompt.response || []), + ...(currentPrompt.reportResponse || []), + ], + ); + existingPrompt.reportResponse = uniq( + [ + ...(existingPrompt.reportResponse || []), + ...(currentPrompt.reportResponse || []), + ], + ); - if (forReport) { - existingPrompt.response = uniq( - [ - ...existingPrompt.response, - ...(currentPrompt.response || []), - ...(currentPrompt.reportResponse || []), - ], - ); - existingPrompt.reportResponse = uniq( - [ - ...(existingPrompt.reportResponse || []), - ...(currentPrompt.reportResponse || []), - ], - ); - - if (existingPrompt.allGoalsHavePromptResponse && (currentPrompt.response || []).length) { - existingPrompt.allGoalsHavePromptResponse = true; - } else { - existingPrompt.allGoalsHavePromptResponse = false; - } + if (existingPrompt.allGoalsHavePromptResponse && (currentPrompt.response || []).length) { + existingPrompt.allGoalsHavePromptResponse = true; + } else { + existingPrompt.allGoalsHavePromptResponse = false; } return previousPrompts; @@ -319,24 +316,18 @@ function reducePrompts( allGoalsHavePromptResponse: false, response: [], reportResponse: [], - }; - - if (forReport) { - newPrompt.response = uniq( - [ - ...(currentPrompt.response || []), - ...(currentPrompt.reportResponse || []), - ], - ); - newPrompt.reportResponse = (currentPrompt.reportResponse || []); + } as IPrompt; - if (newPrompt.response.length) { - newPrompt.allGoalsHavePromptResponse = true; - } - } + newPrompt.response = uniq( + [ + ...(currentPrompt.response || []), + ...(currentPrompt.reportResponse || []), + ], + ); + newPrompt.reportResponse = (currentPrompt.reportResponse || []); - if (!forReport) { - newPrompt.response = uniq(currentPrompt.responses.flatMap((r) => r.response)); + if (newPrompt.response.length) { + newPrompt.allGoalsHavePromptResponse = true; } return [ @@ -352,19 +343,19 @@ function reducePrompts( * @returns {Object[]} array of deduped goals */ export function reduceGoals( - goals: IGoalModelInstance[] | IGoalForRTRQueryWithReducedObjectives[], + goals: IGoalModelInstance[], forReport = false, -) { +): IReducedGoal[] { const objectivesReducer = forReport ? reduceObjectivesForActivityReport : reduceObjectives; - const where = (g: IGoalModelInstance, currentValue) => (forReport + const where = (g: IReducedGoal, currentValue: IGoalModelInstance) => (forReport ? g.name === currentValue.dataValues.name : g.name === currentValue.dataValues.name && g.status === currentValue.dataValues.status); function getGoalCollaboratorDetails( collabType: string, - dataValues: IGoal | IGoalForRTRQueryWithReducedObjectives, + dataValues: IGoalModelInstance, ) { // eslint-disable-next-line max-len const collaborator = dataValues.goalCollaborators?.find((gc) => gc.collaboratorType.name === collabType); @@ -375,7 +366,7 @@ export function reduceGoals( }; } - const r = goals.reduce((previousValues, currentValue) => { + const r = goals.reduce((previousValues: IReducedGoal[], currentValue: IGoalModelInstance) => { try { const existingGoal = previousValues.find((g) => where(g, currentValue)); if (existingGoal) { @@ -405,31 +396,27 @@ export function reduceGoals( goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, ...getGoalCollaboratorDetails('Creator', currentValue.dataValues as IGoal), ...getGoalCollaboratorDetails('Linker', currentValue.dataValues as IGoal), + } as { + goalNumber: string; + goalCreatorName: string; + goalCreatorRoles: string; }, ], 'goalCreatorName'); existingGoal.isReopenedGoal = wasGoalPreviouslyClosed(existingGoal); - if (forReport) { - existingGoal.prompts = reducePrompts( - forReport, + existingGoal.prompts = { + ...existingGoal.prompts, + [currentValue.grant.numberWithProgramTypes]: reducePrompts( currentValue.dataValues.prompts || [], - existingGoal.prompts || [], - ); - } else { - existingGoal.prompts = { - ...existingGoal.prompts, - [currentValue.grant.numberWithProgramTypes]: reducePrompts( - forReport, - currentValue.dataValues.prompts || [], - [], // we don't want to combine existing prompts if reducing for the RTR - ), - }; - existingGoal.source = { - ...existingGoal.source, - [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, - }; - } + [], // we don't want to combine existing prompts if reducing for the RTR + ), + }; + existingGoal.source = { + ...existingGoal.source, + [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, + }; + return previousValues; } @@ -443,30 +430,45 @@ export function reduceGoals( return date; })(); - let { source } = currentValue.dataValues; const prompts = reducePrompts( - forReport, currentValue.dataValues.prompts || [], [], ); - let promptsIfNotForReport = {}; + const updatedSource = { + [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, + } as { + [key: string]: string; + }; - if (!forReport) { - source = { - [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, - } as { - [key: string]: string; - }; - promptsIfNotForReport = { - [currentValue.grant.numberWithProgramTypes]: prompts, - } as { - [key: string]: IPromptModelInstance[]; - }; - } + const updatedPrompts = { + [currentValue.grant.numberWithProgramTypes]: prompts, + }; const goal = { - ...currentValue.dataValues, + isCurated: currentValue.isCurated, + goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, + grantId: currentValue.grant.id, + collaborators: currentValue.collaborators, + id: currentValue.id, + name: currentValue.name, + endDate, + status: currentValue.status, + regionId: currentValue.grant.regionId, + recipientId: currentValue.grant.recipientId, + goalTemplateId: currentValue.goalTemplateId, + createdVia: currentValue.createdVia, + source: updatedSource, + prompts: updatedPrompts, + isNew: false, + onAR: currentValue.onAR, + onApprovedAR: currentValue.onApprovedAR, + rtrOrder: currentValue.rtrOrder, + isReopenedGoal: wasGoalPreviouslyClosed(currentValue), + goalCollaborators: currentValue.goalCollaborators, + objectives: objectivesReducer( + currentValue.objectives, + ), goalNumbers: [currentValue.goalNumber || `G-${currentValue.dataValues.id}`], goalIds: [currentValue.dataValues.id], grants: [ @@ -479,21 +481,18 @@ export function reduceGoals( }, ], grantIds: [currentValue.grant.id], - objectives: objectivesReducer( - currentValue.objectives, - ), - prompts: forReport ? prompts : promptsIfNotForReport, - isNew: false, - endDate, - source, - isReopenedGoal: wasGoalPreviouslyClosed(currentValue), - }; + statusChanges: currentValue.statusChanges, + } as IReducedGoal; goal.collaborators = [ { goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, ...getGoalCollaboratorDetails('Creator', currentValue.dataValues), ...getGoalCollaboratorDetails('Linker', currentValue.dataValues), + } as { + goalNumber: string; + goalCreatorName: string; + goalCreatorRoles: string; }, ]; diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index 9549a6ba01..0aa88b9633 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -1,13 +1,32 @@ -interface ICourse { - name: string; +interface IPrompt { + ordinal: number; + title: string; + prompt: string; + hint: string; + fieldType: string; + options: string; + validations: string; + promptId?: number; + response?: string[]; + responses?: { + response: string[]; + }[]; + reportResponse?: string[]; + reportResponses?: { + response: string[]; + }[]; + dataValues?: IPrompt; + toJSON?: () => IPrompt; + allGoalsHavePromptResponse?: boolean; } interface ITopic { name: string; } -interface IResource { - value: string; +interface ITopicModelInstance extends ITopic { + dataValues?: ITopic; + toJSON?: () => ITopic; } interface IFile { @@ -18,25 +37,24 @@ interface IFile { } } -interface IPromptModelInstance extends IPrompt { - dataValues?: IPrompt; - toJSON?: () => IPrompt; -} - -interface ITopicModelInstance extends ITopic { - dataValues?: ITopic; - toJSON?: () => ITopic; -} interface IFileModelInstance extends IFile { dataValues?: IFile toJSON?: () => IFile; } +interface IResource { + value: string; +} + interface IResourceModelInstance extends IResource { dataValues?: IResource; toJSON?: () => IResource; } +interface ICourse { + name: string; +} + interface ICourseModelInstance extends ICourse { dataValues?: ICourse; toJSON?: () => ICourse; @@ -74,7 +92,7 @@ interface IActivityReportObjective { }[]; } -interface IActivityReportObjectivesFromDB extends IActivityReportObjective { +interface IActivityReportObjectivesModelInstance extends IActivityReportObjective { toJSON: () => IActivityReportObjective; dataValues?: IActivityReportObjective; } @@ -98,29 +116,13 @@ interface IGrant { id: number; } } + goalId?: number; } interface IGrantModelInstance extends IGrant { dataValues?: IGrant } -interface IPrompt { - responses?: { - response: string[]; - }[]; - promptId?: number; - ordinal: number; - title: string; - prompt: string; - hint: string; - fieldType: string; - options: string; - validations: string; - response: string[]; - reportResponse: string[]; - allGoalsHavePromptResponse: boolean; -} - interface IActivityReportGoal { id: number; goalId: number; @@ -131,7 +133,12 @@ interface IActivityReportGoal { status: string; endDate: string; isActivelyEdited: boolean; - source: string; + source: string | { + [key: string]: string; + }; + closeSuspendReason: string; + closeSuspendContext: string; + originalGoalId: number; } interface IObjective { @@ -142,15 +149,20 @@ interface IObjective { onApprovedAR: boolean; onAR: boolean; rtrOrder: number; - activityReportObjectives?: IActivityReportObjectivesFromDB[]; - topics: ITopic[]; - resources: IResource[]; - files: IFile[]; - courses: ICourse[]; + activityReportObjectives?: IActivityReportObjectivesModelInstance[]; otherEntityId: number | null; activityReports?: { id: number }[]; + grantId?: number; + supportType?: string; + onAnyReport?: boolean; + closeSuspendReason?: string; + closeSuspendContext?: string; + topics: ITopic[]; + resources: IResource[]; + files: IFile[]; + courses: ICourse[]; } interface IObjectiveModelInstance extends IObjective { @@ -169,70 +181,23 @@ type IReducedObjective = Omit & { otherEntityId?: number; }; -interface IGoalForRTRQuery { +interface IGoalCollaborator { id: number; - endDate: string; - name: string; - status: string; - regionId: number; - recipientId: number; - goalNumber: string; - createdVia: string; - goalTemplateId: number; - source: string; - onAR: boolean; - onApprovedAR: boolean; - isCurated: boolean; - rtrOrder: number; - statusChanges: { oldStatus: string }[] - objectives: IObjectiveModelInstance[]; - collaborators?:{ - [key: string]: string; - }[]; - goalCollaborators: { - id: number; - collaboratorType: { name: string }; - user: { - name: string; - userRoles: { - role: { name: string }; - }[]; - }; - }[]; - grant: IGrantModelInstance; - prompts: IPromptModelInstance[]; - goalTemplateFieldPrompts: { - promptId: number; - ordinal: number; - title: string; - prompt: string; - hint: string; - fieldType: string; - options: string; - validations: string; - responses: { response: string }[]; - reportResponses: { - response: string; - activityReportGoal: { - activityReportId: number; - activityReportGoalId: number; + collaboratorType: { + name: string; + mapsToCollaboratorType: string; + }; + user: { + name: string; + userRoles: { + id: number; + role: { + name: string; }; }[]; - }[]; -} - -interface IGoalForRTRForm extends IGoalForRTRQuery { - toJSON: () => IGoalForRTRQuery; - dataValues: IGoalForRTRQuery; + }; } -type IGoalForRTRQueryWithReducedObjectives = Omit & { - isReopenedGoal: boolean; - objectives: IReducedObjective[]; - toJSON: () => IGoalForRTRQueryWithReducedObjectives; - dataValues: IGoalForRTRQueryWithReducedObjectives; -}; - interface IGoal { id: number; name: string; @@ -240,34 +205,69 @@ interface IGoal { isCurated: boolean; grantId: number; createdVia: string; - source: string | { - [key: string]: string, - }; + source: string; + goalTemplateId: number; onAR: boolean; onApprovedAR: boolean; - prompts: IPromptModelInstance[]; + prompts: IPrompt[]; activityReportGoals: IActivityReportGoal[]; objectives: IObjective[]; grant: IGrantModelInstance; status: string; goalNumber: string; - statusChanges?: { oldStatus: string }[] - goalCollaborators?: { - collaboratorType: { - name: string; - }; - user: { - name: string; - userRoles: { - role: { - name: string; - } - }[] - } + statusChanges?: { oldStatus: string }[]; + rtrOrder: number; + goalCollaborators: IGoalCollaborator[]; + goalNumbers: string[]; + goalIds: number[]; + grants: IGrant[]; + grantIds: number[]; + isNew: boolean; + isReopenedGoal: boolean; + collaborators: { + goalNumber: string; + goalCreator: IGoalCollaborator; + goalCreatorName: string; + goalCreatorRoles: string; }[]; - collaborators?: { +} + +interface IReducedGoal { + id: number; + name: string; + endDate: string; + status: string; + regionId: number; + recipientId: number; + goalTemplateId: number; + createdVia: string; + source: { [key: string]: string; + }; + onAR: boolean; + onApprovedAR: boolean; + isCurated: boolean; + rtrOrder: number; + goalCollaborators: IGoalCollaborator[]; + objectives: IReducedObjective[]; + prompts : { + [x: string]: IPrompt[]; + }; + statusChanges?: { oldStatus: string }[]; + goalNumber: string; + goalNumbers: string[]; + goalIds: number[]; + grants: IGrant[]; + grantId: number; + grantIds: number[]; + isNew: boolean; + isReopenedGoal: boolean; + collaborators: { + goalNumber?: string; + goalCreatorName: string; + goalCreatorRoles: string; }[]; + activityReportGoals?: IActivityReportGoal[]; } interface IGoalModelInstance extends IGoal { @@ -289,15 +289,13 @@ export { // -- model version of the above -- // IGoalModelInstance, IGrantModelInstance, - IPromptModelInstance, ICourseModelInstance, ITopicModelInstance, IResourceModelInstance, IFileModelInstance, IObjectiveModelInstance, - IActivityReportObjectivesFromDB, - // -- for the rtr query, slightly distinct types are used -- // - IGoalForRTRQueryWithReducedObjectives, - IGoalForRTRForm, + IActivityReportObjectivesModelInstance, + // -- after going through reduceGoals -- // IReducedObjective, + IReducedGoal, }; From a7502528c67d735f0e14534b1d67d314c678b00e Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 12:39:52 -0400 Subject: [PATCH 28/78] Scale back changes --- src/goalServices/getGoalsForReport.ts | 91 +++++----------- src/goalServices/reduceGoals.ts | 144 +++++++++++++++----------- src/goalServices/types.ts | 4 +- 3 files changed, 113 insertions(+), 126 deletions(-) diff --git a/src/goalServices/getGoalsForReport.ts b/src/goalServices/getGoalsForReport.ts index aba71fe8f9..0b5937661d 100644 --- a/src/goalServices/getGoalsForReport.ts +++ b/src/goalServices/getGoalsForReport.ts @@ -20,9 +20,6 @@ const { ActivityReportObjectiveFile, ActivityReportObjectiveResource, ActivityReportObjectiveCourse, - GoalTemplateFieldPrompt, - GoalFieldResponse, - ActivityReportGoalFieldResponse, sequelize, Resource, ActivityReportGoal, @@ -58,71 +55,33 @@ export default async function getGoalsForReport(reportId: number, goalIds: numbe [sequelize.col('grant.regionId'), 'regionId'], [sequelize.col('grant.recipient.id'), 'recipientId'], [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], - // [sequelize.literal(`( - // SELECT - // jsonb_agg( DISTINCT jsonb_build_object( - // 'promptId', gtfp.id , - // 'ordinal', gtfp.ordinal, - // 'title', gtfp.title, - // 'prompt', gtfp.prompt, - // 'hint', gtfp.hint, - // 'caution', gtfp.caution, - // 'fieldType', gtfp."fieldType", - // 'options', gtfp.options, - // 'validations', gtfp.validations, - // 'response', gfr.response, - // 'reportResponse', argfr.response - // )) - // FROM "GoalTemplateFieldPrompts" gtfp - // LEFT JOIN "GoalFieldResponses" gfr - // ON gtfp.id = gfr."goalTemplateFieldPromptId" - // AND gfr."goalId" = "Goal".id - // LEFT JOIN "ActivityReportGoalFieldResponses" argfr - // ON gtfp.id = argfr."goalTemplateFieldPromptId" - // AND argfr."activityReportGoalId" = "activityReportGoals".id - // WHERE "goalTemplate".id = gtfp."goalTemplateId" - // GROUP BY TRUE - // )`), 'prompts'], + [sequelize.literal(`( + SELECT + jsonb_agg( DISTINCT jsonb_build_object( + 'promptId', gtfp.id , + 'ordinal', gtfp.ordinal, + 'title', gtfp.title, + 'prompt', gtfp.prompt, + 'hint', gtfp.hint, + 'caution', gtfp.caution, + 'fieldType', gtfp."fieldType", + 'options', gtfp.options, + 'validations', gtfp.validations, + 'response', gfr.response, + 'reportResponse', argfr.response + )) + FROM "GoalTemplateFieldPrompts" gtfp + LEFT JOIN "GoalFieldResponses" gfr + ON gtfp.id = gfr."goalTemplateFieldPromptId" + AND gfr."goalId" = "Goal".id + LEFT JOIN "ActivityReportGoalFieldResponses" argfr + ON gtfp.id = argfr."goalTemplateFieldPromptId" + AND argfr."activityReportGoalId" = "activityReportGoals".id + WHERE "goalTemplate".id = gtfp."goalTemplateId" + GROUP BY TRUE + )`), 'prompts'], ], include: [ - { - model: GoalTemplateFieldPrompt, - as: 'prompts', - attributes: [ - ['id', 'promptId'], - 'ordinal', - 'title', - 'prompt', - 'hint', - 'fieldType', - 'options', - 'validations', - ], - required: false, - include: [ - { - model: GoalFieldResponse, - as: 'responses', - attributes: ['response'], - required: false, - }, - { - model: ActivityReportGoalFieldResponse, - as: 'reportResponses', - attributes: ['response'], - required: false, - include: [{ - model: ActivityReportGoal, - as: 'activityReportGoal', - attributes: ['activityReportId', ['id', 'activityReportGoalId']], - required: true, - where: { - reportId, - }, - }], - }, - ], - }, { model: GoalStatusChange, as: 'statusChanges', diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index dd39ea8520..862c405669 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -265,14 +265,16 @@ export function reduceObjectivesForActivityReport( } /** - * - * @param {Array} newPrompts - * @param {Array} promptsToReduce - * @returns Array of reduced prompts - */ + * + * @param {Boolean} forReport + * @param {Array} newPrompts + * @param {Array} promptsToReduce + * @returns Array of reduced prompts + */ function reducePrompts( - newPrompts: IPrompt[] = [], - promptsToReduce: IPrompt[] = [], + forReport: boolean, + newPrompts:IPrompt[] = [], + promptsToReduce:IPrompt[] = [], ) { return newPrompts ?.reduce((previousPrompts, currentPrompt) => { @@ -281,24 +283,32 @@ function reducePrompts( const existingPrompt = previousPrompts.find((pp) => pp.promptId === currentPrompt.promptId); if (existingPrompt) { - existingPrompt.response = uniq( - [ - ...existingPrompt.response, - ...(currentPrompt.response || []), - ...(currentPrompt.reportResponse || []), - ], - ); - existingPrompt.reportResponse = uniq( - [ - ...(existingPrompt.reportResponse || []), - ...(currentPrompt.reportResponse || []), - ], - ); + if (!forReport) { + existingPrompt.response = uniq( + [...existingPrompt.response, ...currentPrompt.responses.flatMap((r) => r.response)], + ); + } - if (existingPrompt.allGoalsHavePromptResponse && (currentPrompt.response || []).length) { - existingPrompt.allGoalsHavePromptResponse = true; - } else { - existingPrompt.allGoalsHavePromptResponse = false; + if (forReport) { + existingPrompt.response = uniq( + [ + ...existingPrompt.response, + ...(currentPrompt.response || []), + ...(currentPrompt.reportResponse || []), + ], + ); + existingPrompt.reportResponse = uniq( + [ + ...(existingPrompt.reportResponse || []), + ...(currentPrompt.reportResponse || []), + ], + ); + + if (existingPrompt.allGoalsHavePromptResponse && (currentPrompt.response || []).length) { + existingPrompt.allGoalsHavePromptResponse = true; + } else { + existingPrompt.allGoalsHavePromptResponse = false; + } } return previousPrompts; @@ -314,20 +324,24 @@ function reducePrompts( options: currentPrompt.options, validations: currentPrompt.validations, allGoalsHavePromptResponse: false, - response: [], - reportResponse: [], } as IPrompt; - newPrompt.response = uniq( - [ - ...(currentPrompt.response || []), - ...(currentPrompt.reportResponse || []), - ], - ); - newPrompt.reportResponse = (currentPrompt.reportResponse || []); + if (forReport) { + newPrompt.response = uniq( + [ + ...(currentPrompt.response || []), + ...(currentPrompt.reportResponse || []), + ], + ); + newPrompt.reportResponse = (currentPrompt.reportResponse || []); - if (newPrompt.response.length) { - newPrompt.allGoalsHavePromptResponse = true; + if (newPrompt.response.length) { + newPrompt.allGoalsHavePromptResponse = true; + } + } + + if (!forReport) { + newPrompt.response = uniq(currentPrompt.responses.flatMap((r) => r.response)); } return [ @@ -404,19 +418,29 @@ export function reduceGoals( ], 'goalCreatorName'); existingGoal.isReopenedGoal = wasGoalPreviouslyClosed(existingGoal); - - existingGoal.prompts = { - ...existingGoal.prompts, - [currentValue.grant.numberWithProgramTypes]: reducePrompts( + if (forReport) { + existingGoal.prompts = existingGoal.prompts || []; + existingGoal.prompts = reducePrompts( + forReport, currentValue.dataValues.prompts || [], - [], // we don't want to combine existing prompts if reducing for the RTR - ), - }; - existingGoal.source = { - ...existingGoal.source, - [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, - }; - + (existingGoal.prompts || []) as IPrompt[], + ); + } else { + existingGoal.prompts = { + ...existingGoal.prompts, + [currentValue.grant.numberWithProgramTypes]: reducePrompts( + forReport, + currentValue.dataValues.prompts || [], + [], // we don't want to combine existing prompts if reducing for the RTR + ), + }; + existingGoal.source = { + ...existingGoal.source as { + [key: string]: string; + }, + [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, + }; + } return previousValues; } @@ -430,20 +454,24 @@ export function reduceGoals( return date; })(); - const prompts = reducePrompts( + const { source: sourceForReport } = currentValue.dataValues; + const promptsForReport = reducePrompts( + forReport, currentValue.dataValues.prompts || [], [], ); - const updatedSource = { - [currentValue.grant.numberWithProgramTypes]: currentValue.dataValues.source, - } as { - [key: string]: string; - }; + let sourceForRTR: { [key: string]: string }; + let sourceForPrompts: { [key: string]: IPrompt[] }; - const updatedPrompts = { - [currentValue.grant.numberWithProgramTypes]: prompts, - }; + if (!forReport) { + sourceForRTR = { + [currentValue.grant.numberWithProgramTypes]: sourceForReport, + }; + sourceForPrompts = { + [currentValue.grant.numberWithProgramTypes]: promptsForReport, + }; + } const goal = { isCurated: currentValue.isCurated, @@ -458,8 +486,8 @@ export function reduceGoals( recipientId: currentValue.grant.recipientId, goalTemplateId: currentValue.goalTemplateId, createdVia: currentValue.createdVia, - source: updatedSource, - prompts: updatedPrompts, + source: forReport ? sourceForReport : sourceForRTR, + prompts: forReport ? promptsForReport : sourceForPrompts, isNew: false, onAR: currentValue.onAR, onApprovedAR: currentValue.onApprovedAR, diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index 0aa88b9633..2ec9f5397f 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -243,7 +243,7 @@ interface IReducedGoal { createdVia: string; source: { [key: string]: string; - }; + } | string; onAR: boolean; onApprovedAR: boolean; isCurated: boolean; @@ -252,7 +252,7 @@ interface IReducedGoal { objectives: IReducedObjective[]; prompts : { [x: string]: IPrompt[]; - }; + } | IPrompt[]; statusChanges?: { oldStatus: string }[]; goalNumber: string; goalNumbers: string[]; From 1850f351dfe1632be5113508753d9ad08f2187d0 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 13:58:47 -0400 Subject: [PATCH 29/78] Update and revert some changes --- src/goalServices/createOrUpdateGoals.test.js | 5 + src/goalServices/goals.alt.test.js | 7 +- src/goalServices/goals.js | 191 ++++++++++++++++++- src/goalServices/goalsByIdAndRecipient.ts | 2 +- src/goalServices/reduceGoals.ts | 22 ++- src/goalServices/types.ts | 1 + src/routes/goals/handlers.js | 5 +- 7 files changed, 211 insertions(+), 22 deletions(-) diff --git a/src/goalServices/createOrUpdateGoals.test.js b/src/goalServices/createOrUpdateGoals.test.js index 7748fc4fb3..448145cc90 100644 --- a/src/goalServices/createOrUpdateGoals.test.js +++ b/src/goalServices/createOrUpdateGoals.test.js @@ -159,6 +159,11 @@ describe('createOrUpdateGoals', () => { expect(statuses).toContain('Not Started'); expect(statuses).toContain('Draft'); + const isCurateds = newGoals.map((g) => g.isCurated); + isCurateds.forEach((isCurated) => { + expect(isCurated).toBeDefined(); + }); + const createdVias = newGoals.map((g) => g.createdVia); expect(createdVias.length).toBe(2); expect(createdVias).toContain('activityReport'); diff --git a/src/goalServices/goals.alt.test.js b/src/goalServices/goals.alt.test.js index 16db2f136b..b42b61b6ee 100644 --- a/src/goalServices/goals.alt.test.js +++ b/src/goalServices/goals.alt.test.js @@ -450,8 +450,9 @@ describe('Goals DB service', () => { }); it('objective reduce returns the correct number of objectives with spaces', async () => { - const reducedObjectives = await reduceObjectives( - [objectiveOne, + const reducedObjectives = reduceObjectives( + [ + objectiveOne, objectiveTwo, objectiveThree, objectiveFour, @@ -463,7 +464,7 @@ describe('Goals DB service', () => { }); it('ar reduce returns the correct number of objectives with spaces', async () => { - const reducedObjectives = await reduceObjectivesForActivityReport( + const reducedObjectives = reduceObjectivesForActivityReport( [objectiveOne, objectiveTwo, objectiveThree, diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index c1319e88f9..fe8fa09604 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -25,6 +25,9 @@ import { ActivityReportGoal, ActivityRecipient, Topic, + Course, + GoalTemplateFieldPrompt, + ActivityReportGoalFieldResponse, File, } from '../models'; import { @@ -56,6 +59,7 @@ import goalsByIdAndRecipient, { OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, } from './goalsByIdAndRecipient'; import getGoalsForReport from './getGoalsForReport'; +import reduceGoals from './reduceGoals'; const namespace = 'SERVICE:GOALS'; const logContext = { @@ -209,6 +213,166 @@ export async function saveObjectiveAssociations( }; } +/** + * + * @param {number} id + * @returns {Promise{Object}} + */ +export async function goalsByIdsAndActivityReport(id, activityReportId) { + const goals = await Goal.findAll({ + attributes: [ + 'endDate', + 'status', + ['id', 'value'], + ['name', 'label'], + 'id', + 'name', + ], + where: { + id, + }, + include: [ + { + model: Grant, + as: 'grant', + }, + { + model: Objective, + as: 'objectives', + where: { + [Op.and]: [ + { + title: { + [Op.ne]: '', + }, + }, + { + status: { + [Op.notIn]: [OBJECTIVE_STATUS.COMPLETE, OBJECTIVE_STATUS.SUSPENDED], + }, + }, + ], + }, + attributes: [ + 'id', + ['title', 'label'], + 'title', + 'status', + 'goalId', + 'supportType', + 'onApprovedAR', + 'onAR', + ], + required: false, + include: [ + { + model: Resource, + as: 'resources', + attributes: [ + ['url', 'value'], + ['id', 'key'], + ], + required: false, + through: { + attributes: [], + where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, + required: false, + }, + }, + { + model: ActivityReportObjective, + as: 'activityReportObjectives', + attributes: [ + 'ttaProvided', + 'closeSuspendReason', + 'closeSuspendContext', + ], + required: false, + where: { + activityReportId, + }, + }, + { + model: File, + as: 'files', + }, + { + model: Topic, + as: 'topics', + required: false, + }, + { + model: Course, + as: 'courses', + required: false, + }, + { + model: ActivityReport, + as: 'activityReports', + where: { + calculatedStatus: { + [Op.not]: REPORT_STATUSES.DELETED, + }, + }, + required: false, + }, + ], + }, + { + model: GoalTemplateFieldPrompt, + as: 'prompts', + attributes: [ + 'id', + 'ordinal', + 'title', + 'prompt', + 'hint', + 'fieldType', + 'options', + 'validations', + ], + required: false, + include: [ + { + model: GoalFieldResponse, + as: 'responses', + attributes: [ + 'response', + ], + required: false, + where: { goalId: id }, + }, + { + model: ActivityReportGoalFieldResponse, + as: 'reportResponses', + attributes: ['response'], + required: false, + include: [{ + model: ActivityReportGoal, + as: 'activityReportGoal', + attributes: ['activityReportId', ['id', 'activityReportGoalId']], + required: true, + where: { goalId: id, activityReportId }, + }], + }, + ], + }, + ], + }); + + const reducedGoals = reduceGoals(goals); + + // sort reduced goals by rtr order + reducedGoals.sort((a, b) => { + if (a.rtrOrder < b.rtrOrder) { + return -1; + } + return 1; + }); + + return reducedGoals; +} + /** * * @param {*} goalId @@ -853,27 +1017,42 @@ async function removeUnusedGoalsCreatedViaAr(goalsToRemove, reportId) { return Promise.resolve(); } -async function removeObjectives(objectivesToRemove) { +async function removeObjectives(objectivesToRemove, reportId) { if (!objectivesToRemove.length) { return Promise.resolve(); } // TODO - when we have an "onAnyReport" flag, we can use that here instead of two SQL statements - const objectivesToDestroy = await Objective.findAll({ + const objectivesToPossiblyDestroy = await Objective.findAll({ where: { createdVia: 'activityReport', id: objectivesToRemove, onApprovedAR: false, - onAR: false, }, + include: [ + { + model: ActivityReport, + as: 'activityReports', + required: false, + where: { + id: { + [Op.not]: reportId, + }, + }, + }, + ], }); - if (!objectivesToDestroy.length) { + // see TODO above, but this can be removed when we have an "onAnyReport" flag + const objectivesToDefinitelyDestroy = objectivesToPossiblyDestroy + .filter((o) => !o.activityReports.length); + + if (!objectivesToDefinitelyDestroy.length) { return Promise.resolve(); } // Objectives to destroy. - const objectivesIdsToDestroy = objectivesToDestroy.map((o) => o.id); + const objectivesIdsToDestroy = objectivesToDefinitelyDestroy.map((o) => o.id); // cleanup any ObjectiveFiles that are no longer needed await ObjectiveFile.destroy({ @@ -894,7 +1073,7 @@ async function removeObjectives(objectivesToRemove) { // Delete objective. return Objective.destroy({ where: { - id: objectivesToDestroy.map((o) => o.id), + id: objectivesToDefinitelyDestroy.map((o) => o.id), }, }); } diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index 07853b2d28..86e3c31144 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -265,7 +265,7 @@ export default async function goalsByIdAndRecipient(ids: number | number[], reci objective.activityReportObjectives, 'files', ), - })), // Convert to 'unknown' first + })), })); return reduceGoals(reformattedGoals); diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index 862c405669..407a4f1b1d 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -80,7 +80,7 @@ export function reduceObjectives( ...objectives, newObjective, ]; - }, currentObjectives); + }, currentObjectives || []); objectivesToSort.sort((o1, o2) => { if (o1.rtrOrder < o2.rtrOrder) { @@ -474,24 +474,25 @@ export function reduceGoals( } const goal = { - isCurated: currentValue.isCurated, + isCurated: currentValue.dataValues.isCurated, goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, grantId: currentValue.grant.id, collaborators: currentValue.collaborators, - id: currentValue.id, - name: currentValue.name, + id: currentValue.dataValues.id, + name: currentValue.dataValues.name, endDate, - status: currentValue.status, + activityReportGoals: currentValue.activityReportGoals, + status: currentValue.dataValues.status, regionId: currentValue.grant.regionId, recipientId: currentValue.grant.recipientId, - goalTemplateId: currentValue.goalTemplateId, - createdVia: currentValue.createdVia, + goalTemplateId: currentValue.dataValues.goalTemplateId, + createdVia: currentValue.dataValues.createdVia, source: forReport ? sourceForReport : sourceForRTR, prompts: forReport ? promptsForReport : sourceForPrompts, isNew: false, - onAR: currentValue.onAR, - onApprovedAR: currentValue.onApprovedAR, - rtrOrder: currentValue.rtrOrder, + onAR: currentValue.dataValues.onAR, + onApprovedAR: currentValue.dataValues.onApprovedAR, + rtrOrder: currentValue.dataValues.rtrOrder, isReopenedGoal: wasGoalPreviouslyClosed(currentValue), goalCollaborators: currentValue.goalCollaborators, objectives: objectivesReducer( @@ -499,6 +500,7 @@ export function reduceGoals( ), goalNumbers: [currentValue.goalNumber || `G-${currentValue.dataValues.id}`], goalIds: [currentValue.dataValues.id], + grant: currentValue.grant.dataValues, grants: [ { ...currentValue.grant.dataValues, diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index 2ec9f5397f..dfc0013287 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -257,6 +257,7 @@ interface IReducedGoal { goalNumber: string; goalNumbers: string[]; goalIds: number[]; + grant: IGrant; grants: IGrant[]; grantId: number; grantIds: number[]; diff --git a/src/routes/goals/handlers.js b/src/routes/goals/handlers.js index 96946d9775..a25fa99ec0 100644 --- a/src/routes/goals/handlers.js +++ b/src/routes/goals/handlers.js @@ -8,6 +8,7 @@ import { destroyGoal, mergeGoals, getGoalIdsBySimilarity, + goalsByIdsAndActivityReport, } from '../../goalServices/goals'; import _changeGoalStatus from '../../goalServices/changeGoalStatus'; import getGoalsMissingDataForActivityReportSubmission from '../../goalServices/getGoalsMissingDataForActivityReportSubmission'; @@ -17,7 +18,7 @@ import Goal from '../../policies/goals'; import { userById } from '../../services/users'; import { currentUserId } from '../../services/currentUser'; import { validateMergeGoalPermissions } from '../utils'; -import getGoalsForReport from '../../goalServices/getGoalsForReport'; +// import getGoalsForReport from '../../goalServices/getGoalsForReport'; const namespace = 'SERVICE:GOALS'; @@ -240,7 +241,7 @@ export async function retrieveGoalsByIds(req, res) { } const gIds = goalIds.map((g) => parseInt(g, 10)); - const retrievedGoal = await getGoalsForReport(reportId, gIds); + const retrievedGoal = await goalsByIdsAndActivityReport(gIds, reportId); if (!retrievedGoal || !retrievedGoal.length) { res.sendStatus(404); From c52d826c00095386cfc0506bd5803d1b17499d36 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 14:07:29 -0400 Subject: [PATCH 30/78] Let us be inclusive, as before, with reduce goals --- src/goalServices/reduceGoals.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/goalServices/reduceGoals.ts b/src/goalServices/reduceGoals.ts index 407a4f1b1d..6005329501 100644 --- a/src/goalServices/reduceGoals.ts +++ b/src/goalServices/reduceGoals.ts @@ -474,6 +474,7 @@ export function reduceGoals( } const goal = { + ...currentValue.dataValues, isCurated: currentValue.dataValues.isCurated, goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, grantId: currentValue.grant.id, From 9af560fe9e7c8f64f24c92ce375f8961ea4ee316 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 16:04:17 -0400 Subject: [PATCH 31/78] Revert some more changes and fix another test --- src/routes/goals/handlers.js | 3 +-- src/routes/goals/handlers.test.js | 15 +++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/routes/goals/handlers.js b/src/routes/goals/handlers.js index a25fa99ec0..5be7201e6b 100644 --- a/src/routes/goals/handlers.js +++ b/src/routes/goals/handlers.js @@ -4,11 +4,11 @@ import { updateGoalStatusById, createOrUpdateGoalsForActivityReport, createOrUpdateGoals, + goalsByIdsAndActivityReport, goalByIdWithActivityReportsAndRegions, destroyGoal, mergeGoals, getGoalIdsBySimilarity, - goalsByIdsAndActivityReport, } from '../../goalServices/goals'; import _changeGoalStatus from '../../goalServices/changeGoalStatus'; import getGoalsMissingDataForActivityReportSubmission from '../../goalServices/getGoalsMissingDataForActivityReportSubmission'; @@ -18,7 +18,6 @@ import Goal from '../../policies/goals'; import { userById } from '../../services/users'; import { currentUserId } from '../../services/currentUser'; import { validateMergeGoalPermissions } from '../utils'; -// import getGoalsForReport from '../../goalServices/getGoalsForReport'; const namespace = 'SERVICE:GOALS'; diff --git a/src/routes/goals/handlers.test.js b/src/routes/goals/handlers.test.js index d3ed3c2d75..e037da474e 100644 --- a/src/routes/goals/handlers.test.js +++ b/src/routes/goals/handlers.test.js @@ -26,10 +26,10 @@ import { destroyGoal, goalByIdWithActivityReportsAndRegions, createOrUpdateGoalsForActivityReport, + goalsByIdsAndActivityReport, mergeGoals, getGoalIdsBySimilarity, } from '../../goalServices/goals'; -import getGoalsForReport from '../../goalServices/getGoalsForReport'; import nudge from '../../goalServices/nudge'; import { currentUserId } from '../../services/currentUser'; import { validateMergeGoalPermissions } from '../utils'; @@ -46,8 +46,6 @@ jest.mock('../../services/currentUser', () => ({ currentUserId: jest.fn(), })); -jest.mock('../../goalServices/getGoalsForReport'); - jest.mock('../../goalServices/goals', () => ({ updateGoalStatusById: jest.fn(), createOrUpdateGoals: jest.fn(), @@ -55,6 +53,7 @@ jest.mock('../../goalServices/goals', () => ({ goalByIdAndRecipient: jest.fn(), destroyGoal: jest.fn(), createOrUpdateGoalsForActivityReport: jest.fn(), + goalsByIdsAndActivityReport: jest.fn(), goalRegionsById: jest.fn(), mergeGoals: jest.fn(), getGoalIdsBySimilarity: jest.fn(), @@ -743,7 +742,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - getGoalsForReport.mockResolvedValueOnce([ + goalsByIdsAndActivityReport.mockResolvedValueOnce([ { id: 1, name: 'Goal 1', @@ -817,7 +816,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - getGoalsForReport.mockResolvedValueOnce([ + goalsByIdsAndActivityReport.mockResolvedValueOnce([ { id: 1, name: 'Goal 1', @@ -895,7 +894,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - getGoalsForReport.mockResolvedValueOnce([]); + goalsByIdsAndActivityReport.mockResolvedValueOnce([]); await retrieveGoalsByIds(req, mockResponse, true); expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); @@ -926,7 +925,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - getGoalsForReport.mockResolvedValueOnce(null); + goalsByIdsAndActivityReport.mockResolvedValueOnce(null); await retrieveGoalsByIds(req, mockResponse); expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); @@ -957,7 +956,7 @@ describe('retrieveGoalsByIds', () => { grant: { regionId: 2 }, }); - getGoalsForReport.mockImplementationOnce(() => { + goalsByIdsAndActivityReport.mockImplementationOnce(() => { throw new Error('a test error for the goals handler'); }); From ccec1c24a029f640884f2c4310c61c918f325957 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 16:10:51 -0400 Subject: [PATCH 32/78] assert the new AR objective column is set by the cacheObjectiveMetadata fn --- src/services/reportCache.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/reportCache.test.js b/src/services/reportCache.test.js index d2aaf6619d..58279a538b 100644 --- a/src/services/reportCache.test.js +++ b/src/services/reportCache.test.js @@ -700,6 +700,7 @@ describe('cacheObjectiveMetadata', () => { courses: coursesForThisObjective, ttaProvided: null, order: 1, + objectiveCreatedHere: true, }; await cacheObjectiveMetadata(objective, report.id, metadata); const aro = await ActivityReportObjective.findOne({ @@ -731,6 +732,7 @@ describe('cacheObjectiveMetadata', () => { expect(aro.activityReportObjectiveResources[0].resource.dataValues.url) .toEqual(mockObjectiveResources[0]); + expect(aro.objectiveCreatedHere).toBe(true); expect(aro.activityReportObjectiveTopics.length).toEqual(1); expect(aro.activityReportObjectiveCourses.length).toEqual(2); expect(aro.arOrder).toEqual(2); From 63fc4742d10e2c60766024855dd6cc3847d8514d Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 16:52:29 -0400 Subject: [PATCH 33/78] Revert more changes --- src/goalServices/getGoalsForReport.ts | 104 +++++++++++++------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/src/goalServices/getGoalsForReport.ts b/src/goalServices/getGoalsForReport.ts index 0b5937661d..b787162bb6 100644 --- a/src/goalServices/getGoalsForReport.ts +++ b/src/goalServices/getGoalsForReport.ts @@ -28,59 +28,40 @@ const { File, } = db; -export default async function getGoalsForReport(reportId: number, goalIds: number[] = []) { - let where = {} as WhereOptions; - - if (goalIds.length) { - where = { - id: goalIds, - }; - } - +export default async function getGoalsForReport(reportId: number) { const goals = await Goal.findAll({ - where, - attributes: [ - 'id', - 'name', - 'endDate', - 'status', - 'grantId', - 'createdVia', - 'source', - 'onAR', - 'onApprovedAR', - 'goalNumber', - 'goalTemplateId', - 'rtrOrder', - [sequelize.col('grant.regionId'), 'regionId'], - [sequelize.col('grant.recipient.id'), 'recipientId'], - [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], - [sequelize.literal(`( - SELECT - jsonb_agg( DISTINCT jsonb_build_object( - 'promptId', gtfp.id , - 'ordinal', gtfp.ordinal, - 'title', gtfp.title, - 'prompt', gtfp.prompt, - 'hint', gtfp.hint, - 'caution', gtfp.caution, - 'fieldType', gtfp."fieldType", - 'options', gtfp.options, - 'validations', gtfp.validations, - 'response', gfr.response, - 'reportResponse', argfr.response - )) - FROM "GoalTemplateFieldPrompts" gtfp - LEFT JOIN "GoalFieldResponses" gfr - ON gtfp.id = gfr."goalTemplateFieldPromptId" - AND gfr."goalId" = "Goal".id - LEFT JOIN "ActivityReportGoalFieldResponses" argfr - ON gtfp.id = argfr."goalTemplateFieldPromptId" - AND argfr."activityReportGoalId" = "activityReportGoals".id - WHERE "goalTemplate".id = gtfp."goalTemplateId" - GROUP BY TRUE - )`), 'prompts'], - ], + attributes: { + include: [ + [sequelize.col('grant.regionId'), 'regionId'], + [sequelize.col('grant.recipient.id'), 'recipientId'], + [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], + [sequelize.literal(`( + SELECT + jsonb_agg( DISTINCT jsonb_build_object( + 'promptId', gtfp.id , + 'ordinal', gtfp.ordinal, + 'title', gtfp.title, + 'prompt', gtfp.prompt, + 'hint', gtfp.hint, + 'caution', gtfp.caution, + 'fieldType', gtfp."fieldType", + 'options', gtfp.options, + 'validations', gtfp.validations, + 'response', gfr.response, + 'reportResponse', argfr.response + )) + FROM "GoalTemplateFieldPrompts" gtfp + LEFT JOIN "GoalFieldResponses" gfr + ON gtfp.id = gfr."goalTemplateFieldPromptId" + AND gfr."goalId" = "Goal".id + LEFT JOIN "ActivityReportGoalFieldResponses" argfr + ON gtfp.id = argfr."goalTemplateFieldPromptId" + AND argfr."activityReportGoalId" = "activityReportGoals".id + WHERE "goalTemplate".id = gtfp."goalTemplateId" + GROUP BY TRUE + )`), 'prompts'], + ], + }, include: [ { model: GoalStatusChange, @@ -173,6 +154,25 @@ export default async function getGoalsForReport(reportId: number, goalIds: numbe }, ], }, + { + model: Topic, + as: 'topics', + }, + { + model: Resource, + as: 'resources', + attributes: [['url', 'value']], + through: { + attributes: [], + where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, + required: false, + }, + required: false, + }, + { + model: File, + as: 'files', + }, ], }, ], From 9495c3cb27f169787f5113cc83cd5a74a8056484 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 16:54:44 -0400 Subject: [PATCH 34/78] Allow goal number in api test --- tests/api/recipient.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts index fabfa10f54..4e01548914 100644 --- a/tests/api/recipient.spec.ts +++ b/tests/api/recipient.spec.ts @@ -214,6 +214,7 @@ test.describe('get /recipient', () => { }) ) }), + goalNumber: Joi.string(), goalNumbers: Joi.array().items(Joi.string()), goalIds: Joi.array().items(Joi.number()), grants: Joi.array().items( From 400ea80f25ccaa27281c2d222d8baf6afd5a2527 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 20:57:16 -0400 Subject: [PATCH 35/78] Fix API tests --- ...ssociationsFromActivityReportObjectives.ts | 15 +++ src/goalServices/goals.js | 93 +++++++++++++------ src/goalServices/goalsByIdAndRecipient.ts | 14 +-- tests/api/goals.spec.ts | 4 + tests/api/recipient.spec.ts | 1 + 5 files changed, 84 insertions(+), 43 deletions(-) create mode 100644 src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts diff --git a/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts b/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts new file mode 100644 index 0000000000..b77f42bbcf --- /dev/null +++ b/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts @@ -0,0 +1,15 @@ +import { + IActivityReportObjectivesModelInstance, + IFileModelInstance, + IResourceModelInstance, + ITopicModelInstance, +} from './types'; + +export default function extractObjectiveAssociationsFromActivityReportObjectives( + activityReportObjectives: IActivityReportObjectivesModelInstance[], + associationName: 'topics' | 'resources' | 'files' | 'courses', +) { + return activityReportObjectives.map((aro) => aro[associationName].map(( + a: ITopicModelInstance | IResourceModelInstance | IFileModelInstance, + ) => a.toJSON())).flat(); +} diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index fe8fa09604..78e4af91d4 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -59,7 +59,9 @@ import goalsByIdAndRecipient, { OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, } from './goalsByIdAndRecipient'; import getGoalsForReport from './getGoalsForReport'; -import reduceGoals from './reduceGoals'; +import { reduceGoals } from './reduceGoals'; +import extractObjectiveAssociationsFromActivityReportObjectives from './extractObjectiveAssociationsFromActivityReportObjectives'; +import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; const namespace = 'SERVICE:GOALS'; const logContext = { @@ -265,20 +267,6 @@ export async function goalsByIdsAndActivityReport(id, activityReportId) { ], required: false, include: [ - { - model: Resource, - as: 'resources', - attributes: [ - ['url', 'value'], - ['id', 'key'], - ], - required: false, - through: { - attributes: [], - where: { sourceFields: { [Op.contains]: [SOURCE_FIELD.OBJECTIVE.RESOURCE] } }, - required: false, - }, - }, { model: ActivityReportObjective, as: 'activityReportObjectives', @@ -291,20 +279,40 @@ export async function goalsByIdsAndActivityReport(id, activityReportId) { where: { activityReportId, }, - }, - { - model: File, - as: 'files', - }, - { - model: Topic, - as: 'topics', - required: false, - }, - { - model: Course, - as: 'courses', - required: false, + include: [ + { + model: Topic, + as: 'topics', + attributes: ['name'], + through: { + attributes: [], + }, + }, + { + model: Resource, + as: 'resources', + attributes: ['url', 'title'], + through: { + attributes: [], + }, + }, + { + model: File, + as: 'files', + attributes: ['originalFileName', 'key', 'url'], + through: { + attributes: [], + }, + }, + { + model: Course, + as: 'courses', + attributes: ['name'], + through: { + attributes: [], + }, + }, + ], }, { model: ActivityReport, @@ -360,7 +368,32 @@ export async function goalsByIdsAndActivityReport(id, activityReportId) { ], }); - const reducedGoals = reduceGoals(goals); + const reformattedGoals = goals.map((goal) => ({ + ...goal, + isReopenedGoal: wasGoalPreviouslyClosed(goal), + objectives: goal.objectives + .map((objective) => ({ + ...objective.toJSON(), + topics: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'topics', + ), + courses: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'courses', + ), + resources: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'resources', + ), + files: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'files', + ), + })), + })); + + const reducedGoals = reduceGoals(reformattedGoals); // sort reduced goals by rtr order reducedGoals.sort((a, b) => { diff --git a/src/goalServices/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts index 86e3c31144..f335274869 100644 --- a/src/goalServices/goalsByIdAndRecipient.ts +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -3,13 +3,10 @@ import { CREATION_METHOD } from '../constants'; import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; import { reduceGoals } from './reduceGoals'; import { - ITopicModelInstance, - IResourceModelInstance, - IFileModelInstance, IGoalModelInstance, IObjectiveModelInstance, - IActivityReportObjectivesModelInstance, } from './types'; +import extractObjectiveAssociationsFromActivityReportObjectives from './extractObjectiveAssociationsFromActivityReportObjectives'; const { Goal, @@ -230,15 +227,6 @@ const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) ], }); -function extractObjectiveAssociationsFromActivityReportObjectives( - activityReportObjectives: IActivityReportObjectivesModelInstance[], - associationName: 'topics' | 'resources' | 'files' | 'courses', -) { - return activityReportObjectives.map((aro) => aro[associationName].map(( - a: ITopicModelInstance | IResourceModelInstance | IFileModelInstance, - ) => a.toJSON())).flat(); -} - export default async function goalsByIdAndRecipient(ids: number | number[], recipientId: number) { const goals = await Goal .findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalModelInstance[]; diff --git a/tests/api/goals.spec.ts b/tests/api/goals.spec.ts index cd7b161ce2..c623f804ed 100644 --- a/tests/api/goals.spec.ts +++ b/tests/api/goals.spec.ts @@ -62,9 +62,11 @@ test('get /goals?goalIds[]=&reportId', async ({ request }) => { name: Joi.string(), grant: grantSchema, objectives: Joi.array(), + goalNumber: Joi.string(), goalNumbers: Joi.array().items(Joi.string()), goalIds: Joi.array().items(Joi.number()), grants: Joi.array().items(grantSchema), + grantId: Joi.number(), grantIds: Joi.array().items(Joi.number()), isNew: Joi.boolean(), collaborators: Joi.array().items(Joi.any().allow(null)), @@ -75,6 +77,8 @@ test('get /goals?goalIds[]=&reportId', async ({ request }) => { newStatus: Joi.string(), })), isReopenedGoal: Joi.boolean(), + regionId: Joi.number(), + recipientId: Joi.number(), })); await validateSchema(response, schema, expect); diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts index 4e01548914..ac56a3066c 100644 --- a/tests/api/recipient.spec.ts +++ b/tests/api/recipient.spec.ts @@ -217,6 +217,7 @@ test.describe('get /recipient', () => { goalNumber: Joi.string(), goalNumbers: Joi.array().items(Joi.string()), goalIds: Joi.array().items(Joi.number()), + grantId: Joi.number(), grants: Joi.array().items( Joi.object({ id: Joi.number(), From e76ffbc0f2824e92af75b967ac5221b877f7efb5 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 20 May 2024 21:03:38 -0400 Subject: [PATCH 36/78] Goal source is read-only on the AR --- .../Pages/components/GoalForm.js | 49 ++----------------- .../Pages/components/__tests__/GoalForm.js | 20 +------- 2 files changed, 5 insertions(+), 64 deletions(-) diff --git a/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js b/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js index 2f4fedea8d..2fbfb3dd21 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js +++ b/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js @@ -18,8 +18,8 @@ import { import { NO_ERROR, ERROR_FORMAT } from './constants'; import AppLoadingContext from '../../../../AppLoadingContext'; import { combinePrompts } from '../../../../components/condtionalFieldConstants'; -import GoalSource from '../../../../components/GoalForm/GoalSource'; import FormFieldThatIsSometimesReadOnly from '../../../../components/GoalForm/FormFieldThatIsSometimesReadOnly'; +import ReadOnlyField from '../../../../components/ReadOnlyField'; export default function GoalForm({ goal, @@ -45,7 +45,6 @@ export default function GoalForm({ const defaultEndDate = useMemo(() => (goal && goal.endDate ? goal.endDate : ''), [goal]); const defaultName = useMemo(() => (goal && goal.name ? goal.name : ''), [goal]); const status = useMemo(() => (goal && goal.status ? goal.status : ''), [goal]); - const defaultSource = useMemo(() => (goal && goal.source ? goal.source : ''), [goal]); const activityRecipientType = watch('activityRecipientType'); @@ -86,24 +85,6 @@ export default function GoalForm({ defaultValue: defaultName, }); - const { - field: { - onChange: onUpdateGoalSource, - onBlur: onBlurGoalSource, - value: goalSource, - name: goalSourceInputName, - }, - } = useController({ - name: 'goalSource', - rules: activityRecipientType === 'recipient' ? { - required: { - value: true, - message: 'Select a goal source', - }, - } : {}, - defaultValue: '', - }); - // when the goal is updated in the selection, we want to update // the fields via the useController functions useEffect(() => { @@ -118,10 +99,6 @@ export default function GoalForm({ onUpdateDate(goal.endDate ? goal.endDate : defaultEndDate); }, [defaultEndDate, goal.endDate, onUpdateDate]); - useEffect(() => { - onUpdateGoalSource(goal.source ? goal.source : defaultSource); - }, [goal.source, onUpdateGoalSource, defaultSource]); - // objectives for the objective select, blood for the blood god, etc const [objectiveOptions, setObjectiveOptions] = useState([]); @@ -179,29 +156,11 @@ export default function GoalForm({ userCanEdit /> - - - + {goal.source || ''} + { expect(endDate).toBeVisible(); }); - it('disables goal source when created via tr', async () => { + it('Shows the goal source as read only on the AR', async () => { const trGoal = { id: 1, isNew: false, @@ -153,22 +153,4 @@ describe('GoalForm', () => { expect(screen.getByText(/goal source/i)).toBeVisible(); expect(screen.getByText(/training event source/i)).toBeVisible(); }); - - it('enables goal source when created via is not tr', () => { - const trGoal = { - id: 1, - isNew: false, - goalIds: [123], - createdVia: 'activityReport', - source: 'Not training event', - }; - const user = { - ...DEFAULT_USER, - permissions: [{ scopeId: SCOPE_IDS.ADMIN }], - }; - renderGoalForm(1, trGoal, user); - // Expect the goal source to be disabled - const sourceSelect = screen.getByRole('combobox', { name: /goal source/i }); - expect(sourceSelect).not.toBeDisabled(); - }); }); From 78bede555b1161fb80e4596a1af6039b23d0df6c Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 21 May 2024 09:19:17 -0400 Subject: [PATCH 37/78] Revert "Goal source is read-only on the AR" This reverts commit e76ffbc0f2824e92af75b967ac5221b877f7efb5. --- .../Pages/components/GoalForm.js | 49 +++++++++++++++++-- .../Pages/components/__tests__/GoalForm.js | 20 +++++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js b/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js index 2fbfb3dd21..2f4fedea8d 100644 --- a/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js +++ b/frontend/src/pages/ActivityReport/Pages/components/GoalForm.js @@ -18,8 +18,8 @@ import { import { NO_ERROR, ERROR_FORMAT } from './constants'; import AppLoadingContext from '../../../../AppLoadingContext'; import { combinePrompts } from '../../../../components/condtionalFieldConstants'; +import GoalSource from '../../../../components/GoalForm/GoalSource'; import FormFieldThatIsSometimesReadOnly from '../../../../components/GoalForm/FormFieldThatIsSometimesReadOnly'; -import ReadOnlyField from '../../../../components/ReadOnlyField'; export default function GoalForm({ goal, @@ -45,6 +45,7 @@ export default function GoalForm({ const defaultEndDate = useMemo(() => (goal && goal.endDate ? goal.endDate : ''), [goal]); const defaultName = useMemo(() => (goal && goal.name ? goal.name : ''), [goal]); const status = useMemo(() => (goal && goal.status ? goal.status : ''), [goal]); + const defaultSource = useMemo(() => (goal && goal.source ? goal.source : ''), [goal]); const activityRecipientType = watch('activityRecipientType'); @@ -85,6 +86,24 @@ export default function GoalForm({ defaultValue: defaultName, }); + const { + field: { + onChange: onUpdateGoalSource, + onBlur: onBlurGoalSource, + value: goalSource, + name: goalSourceInputName, + }, + } = useController({ + name: 'goalSource', + rules: activityRecipientType === 'recipient' ? { + required: { + value: true, + message: 'Select a goal source', + }, + } : {}, + defaultValue: '', + }); + // when the goal is updated in the selection, we want to update // the fields via the useController functions useEffect(() => { @@ -99,6 +118,10 @@ export default function GoalForm({ onUpdateDate(goal.endDate ? goal.endDate : defaultEndDate); }, [defaultEndDate, goal.endDate, onUpdateDate]); + useEffect(() => { + onUpdateGoalSource(goal.source ? goal.source : defaultSource); + }, [goal.source, onUpdateGoalSource, defaultSource]); + // objectives for the objective select, blood for the blood god, etc const [objectiveOptions, setObjectiveOptions] = useState([]); @@ -156,11 +179,29 @@ export default function GoalForm({ userCanEdit /> - - {goal.source || ''} - + + { expect(endDate).toBeVisible(); }); - it('Shows the goal source as read only on the AR', async () => { + it('disables goal source when created via tr', async () => { const trGoal = { id: 1, isNew: false, @@ -153,4 +153,22 @@ describe('GoalForm', () => { expect(screen.getByText(/goal source/i)).toBeVisible(); expect(screen.getByText(/training event source/i)).toBeVisible(); }); + + it('enables goal source when created via is not tr', () => { + const trGoal = { + id: 1, + isNew: false, + goalIds: [123], + createdVia: 'activityReport', + source: 'Not training event', + }; + const user = { + ...DEFAULT_USER, + permissions: [{ scopeId: SCOPE_IDS.ADMIN }], + }; + renderGoalForm(1, trGoal, user); + // Expect the goal source to be disabled + const sourceSelect = screen.getByRole('combobox', { name: /goal source/i }); + expect(sourceSelect).not.toBeDisabled(); + }); }); From 643481e30637d1b49708f97dc5b38eab771eb6e5 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 21 May 2024 10:02:40 -0400 Subject: [PATCH 38/78] Goal source is not editable once on an approved report --- frontend/src/components/GoalForm/Form.js | 1 + ...tObjectiveAssociationsFromActivityReportObjectives.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/GoalForm/Form.js b/frontend/src/components/GoalForm/Form.js index c1218e6c99..dc555be31b 100644 --- a/frontend/src/components/GoalForm/Form.js +++ b/frontend/src/components/GoalForm/Form.js @@ -182,6 +182,7 @@ export default function Form({ status !== 'Closed', createdVia !== 'tr', userCanEdit, + !isOnApprovedReport, ]} label="Goal source" value={uniq(Object.values(source || {})).join(', ') || ''} diff --git a/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts b/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts index b77f42bbcf..b977533326 100644 --- a/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts +++ b/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts @@ -3,13 +3,20 @@ import { IFileModelInstance, IResourceModelInstance, ITopicModelInstance, + ICourseModelInstance, } from './types'; +/** + * + * @param activityReportObjectives an array ARO model instances + * @param associationName string, one of the following: 'topics' | 'resources' | 'files' | 'courses' + * @returns an array of associations extracted from the AROs + */ export default function extractObjectiveAssociationsFromActivityReportObjectives( activityReportObjectives: IActivityReportObjectivesModelInstance[], associationName: 'topics' | 'resources' | 'files' | 'courses', ) { return activityReportObjectives.map((aro) => aro[associationName].map(( - a: ITopicModelInstance | IResourceModelInstance | IFileModelInstance, + a: ITopicModelInstance | IResourceModelInstance | IFileModelInstance | ICourseModelInstance, ) => a.toJSON())).flat(); } From 0f42b961813c6724fa7b2873259362726fb5f7d2 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 07:12:03 -0700 Subject: [PATCH 39/78] dedupe ARG and create timeseries functions, restore missing ARGFRs --- .../20240520000000-merge_duplicate_args.js | 229 +++++++++++ ...00001-create-timeseries-and-root-causes.js | 366 ++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 src/migrations/20240520000000-merge_duplicate_args.js create mode 100644 src/migrations/20240520000001-create-timeseries-and-root-causes.js diff --git a/src/migrations/20240520000000-merge_duplicate_args.js b/src/migrations/20240520000000-merge_duplicate_args.js new file mode 100644 index 0000000000..e7403aa43f --- /dev/null +++ b/src/migrations/20240520000000-merge_duplicate_args.js @@ -0,0 +1,229 @@ +const { + prepMigration, +} = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + await queryInterface.sequelize.query(` + + -- Creating it as a function because we'll need to rerun this in the future + -- up until and unless all issues producing duplicate ARGs are addressed + + CREATE OR REPLACE FUNCTION dedupe_args() + RETURNS VOID LANGUAGE sql AS + $$ + -- There are some duplicate ARGs, meaning link records that connect the same + -- AR-Goal pairs. This migration merges them down to the link record that was + -- most recently updated and thus presumably has the latest status & etc. + -- Merging rather than simply deleting is necessary to account for + -- ActivityReportGoalFieldResponses and ActivityReportGoalResources, both of + -- which link to ARGs and so may need to be moved and deconflicted. + -- Neither ActivityReportGoalResources nor ActivityReportGoalFieldResponses + -- have applicable records at time of writing, but this may not remain true in + -- the future when this runs. + + DROP TABLE IF EXISTS arg_merges; + CREATE TEMP TABLE arg_merges + AS + WITH link_counts AS ( + SELECT + "activityReportId" arid, + "goalId" gid, + COUNT(*) link_cnt + FROM "ActivityReportGoals" + GROUP BY 1,2 + ), + latest_updated AS ( + SELECT + arid, + gid, + arg.id argid, + ROW_NUMBER() OVER ( + PARTITION BY arid,gid + ORDER BY "updatedAt" DESC, arg.id + ) updated_rank + FROM "ActivityReportGoals" arg + JOIN link_counts + ON "activityReportId" = arid + AND "goalId" = gid + WHERE link_cnt > 1 + ) + SELECT + id donor_arg, + argid target_arg + FROM "ActivityReportGoals" arg + JOIN latest_updated + ON "activityReportId" = arid + AND "goalId" = gid + AND updated_rank = 1 + ; + + -- Relink any ActivityReportGoalFieldResponses connected to the + -- duplicate (and therefore donor) ARG + -- Because there could theoretically be multiple prompts on + -- multiple duplicates, we need to rank the ARGFRs referring to + -- a particular prompt-goal pair and select just one of each. + -- There's one target_arg per goal so we use that as a proxy. + -- Just for simplicity, ARGFRs that are already on the target + -- ARG are left alone and the corresponding responses on donors + -- will be deleted. + -- + -- At time of writing this is all theoretical as there aren't + -- any reponses at all for FEI goals with duplicate ARGs, but this + -- could change by the time it runs + DROP TABLE IF EXISTS relinked_argfrs; + CREATE TEMP TABLE relinked_argfrs + AS + WITH updater AS ( + WITH argfr_on_donor_args AS ( + SELECT + donor_arg, + target_arg, + argfr."activityReportGoalId" argid, + argfr."goalTemplateFieldPromptId" promptid, + argfr.id argfrid, + ROW_NUMBER() OVER ( + PARTITION BY target_arg, argfr."goalTemplateFieldPromptId" + ORDER BY argfr."activityReportGoalId" = target_arg, argfr."updatedAt" DESC, argfr.id + ) choice_rank + FROM arg_merges am + JOIN "ActivityReportGoalFieldResponses" argfr + ON am.donor_arg = argfr."activityReportGoalId" + ), unmatched AS ( + SELECT + donor_arg, + argid, + argfrid + FROM argfr_on_donor_args aoda + WHERE choice_rank = 1 + AND argid != target_arg + ) + UPDATE "ActivityReportGoalFieldResponses" AS argfr + SET "activityReportGoalId" = target_arg + FROM arg_merges am + JOIN unmatched u + ON u.donor_arg = am.donor_arg + WHERE argfr.id = u.argfrid + RETURNING + id argfrid, + am.donor_arg original_arg + ) SELECT * FROM updater + ; + + -- Delete duplicate objective ARGFRs + DROP TABLE IF EXISTS deleted_argfrs; + CREATE TEMP TABLE deleted_argfrs + AS + WITH updater AS ( + DELETE FROM "ActivityReportGoalFieldResponses" + USING arg_merges + WHERE "activityReportGoalId" = donor_arg + RETURNING + id argfrid, + donor_arg + ) SELECT * FROM updater + ; + + -- Relink any ActivityReportGoalResources connected to the + -- duplicate (and therefore donor) ARG + -- Because there could theoretically be multiple resources on + -- multiple duplicates, we need to rank the ARGRs referring to + -- a particular resource-goal pair and select just one of each. + -- There's one target_arg per goal so we use that as a proxy. + -- Just for simplicity, ARGRs that are already on the target + -- ARG are left alone and the corresponding responses on donors + -- will be deleted. + -- + -- At time of writing this is all theoretical as there aren't + -- any ARGRs at all but this could change by the time it runs + CREATE TEMP TABLE relinked_argrs + AS + WITH updater AS ( + WITH argr_on_donor_args AS ( + SELECT + donor_arg, + target_arg, + argr."activityReportGoalId" argid, + argr."resourceId" resourceid, + argr.id argrid, + ROW_NUMBER() OVER ( + PARTITION BY target_arg, argr."resourceId" + ORDER BY argr."activityReportGoalId" = target_arg, argr."updatedAt" DESC, argr.id + ) choice_rank + FROM arg_merges am + JOIN "ActivityReportGoalResources" argr + ON am.donor_arg = argr."activityReportGoalId" + ), unmatched AS ( + SELECT + donor_arg, + argid, + argrid + FROM argr_on_donor_args aoda + WHERE choice_rank = 1 + AND argid != target_arg + ) + UPDATE "ActivityReportGoalResources" AS argr + SET "activityReportGoalId" = target_arg + FROM arg_merges am + JOIN unmatched u + ON u.donor_arg = am.donor_arg + WHERE argr.id = u.argrid + RETURNING + id argrid, + am.donor_arg original_arg + ) SELECT * FROM updater + ; + -- Delete duplicate objective ARGRs + DROP TABLE IF EXISTS deleted_argrs; + CREATE TEMP TABLE deleted_argrs + AS + WITH updater AS ( + DELETE FROM "ActivityReportGoalResources" + USING arg_merges + WHERE "activityReportGoalId" = donor_arg + RETURNING + id argrid, + donor_arg + ) SELECT * FROM updater + ; + + -- Delete duplicate ARGs + DROP TABLE IF EXISTS deleted_args; + CREATE TEMP TABLE deleted_args + AS + WITH updater AS ( + DELETE FROM "ActivityReportGoals" + USING arg_merges + WHERE id = donor_arg + AND target_arg != donor_arg + RETURNING + donor_arg + ) SELECT * FROM updater + ; + SELECT + 1 op_order, + 'relinked_argfrs' op_name, + COUNT(*) record_cnt + FROM relinked_argfrs + UNION SELECT 2, 'deleted_argfrs', COUNT(*) FROM deleted_argfrs + UNION SELECT 3, 'relinked_argrs', COUNT(*) FROM relinked_argrs + UNION SELECT 4, 'deleted_argrs', COUNT(*) FROM deleted_argrs + UNION SELECT 5, 'deleted_args', COUNT(*) FROM deleted_args + ORDER BY 1 + ; + + $$ + ; + -- Actually call the function + SELECT dedupe_args(); + `, { transaction }); + }); + }, + async down() { + // rolling back merges and deletes would be a mess + }, +}; diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js new file mode 100644 index 0000000000..45fa66fdb9 --- /dev/null +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -0,0 +1,366 @@ +const { + prepMigration, +} = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + // This starts by creating a stored procedure that can be + // used now and in the future to create a time series table with a + // name of the form _timeseries, e.g. Goals_timeseries + + // usage: SELECT create_timeseries_from_audit_log('') + // example: SELECT create_timeseries_from_audit_log('Goals') + // creates Goals_timeseries + + // Resulting tables are like their source tables except: + // * id becomes data_id and is only unique when combined with the timeband + // * timeband_start identifies the beginning of the timeband of a particular + // record state + // * timeband_end identifies the ending of the timeband were the record has + // that state + // * enums are converted to text values because enums change over time and + // thus historical data may not fit current enums + + // Root causes were not saving to ActivityReportGoalFieldResponses + // on multi-recipient ARs where a goal already had a root cause + // for one recipient. Because ActivityReportGoalFieldResponses + // is supposed to have the root cause as it was at report approval + // time, which can differ from the current root causes for goals + // as recorded in GoalFieldResponses. Calculating what the state of + // root causes at the time of AR approval is aided by having a + // time series of GoalFieldResponses to join into with the addition of + // of a check like: + // LEFT JOIN GoalFieldResponses_timeseries gfrt + // ON arg."goalId" = gfrt."goalId" + // AND ar."approvedAt" BETWEEN gfrt.timeband_start AND gfrt.timeband_end + + await queryInterface.sequelize.query(` + + CREATE OR REPLACE FUNCTION create_timeseries_from_audit_log(tablename text) + RETURNS VOID LANGUAGE plpgsql AS + $$ + DECLARE + qry text := ''; + wtext text := ''; + rec record; + BEGIN + + -- Get the column list for the main table + qry := format(' + DROP TABLE IF EXISTS clist; + CREATE TEMP TABLE clist + AS + SELECT + column_name cname + ,ordinal_position cnum + ,data_type ctype + FROM information_schema.columns ic + WHERE table_schema = %L + AND table_name = %L' + ,'public' + ,tablename); + + EXECUTE qry; + qry := ''; + + -- Get the pg_typeof column datatypes for the main table + -- these are more precise than the information schema types + qry := 'DROP TABLE IF EXISTS ctypes; + CREATE TEMP TABLE ctypes + AS'; + + FOR rec IN + SELECT * FROM clist ORDER BY cnum + LOOP + wtext := wtext || format(' + SELECT cname, cnum, ctype, pg_typeof( %I ) pgtype FROM %I JOIN clist ON %L = cname UNION' + ,rec.cname + ,tablename + ,rec.cname); + END LOOP; + + qry := qry || LEFT(wtext,-6) || ' + ORDER BY cnum'; + wtext := ''; + + EXECUTE qry; + qry := ''; + + -- set up the beginning and end of time + qry := format('DROP TABLE IF EXISTS timeband; + CREATE TEMP TABLE timeband + AS + SELECT + %L::timestamp timebegin, + NOW() timeend' + ,'2020-01-01'); + + EXECUTE qry; + qry := ''; + wtext := ''; + + -- assemble flat_z, containing the typed columns with changed data + -- there will be one record per audit log entry, plus one for the + -- current value + -- This assumes every table as an id column, which the audit log + -- also assumes + qry := format('DROP TABLE IF EXISTS flat_z; + CREATE TEMP TABLE flat_z + AS + SELECT + id zid + ,data_id + ,dml_timestamp + ,dml_type = %L is_insert + ,FALSE is_current_record' + ,'INSERT'); + + FOR rec IN + SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum + LOOP + CASE + WHEN rec.ctype = 'USER-DEFINED' THEN -- for enums + -- because the enums have changed over time we + -- are only casting to text. The other option + -- of building a new enum containing all historical + -- enums is both more complex and won't make + -- using the resulting time series any easier + wtext := wtext || format(' + ,(old_row_data->>%L)::%s AS %I' + ,rec.cname + ,'text' + ,rec.cname); + WHEN rec.ctype = 'ARRAY' THEN -- for string arrays + wtext := wtext || format(' + ,( + string_to_array( + TRIM( + TRIM( + regexp_replace((old_row_data->>%L), %L , %L, %L + ), %L + ),%L + ), %L + ) + )::%s AS %I' + ,rec.cname + ,'", "' + ,'X-X-X' + ,'g' + ,'["' + ,'"]' + ,'X-X-X' + ,rec.pgtype + ,rec.cname); + ELSE -- for everything else + wtext := wtext || format(' + ,(old_row_data->>%L)::%s AS %I' + ,rec.cname + ,rec.ctype + ,rec.cname); + END CASE; + -- this detects whether the column was updated to be null + wtext := wtext || format(' + ,(old_row_data->%L) = %L %I' + ,rec.cname + ,'null' + ,rec.cname || '_isnull'); + END LOOP; + + qry := qry || wtext || format(' + FROM %I + UNION ALL + SELECT + 9223372036854775807 --max bigint so these always sort last + ,id + ,timeend + ,FALSE + ,TRUE' + , 'ZAL' || tablename); + wtext := ''; + + FOR rec IN + SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum + LOOP + CASE + WHEN rec.ctype = 'USER-DEFINED' THEN -- for enums + -- this is to match pushing enums to text in + -- records pulled from the audit log + wtext := wtext || format(' + ,%I::%s' + ,rec.cname + ,'text'); + ELSE + wtext := wtext || format(' + ,%I' + ,rec.cname); + END CASE; + wtext := wtext || format(' + ,%I IS NULL %I' + ,rec.cname + ,rec.cname || '_isnull'); + END LOOP; + + qry := qry || wtext || format(' + FROM %I + CROSS JOIN timeband + ORDER BY 2,1' + ,tablename); + wtext := ''; + + EXECUTE qry; + qry := ''; + + -- create group ids for each column to identify which iteration + -- of column value each record should have + qry := 'DROP TABLE IF EXISTS group_z; + CREATE TEMP TABLE group_z + AS + SELECT + zid + ,data_id'; + + FOR rec IN + SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum + LOOP + wtext := wtext || format(' + ,SUM(CASE WHEN %I OR %I IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY data_id ORDER BY zid DESC ROWS UNBOUNDED PRECEDING) AS %I' + ,rec.cname || '_isnull' + ,rec.cname + ,rec.cname || '_group'); + END LOOP; + + qry := qry || wtext || E'\n' || 'FROM flat_z'; + wtext := ''; + + EXECUTE qry; + qry := ''; + + -- spread the value from the records with update values throughout their respective groups + -- also create the start and end timestamps using adjacent timestamps. Add one millisecond + -- to the previous record's timestamp so it's not possible to match both with a BETWEEN. + -- This is not implausible if a large number of records are updated at the same time in a + -- shared transaction + qry := format('DROP TABLE IF EXISTS banded_z; + CREATE TEMP TABLE banded_z + AS + SELECT + fz.zid + ,fz.data_id + ,fz.is_insert + ,fz.is_current_record + ,(LAG(fz.dml_timestamp) OVER (PARTITION BY fz.data_id ORDER BY fz.zid)) + (1 * interval %L) timeband_start + ,fz.dml_timestamp timeband_end' + ,'1 ms'); + + FOR rec IN + SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum + LOOP + wtext := wtext || format(' + ,FIRST_VALUE(fz.%I) OVER (PARTITION BY fz.data_id, %I ORDER BY fz.zid DESC) AS %I' + ,rec.cname + ,rec.cname || '_group' + ,rec.cname); + END LOOP; + + qry := qry || wtext || ' + FROM flat_z fz + JOIN group_z gz + ON fz.zid = gz.zid + AND fz.data_id = gz.data_id'; + wtext := ''; + + EXECUTE qry; + qry := ''; + + -- create the actual time series table + qry := format('DROP TABLE IF EXISTS %I; + CREATE TEMP TABLE %I + AS + SELECT + data_id + ,CASE + WHEN is_current_record AND timeband_start IS NULL THEN timebegin + ELSE COALESCE(timeband_start, timebegin) + END timeband_start + ,timeband_end' + ,tablename || '_timeseries' + ,tablename || '_timeseries'); + + FOR rec IN + SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum + LOOP + wtext := wtext || format(' + ,%I' + ,rec.cname); + END LOOP; + + qry := qry || wtext || ' + FROM banded_z + CROSS JOIN timeband + WHERE NOT is_insert'; + wtext := ''; + + EXECUTE qry; + + END + $$ + ; + `, { transaction }); + + // Putting this in a separate transaction because we want + // create_timeseries_from_audit_log() created regardless + await queryInterface.sequelize.query(/* sql */` + + -- Create GoalFieldResponses_timeseries + SELECT create_timeseries_from_audit_log('GoalFieldResponses'); + + -- Pull the data necessary to create an ARGFR from the historical + -- state of the associated GFR + DROP TABLE IF EXISTS argfrs_to_insert; + CREATE TEMP TABLE argfrs_to_insert + AS + SELECT + arg.id argid, + gfrt."goalId" gid, + gfrt.data_id gfrid, + gfrt."goalTemplateFieldPromptId", + gfrt.response + FROM "ActivityReports" ar + JOIN "ActivityReportGoals" arg + ON ar.id = arg."activityReportId" + JOIN "GoalFieldResponses_timeseries" gfrt + ON arg."goalId" = gfrt."goalId" + AND ar."approvedAt" BETWEEN timeband_start AND timeband_end + LEFT JOIN "ActivityReportGoalFieldResponses" argfr + ON arg.id = argfr."activityReportGoalId" + WHERE argfr.id IS NULL + ; + + -- Insert the records + INSERT INTO "ActivityReportGoalFieldResponses" ( + "activityReportGoalId", + "goalTemplateFieldPromptId", + response, + "createdAt", + "updatedAt" + ) + SELECT + argid, + "goalTemplateFieldPromptId", + response, + NOW(), + NOW() + FROM argfrs_to_insert + ; + `, { transaction }); + }); + }, + async down() { + // rolling back merges and deletes would be a mess + }, +}; From 937f4d988d092c1dc67b1a69dfefa3162417afb1 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 08:13:25 -0700 Subject: [PATCH 40/78] use current values if no historical root cause --- ...240520000001-create-timeseries-and-root-causes.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 45fa66fdb9..88e5a3dc98 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -328,17 +328,23 @@ module.exports = { arg.id argid, gfrt."goalId" gid, gfrt.data_id gfrid, - gfrt."goalTemplateFieldPromptId", - gfrt.response + COALESCE(gfrt."goalTemplateFieldPromptId", gfr."goalTemplateFieldPromptId") "goalTemplateFieldPromptId", + COALESCE(gfrt.response, gfr.response) response FROM "ActivityReports" ar JOIN "ActivityReportGoals" arg ON ar.id = arg."activityReportId" - JOIN "GoalFieldResponses_timeseries" gfrt + JOIN "Goals" g + ON arg."goalId" = g.id + LEFT JOIN "GoalFieldResponses_timeseries" gfrt ON arg."goalId" = gfrt."goalId" AND ar."approvedAt" BETWEEN timeband_start AND timeband_end LEFT JOIN "ActivityReportGoalFieldResponses" argfr ON arg.id = argfr."activityReportGoalId" + LEFT JOIN "GoalFieldResponses" gfr + ON gfr."goalId" = arg."goalId" WHERE argfr.id IS NULL + AND g."goalTemplateId" = 19017 + AND (gfrt.response IS NOT NULL OR gfr.response IS NOT NULL) ; -- Insert the records From 2f32d4bb13a4d3b8e019eecd08f6be94091d19f9 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 08:14:59 -0700 Subject: [PATCH 41/78] pacify linter --- .../20240520000001-create-timeseries-and-root-causes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 88e5a3dc98..474b501921 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -12,11 +12,11 @@ module.exports = { // This starts by creating a stored procedure that can be // used now and in the future to create a time series table with a // name of the form _timeseries, e.g. Goals_timeseries - + // usage: SELECT create_timeseries_from_audit_log('') // example: SELECT create_timeseries_from_audit_log('Goals') // creates Goals_timeseries - + // Resulting tables are like their source tables except: // * id becomes data_id and is only unique when combined with the timeband // * timeband_start identifies the beginning of the timeband of a particular @@ -311,7 +311,7 @@ module.exports = { $$ ; `, { transaction }); - + // Putting this in a separate transaction because we want // create_timeseries_from_audit_log() created regardless await queryInterface.sequelize.query(/* sql */` From e8d5eafdcb3b658302710168262401bee5ae1738 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 09:24:44 -0700 Subject: [PATCH 42/78] switch to plpgsql --- .../20240520000000-merge_duplicate_args.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/migrations/20240520000000-merge_duplicate_args.js b/src/migrations/20240520000000-merge_duplicate_args.js index e7403aa43f..2a15a3dcc9 100644 --- a/src/migrations/20240520000000-merge_duplicate_args.js +++ b/src/migrations/20240520000000-merge_duplicate_args.js @@ -14,8 +14,9 @@ module.exports = { -- up until and unless all issues producing duplicate ARGs are addressed CREATE OR REPLACE FUNCTION dedupe_args() - RETURNS VOID LANGUAGE sql AS + RETURNS VOID LANGUAGE plpgsql AS $$ + BEGIN -- There are some duplicate ARGs, meaning link records that connect the same -- AR-Goal pairs. This migration merges them down to the link record that was -- most recently updated and thus presumably has the latest status & etc. @@ -140,6 +141,7 @@ module.exports = { -- -- At time of writing this is all theoretical as there aren't -- any ARGRs at all but this could change by the time it runs + DROP TABLE IF EXISTS relinked_argrs; CREATE TEMP TABLE relinked_argrs AS WITH updater AS ( @@ -204,6 +206,13 @@ module.exports = { donor_arg ) SELECT * FROM updater ; + + END + $$ + ; + -- Actually call the function + SELECT dedupe_args(); + SELECT 1 op_order, 'relinked_argfrs' op_name, @@ -213,13 +222,7 @@ module.exports = { UNION SELECT 3, 'relinked_argrs', COUNT(*) FROM relinked_argrs UNION SELECT 4, 'deleted_argrs', COUNT(*) FROM deleted_argrs UNION SELECT 5, 'deleted_args', COUNT(*) FROM deleted_args - ORDER BY 1 - ; - - $$ - ; - -- Actually call the function - SELECT dedupe_args(); + ORDER BY 1; `, { transaction }); }); }, From b73d62741113a03c3be5f95a4646a94a5cc92f03 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 10:09:34 -0700 Subject: [PATCH 43/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 474b501921..3ad65d7bc5 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -318,6 +318,9 @@ module.exports = { -- Create GoalFieldResponses_timeseries SELECT create_timeseries_from_audit_log('GoalFieldResponses'); + + -- Debugging wth is going on in CircleCI + SELECT * FROM "GoalFieldResponses_timeseries" LIMIT 1; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From 764ff5120b7ca70974047f112c8a0c2b37bd92e7 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 10:32:49 -0700 Subject: [PATCH 44/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 3ad65d7bc5..3b64dd5c9d 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -318,9 +318,9 @@ module.exports = { -- Create GoalFieldResponses_timeseries SELECT create_timeseries_from_audit_log('GoalFieldResponses'); - + -- Debugging wth is going on in CircleCI - SELECT * FROM "GoalFieldResponses_timeseries" LIMIT 1; + SELECT data_id, "goalId" FROM "GoalFieldResponses_timeseries" LIMIT 1; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From 3fff80b10bfa69a4937358d11cbd0496997c5499 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 10:39:17 -0700 Subject: [PATCH 45/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 3b64dd5c9d..85b8c79f03 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -320,7 +320,7 @@ module.exports = { SELECT create_timeseries_from_audit_log('GoalFieldResponses'); -- Debugging wth is going on in CircleCI - SELECT data_id, "goalId" FROM "GoalFieldResponses_timeseries" LIMIT 1; + SELECT id, "goalId" FROM "GoalFieldResponses" LIMIT 1; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From 78c51e8fc0f682f4b4f31182378633579764ff3b Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 10:50:50 -0700 Subject: [PATCH 46/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 85b8c79f03..04999c80df 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -316,11 +316,13 @@ module.exports = { // create_timeseries_from_audit_log() created regardless await queryInterface.sequelize.query(/* sql */` + BEGIN; -- Create GoalFieldResponses_timeseries SELECT create_timeseries_from_audit_log('GoalFieldResponses'); + COMMIT; -- Debugging wth is going on in CircleCI - SELECT id, "goalId" FROM "GoalFieldResponses" LIMIT 1; + SELECT id, "goalId" FROM "GoalFieldResponse_timeseries" LIMIT 1; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From cb7cfb56a6e3db689f42df99093cb543197e3aa1 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 11:00:33 -0700 Subject: [PATCH 47/78] typo fix --- .../20240520000001-create-timeseries-and-root-causes.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 04999c80df..e50a754e33 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -316,13 +316,8 @@ module.exports = { // create_timeseries_from_audit_log() created regardless await queryInterface.sequelize.query(/* sql */` - BEGIN; -- Create GoalFieldResponses_timeseries - SELECT create_timeseries_from_audit_log('GoalFieldResponses'); - COMMIT; - - -- Debugging wth is going on in CircleCI - SELECT id, "goalId" FROM "GoalFieldResponse_timeseries" LIMIT 1; + SELECT create_timeseries_from_audit_log('GoalFieldResponse'); -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From 53eb1c6edd7d3ad6ab2a7c310086c47c4cfe5989 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 11:04:05 -0700 Subject: [PATCH 48/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index e50a754e33..c39876f652 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -317,7 +317,10 @@ module.exports = { await queryInterface.sequelize.query(/* sql */` -- Create GoalFieldResponses_timeseries - SELECT create_timeseries_from_audit_log('GoalFieldResponse'); + BEGIN; + SELECT create_timeseries_from_audit_log('GoalFieldResponses'); + COMMIT; + SELECT data_id, "goalId" from "GoalFieldResponses_timeseries"; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From b1cf6d787917b9863caad3abb540c9f3416203ab Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 11:50:21 -0700 Subject: [PATCH 49/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index c39876f652..3934993480 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -317,6 +317,7 @@ module.exports = { await queryInterface.sequelize.query(/* sql */` -- Create GoalFieldResponses_timeseries + SELECT id, "goalId" from public."GoalFieldResponses" LIMIT 1; BEGIN; SELECT create_timeseries_from_audit_log('GoalFieldResponses'); COMMIT; From 99ddb13d7fabb62f4c7cae6e9ae832365ed3135a Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 11:56:18 -0700 Subject: [PATCH 50/78] debug --- .../20240520000001-create-timeseries-and-root-causes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 3934993480..12c659947c 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -321,7 +321,7 @@ module.exports = { BEGIN; SELECT create_timeseries_from_audit_log('GoalFieldResponses'); COMMIT; - SELECT data_id, "goalId" from "GoalFieldResponses_timeseries"; + SELECT data_id, response, "goalId" from "GoalFieldResponses_timeseries"; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR From 4f40d6df07a4c76b7cff1474f3907b743d6c384d Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 12:48:04 -0700 Subject: [PATCH 51/78] tolerate empty tables --- .../20240520000001-create-timeseries-and-root-causes.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 12c659947c..2445004df5 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -78,7 +78,7 @@ module.exports = { SELECT * FROM clist ORDER BY cnum LOOP wtext := wtext || format(' - SELECT cname, cnum, ctype, pg_typeof( %I ) pgtype FROM %I JOIN clist ON %L = cname UNION' + SELECT cname, cnum, ctype, pg_typeof( %I ) pgtype FROM clist LEFT JOIN %I ON %L = cname LIMIT 1 UNION' ,rec.cname ,tablename ,rec.cname); @@ -317,11 +317,8 @@ module.exports = { await queryInterface.sequelize.query(/* sql */` -- Create GoalFieldResponses_timeseries - SELECT id, "goalId" from public."GoalFieldResponses" LIMIT 1; - BEGIN; + SELECT create_timeseries_from_audit_log('GoalFieldResponses'); - COMMIT; - SELECT data_id, response, "goalId" from "GoalFieldResponses_timeseries"; -- Pull the data necessary to create an ARGFR from the historical -- state of the associated GFR @@ -367,6 +364,7 @@ module.exports = { NOW() FROM argfrs_to_insert ; + */ `, { transaction }); }); }, From c48f55d49c894f7db029aa288455f0764198a688 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 12:54:17 -0700 Subject: [PATCH 52/78] delete cruft --- .../20240520000001-create-timeseries-and-root-causes.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 2445004df5..4d67ee8c69 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -364,11 +364,8 @@ module.exports = { NOW() FROM argfrs_to_insert ; - */ `, { transaction }); }); }, - async down() { - // rolling back merges and deletes would be a mess - }, + async down() {}, }; From 0ebe29c85f1de13660d9c2ce4f35a2af3d220a0d Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 21 May 2024 16:07:19 -0400 Subject: [PATCH 53/78] Fix for other entity objectives --- .../components/Navigator/ActivityReportNavigator.js | 2 +- src/services/objectives.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Navigator/ActivityReportNavigator.js b/frontend/src/components/Navigator/ActivityReportNavigator.js index a67a6389c2..32fe3a29c9 100644 --- a/frontend/src/components/Navigator/ActivityReportNavigator.js +++ b/frontend/src/components/Navigator/ActivityReportNavigator.js @@ -405,7 +405,7 @@ const ActivityReportNavigator = ({ }; if (allowUpdateFormData) { - updateFormData(data, true); + updateFormData(data, false); } updateErrorMessage(''); diff --git a/src/services/objectives.ts b/src/services/objectives.ts index 9dbc9f65e9..6b7f290baf 100644 --- a/src/services/objectives.ts +++ b/src/services/objectives.ts @@ -14,6 +14,7 @@ const { File, Course, Resource, + sequelize, } = db; export async function getObjectiveRegionAndGoalStatusByIds(ids: number[]) { @@ -56,7 +57,7 @@ export async function saveObjectivesForReport(objectives, report) { const updatedObjectives = await Promise.all(objectives.map(async (objective, index) => Promise .all(objective.recipientIds.map(async (otherEntityId) => { const { - topics, files, resources, courses, + topics, files, resources, courses, objectiveCreatedHere, } = objective; // Determine if this objective already exists. @@ -128,6 +129,7 @@ export async function saveObjectivesForReport(objectives, report) { ttaProvided: objective.ttaProvided, supportType: objective.supportType, order: index, + objectiveCreatedHere, }); return savedObjective; @@ -253,7 +255,11 @@ function reduceOtherEntityObjectives(newObjectives) { export async function getObjectivesByReportId(reportId) { const objectives = await Objective.findAll({ - model: Objective, + attributes: { + include: [ + [sequelize.col('activityReportObjectives.objectiveCreatedHere'), 'objectiveCreatedHere'], + ], + }, where: { goalId: { [Op.is]: null }, otherEntityId: { [Op.not]: null }, From 2a72ac614e0a12ebfb2d88355b574fa051ce9a9f Mon Sep 17 00:00:00 2001 From: GarrettEHill Date: Tue, 21 May 2024 14:07:44 -0700 Subject: [PATCH 54/78] Create monthly-delivery-report.sql --- src/queries/monthly-delivery-report.sql | 228 ++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 src/queries/monthly-delivery-report.sql diff --git a/src/queries/monthly-delivery-report.sql b/src/queries/monthly-delivery-report.sql new file mode 100644 index 0000000000..e7f0d423c2 --- /dev/null +++ b/src/queries/monthly-delivery-report.sql @@ -0,0 +1,228 @@ +/** +* This query collects all the Monitoring goals used on approved reports within the defined time range. +* +* The query results are filterable by the JDI flags. All JDI flags are passed as an array of values +* The following are the available flags within this script: +* - jdi.regionIds - one or more values for 1 through 12 +* - jdi.startDate - two dates defining a range for the startDate to be within +* +* zero or more JDI flags can be set within the same transaction as the query is executed. +* The following is an example of how to set a JDI flag: +* SELECT SET_CONFIG('jdi.regionIds','[11]',TRUE); +* SELECT SET_CONFIG('jdi.startDate','["2024-04-01","2024-04-30"]',TRUE); +*/ + +WITH + duration_data AS ( + SELECT + r.name AS "User Role", + NULL AS "User", + COUNT(DISTINCT a.id) AS "Reports", + SUM(a.duration) AS "Total Duration", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" + FROM "ActivityReports" a + JOIN "Users" u ON a."userId" = u.id + JOIN "UserRoles" ur ON u.id = ur."userId" + JOIN "Roles" r ON ur."roleId" = r.id + WHERE a."calculatedStatus" = 'approved' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR u."homeRegionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + -- Filter for startDate dates between two values if jdi.startDate is defined + AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + GROUP BY 1,2 + + UNION ALL + + SELECT + r.name AS "User Role", + u.name AS "User", + COUNT(DISTINCT a.id) AS "Reports", + SUM(a.duration) AS "Total Duration", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" + FROM "ActivityReports" a + JOIN "Users" u ON a."userId" = u.id + JOIN "UserRoles" ur ON u.id = ur."userId" + JOIN "Roles" r ON ur."roleId" = r.id + WHERE a."calculatedStatus" = 'approved' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR u."homeRegionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + -- Filter for startDate dates between two values if jdi.startDate is defined + AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + GROUP BY 1,2 + + UNION ALL + + SELECT + 'Total' AS "User Role", + NULL AS "User", + COUNT(DISTINCT a.id) AS "Reports", + SUM(a.duration) AS "Total Duration", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" + FROM "ActivityReports" a + JOIN "Users" u ON a."userId" = u.id + JOIN "UserRoles" ur ON u.id = ur."userId" + JOIN "Roles" r ON ur."roleId" = r.id + WHERE a."calculatedStatus" = 'approved' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR u."homeRegionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + -- Filter for startDate dates between two values if jdi.startDate is defined + AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + ), + grant_counts AS ( + SELECT + r.name, + COUNT(DISTINCT gr."number") AS grant_count, + COUNT(DISTINCT gr."recipientId") AS recipient_count + FROM "Grants" gr + LEFT JOIN "ActivityRecipients" ar + ON gr."id" = ar."grantId" + LEFT JOIN "ActivityReports" a + ON ar."activityReportId" = a.id + LEFT JOIN "Users" u + ON a."userId" = u.id + LEFT JOIN "UserRoles" ur + ON u.id = ur."userId" + LEFT JOIN "Roles" r + ON ur."roleId" = r.id + WHERE gr.status = 'Active' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR gr."regionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + AND (a."calculatedStatus" = 'approved' OR a."calculatedStatus" IS NULL) + AND ( + -- Filter for startDate dates between two values if jdi.startDate is defined + (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + OR a."startDate" IS NULL) + AND r.name IS NOT NULL + GROUP BY r.name + ), + grant_count_users AS ( + SELECT + r.name, + u.name "User", + COUNT(DISTINCT gr.number) AS grant_count, + COUNT(DISTINCT gr."recipientId") AS recipient_count + FROM "Grants" gr + LEFT JOIN "ActivityRecipients" ar + ON gr."id" = ar."grantId" + LEFT JOIN "ActivityReports" a + ON ar."activityReportId" = a.id + LEFT JOIN "Users" u + ON a."userId" = u.id + LEFT JOIN "UserRoles" ur + ON u.id = ur."userId" + LEFT JOIN "Roles" r + ON ur."roleId" = r.id + WHERE gr.status = 'Active' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR gr."regionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + AND (a."calculatedStatus" = 'approved' OR a."calculatedStatus" IS NULL) + AND ( + -- Filter for startDate dates between two values if jdi.startDate is defined + (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + OR a."startDate" IS NULL) + AND r.name IS NOT NULL + GROUP BY 1,2 + ), + total_grants AS ( + SELECT + COUNT(DISTINCT gr.number) AS grant_count, + COUNT(DISTINCT gr."recipientId") AS recipient_count + FROM "Grants" gr + WHERE gr.status = 'Active' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR gr."regionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + ), + recipient_data AS ( + SELECT + gc.name AS "User Role", + NULL AS "User", + gc.grant_count, + ((gc.grant_count::float / tg.grant_count) * 100)::DECIMAL(5,2) AS grant_percentage, + ((gc.recipient_count::float / tg.recipient_count) * 100)::DECIMAL(5,2) AS recipient_percentage + FROM grant_counts gc + CROSS JOIN total_grants tg + + UNION ALL + + SELECT + gc.name AS "User Role", + gc."User" AS "User", + gc.grant_count, + ((gc.grant_count::float / tg.grant_count) * 100)::DECIMAL(5,2) AS grant_percentage, + ((gc.recipient_count::float / tg.recipient_count) * 100)::DECIMAL(5,2) AS recipient_percentage + FROM grant_count_users gc + CROSS JOIN total_grants tg + + UNION ALL + + SELECT + 'Total' AS "User Role", + NULL AS "User", + SUM(gc.grant_count) AS grant_count, + ((SUM(gc.grant_count)::float / MAX(tg.grant_count)) * 100)::DECIMAL(5,2) AS grant_percentage, + ((SUM(gc.recipient_count)::float / MAX(tg.recipient_count)) * 100)::DECIMAL(5,2) AS recipient_percentage + FROM grant_counts gc + CROSS JOIN total_grants tg + ) + SELECT + dd.*, + rd.grant_count AS "Grants", + rd.grant_percentage AS "Percentage of Grants", + rd.recipient_percentage AS "Percentage of Recipients" + FROM duration_data dd + JOIN recipient_data rd + ON dd."User Role" = rd."User Role" + AND (dd."User" = rd."User" OR (dd."User" IS NULL AND rd."User" IS NULL)) + ORDER BY + 1 desc, + 2 NULLS FIRST From 802bed9f7f6b4343ecaa218dca213423512010d8 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 14:08:37 -0700 Subject: [PATCH 55/78] tolerate empty tables --- ...00001-create-timeseries-and-root-causes.js | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 4d67ee8c69..9d8634483e 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -49,7 +49,6 @@ module.exports = { wtext text := ''; rec record; BEGIN - -- Get the column list for the main table qry := format(' DROP TABLE IF EXISTS clist; @@ -64,33 +63,27 @@ module.exports = { AND table_name = %L' ,'public' ,tablename); - EXECUTE qry; qry := ''; - -- Get the pg_typeof column datatypes for the main table -- these are more precise than the information schema types qry := 'DROP TABLE IF EXISTS ctypes; CREATE TEMP TABLE ctypes AS'; - FOR rec IN SELECT * FROM clist ORDER BY cnum LOOP wtext := wtext || format(' - SELECT cname, cnum, ctype, pg_typeof( %I ) pgtype FROM clist LEFT JOIN %I ON %L = cname LIMIT 1 UNION' + SELECT cname, cnum, ctype, pg_typeof( %I ) pgtype FROM clist LEFT JOIN (SELECT * FROM %I LIMIT 1) a ON TRUE WHERE %L = cname UNION' ,rec.cname ,tablename ,rec.cname); END LOOP; - qry := qry || LEFT(wtext,-6) || ' ORDER BY cnum'; wtext := ''; - EXECUTE qry; qry := ''; - -- set up the beginning and end of time qry := format('DROP TABLE IF EXISTS timeband; CREATE TEMP TABLE timeband @@ -99,11 +92,9 @@ module.exports = { %L::timestamp timebegin, NOW() timeend' ,'2020-01-01'); - EXECUTE qry; qry := ''; wtext := ''; - -- assemble flat_z, containing the typed columns with changed data -- there will be one record per audit log entry, plus one for the -- current value @@ -119,7 +110,6 @@ module.exports = { ,dml_type = %L is_insert ,FALSE is_current_record' ,'INSERT'); - FOR rec IN SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum LOOP @@ -170,7 +160,6 @@ module.exports = { ,'null' ,rec.cname || '_isnull'); END LOOP; - qry := qry || wtext || format(' FROM %I UNION ALL @@ -182,7 +171,6 @@ module.exports = { ,TRUE' , 'ZAL' || tablename); wtext := ''; - FOR rec IN SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum LOOP @@ -204,17 +192,14 @@ module.exports = { ,rec.cname ,rec.cname || '_isnull'); END LOOP; - qry := qry || wtext || format(' FROM %I CROSS JOIN timeband ORDER BY 2,1' ,tablename); wtext := ''; - EXECUTE qry; qry := ''; - -- create group ids for each column to identify which iteration -- of column value each record should have qry := 'DROP TABLE IF EXISTS group_z; @@ -223,7 +208,6 @@ module.exports = { SELECT zid ,data_id'; - FOR rec IN SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum LOOP @@ -233,13 +217,10 @@ module.exports = { ,rec.cname ,rec.cname || '_group'); END LOOP; - qry := qry || wtext || E'\n' || 'FROM flat_z'; wtext := ''; - EXECUTE qry; qry := ''; - -- spread the value from the records with update values throughout their respective groups -- also create the start and end timestamps using adjacent timestamps. Add one millisecond -- to the previous record's timestamp so it's not possible to match both with a BETWEEN. @@ -256,7 +237,6 @@ module.exports = { ,(LAG(fz.dml_timestamp) OVER (PARTITION BY fz.data_id ORDER BY fz.zid)) + (1 * interval %L) timeband_start ,fz.dml_timestamp timeband_end' ,'1 ms'); - FOR rec IN SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum LOOP @@ -266,17 +246,14 @@ module.exports = { ,rec.cname || '_group' ,rec.cname); END LOOP; - qry := qry || wtext || ' FROM flat_z fz JOIN group_z gz ON fz.zid = gz.zid AND fz.data_id = gz.data_id'; wtext := ''; - EXECUTE qry; qry := ''; - -- create the actual time series table qry := format('DROP TABLE IF EXISTS %I; CREATE TEMP TABLE %I @@ -290,7 +267,6 @@ module.exports = { ,timeband_end' ,tablename || '_timeseries' ,tablename || '_timeseries'); - FOR rec IN SELECT * FROM ctypes WHERE cname != 'id' ORDER BY cnum LOOP @@ -298,15 +274,12 @@ module.exports = { ,%I' ,rec.cname); END LOOP; - qry := qry || wtext || ' FROM banded_z CROSS JOIN timeband WHERE NOT is_insert'; wtext := ''; - EXECUTE qry; - END $$ ; From 5bf52948ddc230fcfc9a0fdb8adb3cdc7b9ba3c8 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 21 May 2024 18:43:10 -0700 Subject: [PATCH 56/78] improve comments --- .../20240520000001-create-timeseries-and-root-causes.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 9d8634483e..46649776e9 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -283,18 +283,15 @@ module.exports = { END $$ ; - `, { transaction }); - - // Putting this in a separate transaction because we want - // create_timeseries_from_audit_log() created regardless - await queryInterface.sequelize.query(/* sql */` -- Create GoalFieldResponses_timeseries SELECT create_timeseries_from_audit_log('GoalFieldResponses'); -- Pull the data necessary to create an ARGFR from the historical - -- state of the associated GFR + -- state of the associated GFR. If there is a historical state, use + -- it, if not, use the root cause currently on the goal. If there's + -- still no root cause, do nothing. DROP TABLE IF EXISTS argfrs_to_insert; CREATE TEMP TABLE argfrs_to_insert AS From d80fc559a2a1af9381312c52f58028c7149489d8 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Wed, 22 May 2024 09:06:40 -0400 Subject: [PATCH 57/78] deploy to dev --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d64bdb24c1..3ab6704b63 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -420,7 +420,7 @@ parameters: type: string dev_git_branch: # change to feature branch to test deployment description: "Name of github branch that will deploy to dev" - default: "mb/TTAHUB-2857/update-goal-status-logic" + default: "mb/TTAHUB-2660/activity-report-updates-for-reduced-objectives" type: string sandbox_git_branch: # change to feature branch to test deployment default: "mb/TTAHUB-2609/move-components" From be509c9c02d35f9cf9118831b07c8fc3324b0846 Mon Sep 17 00:00:00 2001 From: GarrettEHill Date: Wed, 22 May 2024 10:31:51 -0700 Subject: [PATCH 58/78] updated to include collaborators and users that have no activity within the time span --- src/queries/monthly-delivery-report.sql | 430 ++++++++++++------------ 1 file changed, 224 insertions(+), 206 deletions(-) diff --git a/src/queries/monthly-delivery-report.sql b/src/queries/monthly-delivery-report.sql index e7f0d423c2..58c7fda87f 100644 --- a/src/queries/monthly-delivery-report.sql +++ b/src/queries/monthly-delivery-report.sql @@ -11,218 +11,236 @@ * SELECT SET_CONFIG('jdi.regionIds','[11]',TRUE); * SELECT SET_CONFIG('jdi.startDate','["2024-04-01","2024-04-30"]',TRUE); */ - WITH - duration_data AS ( - SELECT - r.name AS "User Role", - NULL AS "User", - COUNT(DISTINCT a.id) AS "Reports", - SUM(a.duration) AS "Total Duration", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" - FROM "ActivityReports" a - JOIN "Users" u ON a."userId" = u.id - JOIN "UserRoles" ur ON u.id = ur."userId" - JOIN "Roles" r ON ur."roleId" = r.id - WHERE a."calculatedStatus" = 'approved' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL - OR u."homeRegionId" in ( - SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value - )) - -- Filter for startDate dates between two values if jdi.startDate is defined - AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL - OR a."startDate"::date <@ ( - SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value - )) - GROUP BY 1,2 + reports AS ( + SELECT + a.id, + a."userId", + a.duration, + a."deliveryMethod", + a."calculatedStatus", + a."startDate" + FROM "ActivityReports" a + WHERE a."calculatedStatus" = 'approved' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR a."regionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + -- Filter for startDate dates between two values if jdi.startDate is defined + AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + UNION + + SELECT + a.id, + arc."userId", + a.duration, + a."deliveryMethod", + a."calculatedStatus", + a."startDate" + FROM "ActivityReports" a + JOIN "ActivityReportCollaborators" arc + ON a.id = arc."activityReportId" + WHERE a."calculatedStatus" = 'approved' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR a."regionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + -- Filter for startDate dates between two values if jdi.startDate is defined + AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + OR a."startDate"::date <@ ( + SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + )) + ), + users_is_scope AS ( + SELECT + u.id, + u.name, + u."homeRegionId" + FROM "Users" u + LEFT JOIN "Permissions" p ON u.id = p."userId" + LEFT JOIN "Scopes" s ON p."scopeId" = s.id + LEFT JOIN "reports" a ON a."userId" = u.id + WHERE (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR u."homeRegionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + GROUP BY 1,2,3 + HAVING COUNT(DISTINCT a.id) > 0 OR 'SITE_ACCESS' = ANY(ARRAY_AGG(s.name)) + ), + duration_data AS ( + SELECT + r.name AS "User Role", + NULL AS "User", + COUNT(DISTINCT a.id) AS "Reports", + SUM(a.duration) AS "Total Duration", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" + FROM "users_is_scope" u + JOIN "UserRoles" ur ON u.id = ur."userId" + JOIN "Roles" r ON ur."roleId" = r.id + LEFT JOIN "reports" a ON a."userId" = u.id + GROUP BY 1,2 - UNION ALL + UNION ALL - SELECT - r.name AS "User Role", - u.name AS "User", - COUNT(DISTINCT a.id) AS "Reports", - SUM(a.duration) AS "Total Duration", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" - FROM "ActivityReports" a - JOIN "Users" u ON a."userId" = u.id - JOIN "UserRoles" ur ON u.id = ur."userId" - JOIN "Roles" r ON ur."roleId" = r.id - WHERE a."calculatedStatus" = 'approved' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL - OR u."homeRegionId" in ( - SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value - )) - -- Filter for startDate dates between two values if jdi.startDate is defined - AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL - OR a."startDate"::date <@ ( - SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value - )) - GROUP BY 1,2 + SELECT + r.name AS "User Role", + u.name AS "User", + COUNT(DISTINCT a.id) AS "Reports", + SUM(a.duration) AS "Total Duration", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" + FROM "users_is_scope" u + JOIN "UserRoles" ur ON u.id = ur."userId" + JOIN "Roles" r ON ur."roleId" = r.id + LEFT JOIN "reports" a ON a."userId" = u.id + GROUP BY 1,2 - UNION ALL + UNION ALL - SELECT - 'Total' AS "User Role", - NULL AS "User", - COUNT(DISTINCT a.id) AS "Reports", - SUM(a.duration) AS "Total Duration", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", - SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" - FROM "ActivityReports" a - JOIN "Users" u ON a."userId" = u.id - JOIN "UserRoles" ur ON u.id = ur."userId" - JOIN "Roles" r ON ur."roleId" = r.id - WHERE a."calculatedStatus" = 'approved' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL - OR u."homeRegionId" in ( - SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value - )) - -- Filter for startDate dates between two values if jdi.startDate is defined - AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL - OR a."startDate"::date <@ ( - SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value - )) - ), - grant_counts AS ( - SELECT - r.name, - COUNT(DISTINCT gr."number") AS grant_count, - COUNT(DISTINCT gr."recipientId") AS recipient_count - FROM "Grants" gr - LEFT JOIN "ActivityRecipients" ar - ON gr."id" = ar."grantId" - LEFT JOIN "ActivityReports" a - ON ar."activityReportId" = a.id - LEFT JOIN "Users" u - ON a."userId" = u.id - LEFT JOIN "UserRoles" ur - ON u.id = ur."userId" - LEFT JOIN "Roles" r - ON ur."roleId" = r.id - WHERE gr.status = 'Active' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL - OR gr."regionId" in ( - SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value - )) - AND (a."calculatedStatus" = 'approved' OR a."calculatedStatus" IS NULL) - AND ( - -- Filter for startDate dates between two values if jdi.startDate is defined - (NULLIF(current_setting('jdi.startDate', true), '') IS NULL - OR a."startDate"::date <@ ( - SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value - )) - OR a."startDate" IS NULL) - AND r.name IS NOT NULL - GROUP BY r.name - ), - grant_count_users AS ( - SELECT - r.name, - u.name "User", - COUNT(DISTINCT gr.number) AS grant_count, - COUNT(DISTINCT gr."recipientId") AS recipient_count - FROM "Grants" gr - LEFT JOIN "ActivityRecipients" ar - ON gr."id" = ar."grantId" - LEFT JOIN "ActivityReports" a - ON ar."activityReportId" = a.id - LEFT JOIN "Users" u - ON a."userId" = u.id - LEFT JOIN "UserRoles" ur - ON u.id = ur."userId" - LEFT JOIN "Roles" r - ON ur."roleId" = r.id - WHERE gr.status = 'Active' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL - OR gr."regionId" in ( - SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value - )) - AND (a."calculatedStatus" = 'approved' OR a."calculatedStatus" IS NULL) - AND ( - -- Filter for startDate dates between two values if jdi.startDate is defined - (NULLIF(current_setting('jdi.startDate', true), '') IS NULL - OR a."startDate"::date <@ ( - SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value - )) - OR a."startDate" IS NULL) - AND r.name IS NOT NULL - GROUP BY 1,2 - ), - total_grants AS ( - SELECT - COUNT(DISTINCT gr.number) AS grant_count, - COUNT(DISTINCT gr."recipientId") AS recipient_count - FROM "Grants" gr - WHERE gr.status = 'Active' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL - OR gr."regionId" in ( - SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value - )) - ), - recipient_data AS ( - SELECT - gc.name AS "User Role", - NULL AS "User", - gc.grant_count, - ((gc.grant_count::float / tg.grant_count) * 100)::DECIMAL(5,2) AS grant_percentage, - ((gc.recipient_count::float / tg.recipient_count) * 100)::DECIMAL(5,2) AS recipient_percentage - FROM grant_counts gc - CROSS JOIN total_grants tg + SELECT + 'Total' AS "User Role", + NULL AS "User", + COUNT(DISTINCT a.id) AS "Reports", + SUM(a.duration) AS "Total Duration", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'in-person') AS "Total Duration - in-person", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'virtual') AS "Total Duration - virtual", + SUM(a.duration) FILTER (WHERE a."deliveryMethod" = 'hybrid') AS "Total Duration - hybrid" + FROM "reports" a + JOIN "users_is_scope" u ON a."userId" = u.id + JOIN "UserRoles" ur ON u.id = ur."userId" + JOIN "Roles" r ON ur."roleId" = r.id + ), + grant_counts AS ( + SELECT + r.name, + COUNT(DISTINCT gr."number") AS grant_count, + COUNT(DISTINCT gr."recipientId") AS recipient_count + FROM "users_is_scope" u + LEFT JOIN "UserRoles" ur + ON u.id = ur."userId" + LEFT JOIN "Roles" r + ON ur."roleId" = r.id + LEFT JOIN "reports" a + ON u.id = a."userId" + LEFT JOIN "ActivityRecipients" ar + ON a.id = ar."activityReportId" + LEFT JOIN "Grants" gr + ON gr."id" = ar."grantId" + WHERE (gr.status = 'Active' OR gr.status IS NULL) + AND r.name IS NOT NULL + GROUP BY r.name + ), + grant_count_users AS ( + SELECT + r.name, + u.name "User", + COUNT(DISTINCT gr.number) AS grant_count, + COUNT(DISTINCT gr."recipientId") AS recipient_count + FROM "users_is_scope" u + LEFT JOIN "UserRoles" ur + ON u.id = ur."userId" + LEFT JOIN "Roles" r + ON ur."roleId" = r.id + LEFT JOIN "reports" a + ON u.id = a."userId" + LEFT JOIN "ActivityRecipients" ar + ON a.id = ar."activityReportId" + LEFT JOIN "Grants" gr + ON gr."id" = ar."grantId" + WHERE (gr.status = 'Active' OR gr.status IS NULL) + AND r.name IS NOT NULL + GROUP BY 1,2 + ), + total_grants AS ( + SELECT + COUNT(DISTINCT gr.number) AS grant_count, + COUNT(DISTINCT gr."recipientId") AS recipient_count + FROM "Grants" gr + WHERE gr.status = 'Active' + -- Filter for regionIds if jdi.regionIds is defined + AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + OR gr."regionId" in ( + SELECT value::integer AS my_array + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + )) + ), + recipient_data AS ( + SELECT + gc.name AS "User Role", + NULL AS "User", + gc.grant_count, + ((gc.grant_count::float / tg.grant_count) * 100)::DECIMAL(5,2) AS grant_percentage, + ((gc.recipient_count::float / tg.recipient_count) * 100)::DECIMAL(5,2) AS recipient_percentage + FROM grant_counts gc + CROSS JOIN total_grants tg - UNION ALL + UNION ALL - SELECT - gc.name AS "User Role", - gc."User" AS "User", - gc.grant_count, - ((gc.grant_count::float / tg.grant_count) * 100)::DECIMAL(5,2) AS grant_percentage, - ((gc.recipient_count::float / tg.recipient_count) * 100)::DECIMAL(5,2) AS recipient_percentage - FROM grant_count_users gc - CROSS JOIN total_grants tg + SELECT + gc.name AS "User Role", + gc."User" AS "User", + gc.grant_count, + ((gc.grant_count::float / tg.grant_count) * 100)::DECIMAL(5,2) AS grant_percentage, + ((gc.recipient_count::float / tg.recipient_count) * 100)::DECIMAL(5,2) AS recipient_percentage + FROM grant_count_users gc + CROSS JOIN total_grants tg - UNION ALL + UNION ALL - SELECT - 'Total' AS "User Role", - NULL AS "User", - SUM(gc.grant_count) AS grant_count, - ((SUM(gc.grant_count)::float / MAX(tg.grant_count)) * 100)::DECIMAL(5,2) AS grant_percentage, - ((SUM(gc.recipient_count)::float / MAX(tg.recipient_count)) * 100)::DECIMAL(5,2) AS recipient_percentage - FROM grant_counts gc - CROSS JOIN total_grants tg - ) - SELECT - dd.*, - rd.grant_count AS "Grants", - rd.grant_percentage AS "Percentage of Grants", - rd.recipient_percentage AS "Percentage of Recipients" - FROM duration_data dd - JOIN recipient_data rd - ON dd."User Role" = rd."User Role" - AND (dd."User" = rd."User" OR (dd."User" IS NULL AND rd."User" IS NULL)) - ORDER BY - 1 desc, - 2 NULLS FIRST + SELECT + 'Total' AS "User Role", + NULL AS "User", + SUM(gc.grant_count) AS grant_count, + ((SUM(gc.grant_count)::float / MAX(tg.grant_count)) * 100)::DECIMAL(5,2) AS grant_percentage, + ((SUM(gc.recipient_count)::float / MAX(tg.recipient_count)) * 100)::DECIMAL(5,2) AS recipient_percentage + FROM grant_counts gc + CROSS JOIN total_grants tg + ), + collected AS ( + SELECT + CASE + WHEN dd."User Role" = 'Total' THEN NULL + ELSE dd."User Role" + END "User Role", + dd."User", + dd."Reports", + dd."Total Duration", + dd."Total Duration - in-person", + dd."Total Duration - virtual", + dd."Total Duration - hybrid", + rd.grant_count AS "Grants", + rd.grant_percentage AS "Percentage of Grants", + rd.recipient_percentage AS "Percentage of Recipients" + FROM duration_data dd + JOIN recipient_data rd + ON dd."User Role" = rd."User Role" + AND (dd."User" = rd."User" OR (dd."User" IS NULL AND rd."User" IS NULL)) + ORDER BY + 1 NULLS FIRST, + 2 NULLS FIRST + ) + SELECT + COALESCE("User Role",'Total') "User Role", + COALESCE("User",'Total') "User", + "Reports", + "Total Duration", + "Total Duration - in-person", + "Total Duration - virtual", + "Total Duration - hybrid", + "Grants", + "Percentage of Grants", + "Percentage of Recipients" + FROM collected; From ba9e93a3cd62914241c26155777a5c6616dd8226 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Wed, 22 May 2024 14:58:40 -0700 Subject: [PATCH 59/78] address review comments --- ...40520000001-create-timeseries-and-root-causes.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 46649776e9..93a755b8a1 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -283,6 +283,9 @@ module.exports = { END $$ ; + `, { transaction }); + + await queryInterface.sequelize.query(/* sql */` -- Create GoalFieldResponses_timeseries @@ -337,5 +340,13 @@ module.exports = { `, { transaction }); }); }, - async down() {}, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await await queryInterface.sequelize.query(/* sql */` + DROP FUNCTION IF EXISTS create_timeseries_from_audit_log; + `, { transaction }); + }); + }, }; From 1871a7dd15e1207c6c9ec52c670323d4faf2cfbd Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Thu, 23 May 2024 15:50:04 -0400 Subject: [PATCH 60/78] Deploy to sandbox --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 34bbeee72d..2097413a56 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -423,7 +423,7 @@ parameters: default: "mb/TTAHUB-2857/update-goal-status-logic" type: string sandbox_git_branch: # change to feature branch to test deployment - default: "mb/TTAHUB-2609/move-components" + default: "mb/TTAHUB-2530/update-RTR-objective-form" type: string prod_new_relic_app_id: default: "877570491" From 926435fde414e5044db137c3eacca0a6d99c81a8 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Thu, 23 May 2024 16:11:25 -0400 Subject: [PATCH 61/78] Group by 1=1 --- src/goalServices/getGoalsForReport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/goalServices/getGoalsForReport.ts b/src/goalServices/getGoalsForReport.ts index 18f99767b1..9c47a5495f 100644 --- a/src/goalServices/getGoalsForReport.ts +++ b/src/goalServices/getGoalsForReport.ts @@ -58,7 +58,7 @@ export default async function getGoalsForReport(reportId: number) { ON gtfp.id = argfr."goalTemplateFieldPromptId" AND argfr."activityReportGoalId" = "activityReportGoals".id WHERE "goalTemplate".id = gtfp."goalTemplateId" - GROUP BY TRUE + GROUP BY 1=1 )`), 'prompts'], ], }, From daf15e6a37038a0d17d8003cf5ea0374574d8125 Mon Sep 17 00:00:00 2001 From: GarrettEHill Date: Thu, 23 May 2024 14:57:58 -0700 Subject: [PATCH 62/78] Update monthly-delivery-report.sql --- src/queries/monthly-delivery-report.sql | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/queries/monthly-delivery-report.sql b/src/queries/monthly-delivery-report.sql index 58c7fda87f..30cda6259a 100644 --- a/src/queries/monthly-delivery-report.sql +++ b/src/queries/monthly-delivery-report.sql @@ -127,7 +127,9 @@ WITH SELECT r.name, COUNT(DISTINCT gr."number") AS grant_count, - COUNT(DISTINCT gr."recipientId") AS recipient_count + COUNT(DISTINCT gr."recipientId") AS recipient_count, + ARRAY_AGG(DISTINCT gr."number") AS grant_numbers, + ARRAY_AGG(DISTINCT gr."recipientId") AS recipient_ids FROM "users_is_scope" u LEFT JOIN "UserRoles" ur ON u.id = ur."userId" @@ -203,10 +205,12 @@ WITH SELECT 'Total' AS "User Role", NULL AS "User", - SUM(gc.grant_count) AS grant_count, - ((SUM(gc.grant_count)::float / MAX(tg.grant_count)) * 100)::DECIMAL(5,2) AS grant_percentage, - ((SUM(gc.recipient_count)::float / MAX(tg.recipient_count)) * 100)::DECIMAL(5,2) AS recipient_percentage + COUNT(DISTINCT gn.number) AS grant_count, + ((COUNT(DISTINCT gn.number)::float / MAX(tg.grant_count)) * 100)::DECIMAL(5,2) AS grant_percentage, + ((COUNT(DISTINCT ri."recipientId")::float / MAX(tg.recipient_count)) * 100)::DECIMAL(5,2) AS recipient_percentage FROM grant_counts gc + CROSS JOIN UNNEST(gc.grant_numbers) gn(number) + CROSS JOIN UNNEST(gc.recipient_ids) ri("recipientId") CROSS JOIN total_grants tg ), collected AS ( From dcf31cbe9fe33187b946c106378e55ee7edb23b6 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 24 May 2024 09:03:35 -0400 Subject: [PATCH 63/78] Fix goal nudge CSS bug --- frontend/src/components/GoalForm/GoalNudge.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/GoalForm/GoalNudge.js b/frontend/src/components/GoalForm/GoalNudge.js index 8d44294eb8..95c0a26ee8 100644 --- a/frontend/src/components/GoalForm/GoalNudge.js +++ b/frontend/src/components/GoalForm/GoalNudge.js @@ -108,6 +108,8 @@ export default function GoalNudge({ // what a hack const checkboxZed = similar.length && !useOhsInitiativeGoal && !dismissSimilar ? 'z-bottom' : ''; + const showOhsInitiativeGoalCheckbox = goalTemplates && goalTemplates.length > 0; + const buttonGroupFlex = showOhsInitiativeGoalCheckbox ? 'flex-justify' : 'flex-justify-end'; return (
    @@ -131,8 +133,8 @@ export default function GoalNudge({ onSelectNudgedGoal={onSelectNudgedGoal} initiativeRef={initiativeRef} /> -
    - { (goalTemplates && goalTemplates.length > 0) && ( +
    + { (showOhsInitiativeGoalCheckbox) && ( Date: Fri, 24 May 2024 10:40:48 -0400 Subject: [PATCH 64/78] Hide objective status on RTR --- frontend/src/components/GoalForm/index.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/GoalForm/index.js b/frontend/src/components/GoalForm/index.js index 4a4c3e1ae5..783b2c6241 100644 --- a/frontend/src/components/GoalForm/index.js +++ b/frontend/src/components/GoalForm/index.js @@ -772,6 +772,20 @@ export default function GoalForm({ ); } + const createdGoalsForReadOnly = createdGoals.map((goal) => { + const { objectives: goalObjectives } = goal; + const newObjectives = goalObjectives.map((obj) => { + const copy = { ...obj }; + delete copy.status; + return copy; + }); + + return { + ...goal, + objectives: newObjectives, + }; + }); + return ( <> { showRTRnavigation ? ( @@ -794,10 +808,10 @@ export default function GoalForm({ - { createdGoals.length ? ( + { createdGoalsForReadOnly.length ? ( <> From a04b40d7745ccee56bf18cfe9048517ad7f5aef9 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 24 May 2024 13:50:27 -0400 Subject: [PATCH 65/78] I made a mistake --- yarn-audit-known-issue | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 yarn-audit-known-issue diff --git a/yarn-audit-known-issue b/yarn-audit-known-issue deleted file mode 100644 index 933bcb05c1..0000000000 --- a/yarn-audit-known-issue +++ /dev/null @@ -1,10 +0,0 @@ -{"type":"auditAdvisory","data":{"resolution":{"id":1096366,"path":"email-templates>preview-email>mailparser>nodemailer","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"6.7.3","paths":["email-templates>preview-email>mailparser>nodemailer"]}],"metadata":null,"vulnerable_versions":"<=6.9.8","module_name":"nodemailer","severity":"moderate","github_advisory_id":"GHSA-9h6g-pr28-7cqp","cves":[],"access":"public","patched_versions":">=6.9.9","cvss":{"score":5.3,"vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"},"updated":"2024-02-01T17:58:50.000Z","recommendation":"Upgrade to version 6.9.9 or later","cwe":["CWE-1333"],"found_by":null,"deleted":null,"id":1096366,"references":"- https://github.com/nodemailer/nodemailer/security/advisories/GHSA-9h6g-pr28-7cqp\n- https://gist.github.com/francoatmega/890dd5053375333e40c6fdbcc8c58df6\n- https://gist.github.com/francoatmega/9aab042b0b24968d7b7039818e8b2698\n- https://github.com/nodemailer/nodemailer/commit/dd8f5e8a4ddc99992e31df76bcff9c590035cd4a\n- https://github.com/advisories/GHSA-9h6g-pr28-7cqp","created":"2024-01-31T22:42:54.000Z","reported_by":null,"title":"nodemailer ReDoS when trying to send a specially crafted email","npm_advisory_id":null,"overview":"### Summary\nA ReDoS vulnerability occurs when nodemailer tries to parse img files with the parameter `attachDataUrls` set, causing the stuck of event loop. \nAnother flaw was found when nodemailer tries to parse an attachments with a embedded file, causing the stuck of event loop. \n\n### Details\n\nRegex: /^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/\n\nPath: compile -> getAttachments -> _processDataUrl\n\nRegex: /(]* src\\s*=[\\s\"']*)(data:([^;]+);[^\"'>\\s]+)/\n\nPath: _convertDataImages\n\n### PoC\n\nhttps://gist.github.com/francoatmega/890dd5053375333e40c6fdbcc8c58df6\nhttps://gist.github.com/francoatmega/9aab042b0b24968d7b7039818e8b2698\n\n### Impact\n\nReDoS causes the event loop to stuck a specially crafted evil email can cause this problem.\n","url":"https://github.com/advisories/GHSA-9h6g-pr28-7cqp"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1096502,"path":"@elastic/elasticsearch>@elastic/transport>undici","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.19.1","paths":["@elastic/elasticsearch>@elastic/transport>undici"]}],"metadata":null,"vulnerable_versions":"<5.26.2","module_name":"undici","severity":"low","github_advisory_id":"GHSA-wqq4-5wpv-mx2g","cves":["CVE-2023-45143"],"access":"public","patched_versions":">=5.26.2","cvss":{"score":3.9,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L"},"updated":"2024-02-16T22:38:40.000Z","recommendation":"Upgrade to version 5.26.2 or later","cwe":["CWE-200"],"found_by":null,"deleted":null,"id":1096502,"references":"- https://github.com/nodejs/undici/security/advisories/GHSA-q768-x9m6-m9qp\n- https://github.com/nodejs/undici/security/advisories/GHSA-wqq4-5wpv-mx2g\n- https://nvd.nist.gov/vuln/detail/CVE-2023-45143\n- https://github.com/nodejs/undici/commit/e041de359221ebeae04c469e8aff4145764e6d76\n- https://hackerone.com/reports/2166948\n- https://github.com/nodejs/undici/releases/tag/v5.26.2\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/3N4NJ7FR4X4FPZUGNTQAPSTVB2HB2Y4A\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/E72T67UPDRXHIDLO3OROR25YAMN4GGW5\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/FNA62Q767CFAFHBCDKYNPBMZWB7TWYVU\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/HT7T2R4MQKLIF4ODV4BDLPARWFPCJ5CZ\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/LKYHSZQFDNR7RSA7LHVLLIAQMVYCUGBG\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/X6QXN4ORIVF6XBW4WWFE7VNPVC74S45Y\n- https://github.com/advisories/GHSA-wqq4-5wpv-mx2g","created":"2023-10-16T14:05:37.000Z","reported_by":null,"title":"Undici's cookie header not cleared on cross-origin redirect in fetch","npm_advisory_id":null,"overview":"### Impact\n\nUndici clears Authorization headers on cross-origin redirects, but does not clear `Cookie` headers. By design, `cookie` headers are [forbidden request headers](https://fetch.spec.whatwg.org/#forbidden-request-header), disallowing them to be set in `RequestInit.headers` in browser environments. Since Undici handles headers more liberally than the specification, there was a disconnect from the assumptions the spec made, and Undici's implementation of fetch.\n\nAs such this may lead to accidental leakage of cookie to a 3rd-party site or a malicious attacker who can control the redirection target (ie. an open redirector) to leak the cookie to the 3rd party site.\n\n### Patches\n\nThis was patched in [e041de359221ebeae04c469e8aff4145764e6d76](https://github.com/nodejs/undici/commit/e041de359221ebeae04c469e8aff4145764e6d76), which is included in version 5.26.2.\n","url":"https://github.com/advisories/GHSA-wqq4-5wpv-mx2g"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1097109,"path":"@elastic/elasticsearch>@elastic/transport>undici","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.19.1","paths":["@elastic/elasticsearch>@elastic/transport>undici"]}],"metadata":null,"vulnerable_versions":"<5.28.4","module_name":"undici","severity":"low","github_advisory_id":"GHSA-m4v8-wqvr-p9f7","cves":["CVE-2024-30260"],"access":"public","patched_versions":">=5.28.4","cvss":{"score":3.9,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L"},"updated":"2024-04-20T00:31:53.000Z","recommendation":"Upgrade to version 5.28.4 or later","cwe":["CWE-200","CWE-285"],"found_by":null,"deleted":null,"id":1097109,"references":"- https://github.com/nodejs/undici/security/advisories/GHSA-m4v8-wqvr-p9f7\n- https://github.com/nodejs/undici/commit/64e3402da4e032e68de46acb52800c9a06aaea3f\n- https://github.com/nodejs/undici/commit/6805746680d27a5369d7fb67bc05f95a28247d75\n- https://hackerone.com/reports/2408074\n- https://nvd.nist.gov/vuln/detail/CVE-2024-30260\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/HQVHWAS6WDXXIU7F72XI55VZ2LTZUB33\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/NC3V3HFZ5MOJRZDY5ZELL6REIRSPFROJ\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/P6Q4RGETHVYVHDIQGTJGU5AV6NJEI67E\n- https://github.com/advisories/GHSA-m4v8-wqvr-p9f7","created":"2024-04-04T14:20:39.000Z","reported_by":null,"title":"Undici's Proxy-Authorization header not cleared on cross-origin redirect for dispatch, request, stream, pipeline","npm_advisory_id":null,"overview":"### Impact\n\nUndici cleared Authorization and Proxy-Authorization headers for `fetch()`, but did not clear them for `undici.request()`.\n\n### Patches\n\nThis has been patched in https://github.com/nodejs/undici/commit/6805746680d27a5369d7fb67bc05f95a28247d75.\nFixes has been released in v5.28.4 and v6.11.1.\n\n### Workarounds\n\nuse `fetch()` or disable `maxRedirections`.\n\n### References\n\nLinzi Shang reported this.\n\n* https://hackerone.com/reports/2408074\n* https://github.com/nodejs/undici/security/advisories/GHSA-3787-6prv-h9w3\n","url":"https://github.com/advisories/GHSA-m4v8-wqvr-p9f7"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1097200,"path":"@elastic/elasticsearch>@elastic/transport>undici","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.19.1","paths":["@elastic/elasticsearch>@elastic/transport>undici"]}],"metadata":null,"vulnerable_versions":"<5.28.4","module_name":"undici","severity":"low","github_advisory_id":"GHSA-9qxr-qj54-h672","cves":["CVE-2024-30261"],"access":"public","patched_versions":">=5.28.4","cvss":{"score":2.6,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:N/I:L/A:N"},"updated":"2024-04-29T05:02:11.000Z","recommendation":"Upgrade to version 5.28.4 or later","cwe":["CWE-284"],"found_by":null,"deleted":null,"id":1097200,"references":"- https://github.com/nodejs/undici/security/advisories/GHSA-9qxr-qj54-h672\n- https://github.com/nodejs/undici/commit/2b39440bd9ded841c93dd72138f3b1763ae26055\n- https://github.com/nodejs/undici/commit/d542b8cd39ec1ba303f038ea26098c3f355974f3\n- https://hackerone.com/reports/2377760\n- https://nvd.nist.gov/vuln/detail/CVE-2024-30261\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/HQVHWAS6WDXXIU7F72XI55VZ2LTZUB33\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/P6Q4RGETHVYVHDIQGTJGU5AV6NJEI67E\n- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/NC3V3HFZ5MOJRZDY5ZELL6REIRSPFROJ\n- https://github.com/advisories/GHSA-9qxr-qj54-h672","created":"2024-04-04T14:20:54.000Z","reported_by":null,"title":"Undici's fetch with integrity option is too lax when algorithm is specified but hash value is in incorrect","npm_advisory_id":null,"overview":"### Impact\n\nIf an attacker can alter the `integrity` option passed to `fetch()`, they can let `fetch()` accept requests as valid even if they have been tampered.\n\n### Patches\n\nFixed in https://github.com/nodejs/undici/commit/d542b8cd39ec1ba303f038ea26098c3f355974f3.\nFixes has been released in v5.28.4 and v6.11.1.\n\n\n### Workarounds\n\nEnsure that `integrity` cannot be tampered with.\n\n### References\n\nhttps://hackerone.com/reports/2377760\n","url":"https://github.com/advisories/GHSA-9qxr-qj54-h672"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1097221,"path":"@elastic/elasticsearch>@elastic/transport>undici","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.19.1","paths":["@elastic/elasticsearch>@elastic/transport>undici"]}],"metadata":null,"vulnerable_versions":"<=5.28.2","module_name":"undici","severity":"low","github_advisory_id":"GHSA-3787-6prv-h9w3","cves":["CVE-2024-24758"],"access":"public","patched_versions":">=5.28.3","cvss":{"score":3.9,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L"},"updated":"2024-05-02T13:15:07.000Z","recommendation":"Upgrade to version 5.28.3 or later","cwe":["CWE-200"],"found_by":null,"deleted":null,"id":1097221,"references":"- https://github.com/nodejs/undici/security/advisories/GHSA-3787-6prv-h9w3\n- https://github.com/nodejs/undici/commit/b9da3e40f1f096a06b4caedbb27c2568730434ef\n- https://github.com/nodejs/undici/commit/d3aa574b1259c1d8d329a0f0f495ee82882b1458\n- https://github.com/nodejs/undici/releases/tag/v5.28.3\n- https://github.com/nodejs/undici/releases/tag/v6.6.1\n- https://nvd.nist.gov/vuln/detail/CVE-2024-24758\n- https://security.netapp.com/advisory/ntap-20240419-0007\n- http://www.openwall.com/lists/oss-security/2024/03/11/1\n- https://github.com/advisories/GHSA-3787-6prv-h9w3","created":"2024-02-16T16:02:52.000Z","reported_by":null,"title":"Undici proxy-authorization header not cleared on cross-origin redirect in fetch","npm_advisory_id":null,"overview":"### Impact\n\nUndici already cleared Authorization headers on cross-origin redirects, but did not clear `Proxy-Authorization` headers. \n\n### Patches\n\nThis is patched in v5.28.3 and v6.6.1\n\n### Workarounds\n\nThere are no known workarounds.\n\n### References\n\n- https://fetch.spec.whatwg.org/#authentication-entries\n- https://github.com/nodejs/undici/security/advisories/GHSA-wqq4-5wpv-mx2g","url":"https://github.com/advisories/GHSA-3787-6prv-h9w3"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1096410,"path":"xml2json>hoek","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"4.2.1","paths":["xml2json>hoek"]},{"version":"5.0.4","paths":["xml2json>joi>hoek"]},{"version":"6.1.3","paths":["xml2json>joi>topo>hoek"]}],"metadata":null,"vulnerable_versions":"<=6.1.3","module_name":"hoek","severity":"high","github_advisory_id":"GHSA-c429-5p7v-vgjp","cves":["CVE-2020-36604"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-02-07T18:59:37.000Z","recommendation":"None","cwe":["CWE-1321"],"found_by":null,"deleted":null,"id":1096410,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2020-36604\n- https://github.com/hapijs/hoek/issues/352\n- https://github.com/hapijs/hoek/commit/4d0804bc6135ad72afdc5e1ec002b935b2f5216a\n- https://github.com/hapijs/hoek/commit/948baf98634a5c206875b67d11368f133034fa90\n- https://github.com/advisories/GHSA-c429-5p7v-vgjp","created":"2022-09-25T00:00:27.000Z","reported_by":null,"title":"hoek subject to prototype pollution via the clone function.","npm_advisory_id":null,"overview":"hoek versions prior to 8.5.1, and 9.x prior to 9.0.3 are vulnerable to prototype pollution in the clone function. If an object with the __proto__ key is passed to clone() the key is converted to a prototype. This issue has been patched in version 9.0.3, and backported to 8.5.1. ","url":"https://github.com/advisories/GHSA-c429-5p7v-vgjp"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1096410,"path":"xml2json>joi>hoek","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"4.2.1","paths":["xml2json>hoek"]},{"version":"5.0.4","paths":["xml2json>joi>hoek"]},{"version":"6.1.3","paths":["xml2json>joi>topo>hoek"]}],"metadata":null,"vulnerable_versions":"<=6.1.3","module_name":"hoek","severity":"high","github_advisory_id":"GHSA-c429-5p7v-vgjp","cves":["CVE-2020-36604"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-02-07T18:59:37.000Z","recommendation":"None","cwe":["CWE-1321"],"found_by":null,"deleted":null,"id":1096410,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2020-36604\n- https://github.com/hapijs/hoek/issues/352\n- https://github.com/hapijs/hoek/commit/4d0804bc6135ad72afdc5e1ec002b935b2f5216a\n- https://github.com/hapijs/hoek/commit/948baf98634a5c206875b67d11368f133034fa90\n- https://github.com/advisories/GHSA-c429-5p7v-vgjp","created":"2022-09-25T00:00:27.000Z","reported_by":null,"title":"hoek subject to prototype pollution via the clone function.","npm_advisory_id":null,"overview":"hoek versions prior to 8.5.1, and 9.x prior to 9.0.3 are vulnerable to prototype pollution in the clone function. If an object with the __proto__ key is passed to clone() the key is converted to a prototype. This issue has been patched in version 9.0.3, and backported to 8.5.1. ","url":"https://github.com/advisories/GHSA-c429-5p7v-vgjp"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1096410,"path":"xml2json>joi>topo>hoek","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"4.2.1","paths":["xml2json>hoek"]},{"version":"5.0.4","paths":["xml2json>joi>hoek"]},{"version":"6.1.3","paths":["xml2json>joi>topo>hoek"]}],"metadata":null,"vulnerable_versions":"<=6.1.3","module_name":"hoek","severity":"high","github_advisory_id":"GHSA-c429-5p7v-vgjp","cves":["CVE-2020-36604"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-02-07T18:59:37.000Z","recommendation":"None","cwe":["CWE-1321"],"found_by":null,"deleted":null,"id":1096410,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2020-36604\n- https://github.com/hapijs/hoek/issues/352\n- https://github.com/hapijs/hoek/commit/4d0804bc6135ad72afdc5e1ec002b935b2f5216a\n- https://github.com/hapijs/hoek/commit/948baf98634a5c206875b67d11368f133034fa90\n- https://github.com/advisories/GHSA-c429-5p7v-vgjp","created":"2022-09-25T00:00:27.000Z","reported_by":null,"title":"hoek subject to prototype pollution via the clone function.","npm_advisory_id":null,"overview":"hoek versions prior to 8.5.1, and 9.x prior to 9.0.3 are vulnerable to prototype pollution in the clone function. If an object with the __proto__ key is passed to clone() the key is converted to a prototype. This issue has been patched in version 9.0.3, and backported to 8.5.1. ","url":"https://github.com/advisories/GHSA-c429-5p7v-vgjp"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1097335,"path":"pug","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"3.0.2","paths":["pug","email-templates>preview-email>pug"]}],"metadata":null,"vulnerable_versions":"<=3.0.2","module_name":"pug","severity":"high","github_advisory_id":"GHSA-3965-hpx2-q597","cves":["CVE-2024-36361"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-05-24T14:45:05.000Z","recommendation":"None","cwe":["CWE-94"],"found_by":null,"deleted":null,"id":1097335,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2024-36361\n- https://github.com/pugjs/pug/pull/3428\n- https://github.com/Coding-Competition-Team/hackac-2024/tree/main/web/pug\n- https://github.com/pugjs/pug/blob/4767cafea0af3d3f935553df0f9a8a6e76d470c2/packages/pug/lib/index.js#L328\n- https://pugjs.org/api/reference.html\n- https://www.npmjs.com/package/pug-code-gen\n- https://github.com/advisories/GHSA-3965-hpx2-q597","created":"2024-05-24T14:45:02.000Z","reported_by":null,"title":"Pug allows JavaScript code execution if an application accepts untrusted input","npm_advisory_id":null,"overview":"Pug through 3.0.2 allows JavaScript code execution if an application accepts untrusted input for the name option of the `compileClient`, `compileFileClient`, or `compileClientWithDependenciesTracked` function. NOTE: these functions are for compiling Pug templates into JavaScript, and there would typically be no reason to allow untrusted callers.","url":"https://github.com/advisories/GHSA-3965-hpx2-q597"}}} -{"type":"auditAdvisory","data":{"resolution":{"id":1097335,"path":"email-templates>preview-email>pug","dev":false,"bundled":false,"optional":false},"advisory":{"findings":[{"version":"3.0.2","paths":["pug","email-templates>preview-email>pug"]}],"metadata":null,"vulnerable_versions":"<=3.0.2","module_name":"pug","severity":"high","github_advisory_id":"GHSA-3965-hpx2-q597","cves":["CVE-2024-36361"],"access":"public","patched_versions":"<0.0.0","cvss":{"score":8.1,"vectorString":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"updated":"2024-05-24T14:45:05.000Z","recommendation":"None","cwe":["CWE-94"],"found_by":null,"deleted":null,"id":1097335,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2024-36361\n- https://github.com/pugjs/pug/pull/3428\n- https://github.com/Coding-Competition-Team/hackac-2024/tree/main/web/pug\n- https://github.com/pugjs/pug/blob/4767cafea0af3d3f935553df0f9a8a6e76d470c2/packages/pug/lib/index.js#L328\n- https://pugjs.org/api/reference.html\n- https://www.npmjs.com/package/pug-code-gen\n- https://github.com/advisories/GHSA-3965-hpx2-q597","created":"2024-05-24T14:45:02.000Z","reported_by":null,"title":"Pug allows JavaScript code execution if an application accepts untrusted input","npm_advisory_id":null,"overview":"Pug through 3.0.2 allows JavaScript code execution if an application accepts untrusted input for the name option of the `compileClient`, `compileFileClient`, or `compileClientWithDependenciesTracked` function. NOTE: these functions are for compiling Pug templates into JavaScript, and there would typically be no reason to allow untrusted callers.","url":"https://github.com/advisories/GHSA-3965-hpx2-q597"}}} From 5bc54245dcffe3d7df6ab68288f967e2a2899e51 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Fri, 24 May 2024 11:11:27 -0700 Subject: [PATCH 66/78] bugfix deletion and relink logic --- .../20240520000000-merge_duplicate_args.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/migrations/20240520000000-merge_duplicate_args.js b/src/migrations/20240520000000-merge_duplicate_args.js index 2a15a3dcc9..b54dc906b9 100644 --- a/src/migrations/20240520000000-merge_duplicate_args.js +++ b/src/migrations/20240520000000-merge_duplicate_args.js @@ -88,10 +88,12 @@ module.exports = { argfr."goalTemplateFieldPromptId" promptid, argfr.id argfrid, ROW_NUMBER() OVER ( - PARTITION BY target_arg, argfr."goalTemplateFieldPromptId" - ORDER BY argfr."activityReportGoalId" = target_arg, argfr."updatedAt" DESC, argfr.id + PARTITION BY arg."goalId", argfr."goalTemplateFieldPromptId" + ORDER BY argfr."activityReportGoalId" = target_arg DESC, argfr."updatedAt" DESC, argfr.id ) choice_rank FROM arg_merges am + JOIN "ActivityReportGoals" arg + ON donor_arg = arg.id JOIN "ActivityReportGoalFieldResponses" argfr ON am.donor_arg = argfr."activityReportGoalId" ), unmatched AS ( @@ -123,6 +125,7 @@ module.exports = { DELETE FROM "ActivityReportGoalFieldResponses" USING arg_merges WHERE "activityReportGoalId" = donor_arg + AND target_arg != donor_arg RETURNING id argfrid, donor_arg @@ -153,10 +156,12 @@ module.exports = { argr."resourceId" resourceid, argr.id argrid, ROW_NUMBER() OVER ( - PARTITION BY target_arg, argr."resourceId" - ORDER BY argr."activityReportGoalId" = target_arg, argr."updatedAt" DESC, argr.id + PARTITION BY arg."goalId", argr."resourceId" + ORDER BY argr."activityReportGoalId" = target_arg DESC, argr."updatedAt" DESC, argr.id ) choice_rank FROM arg_merges am + JOIN "ActivityReportGoals" arg + ON donor_arg = arg.id JOIN "ActivityReportGoalResources" argr ON am.donor_arg = argr."activityReportGoalId" ), unmatched AS ( From 5c6384ed6e1bdbe7b8ce45bdde8bb165185bb124 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Fri, 24 May 2024 13:48:57 -0700 Subject: [PATCH 67/78] expand comments per PR review --- .../20240520000001-create-timeseries-and-root-causes.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 93a755b8a1..e75a0bac2a 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -50,6 +50,13 @@ module.exports = { rec record; BEGIN -- Get the column list for the main table + -- The format() function works like C string interpolation except + -- that by using %I and %L for (respectively) db object names and + -- string literals, it protects frm SQL injection attacks. + -- It also means you don't need to manage the double quotes for + -- db object names and the single quotes for string literals. + -- %s also works for arbitrary string interpolation but doesn't + -- provide the same protections and utility. qry := format(' DROP TABLE IF EXISTS clist; CREATE TEMP TABLE clist From 703eb7967b8bcc8a26648b7257dac1cb06d49694 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Fri, 24 May 2024 13:51:02 -0700 Subject: [PATCH 68/78] typo fix --- .../20240520000001-create-timeseries-and-root-causes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index e75a0bac2a..8e73a545ac 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -52,7 +52,7 @@ module.exports = { -- Get the column list for the main table -- The format() function works like C string interpolation except -- that by using %I and %L for (respectively) db object names and - -- string literals, it protects frm SQL injection attacks. + -- string literals, it protects from SQL injection attacks. -- It also means you don't need to manage the double quotes for -- db object names and the single quotes for string literals. -- %s also works for arbitrary string interpolation but doesn't From c409932d43a264a6b4f44044ec57b1985001f15a Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 28 May 2024 00:07:22 -0700 Subject: [PATCH 69/78] add more comments per PR feedback --- ...00001-create-timeseries-and-root-causes.js | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/migrations/20240520000001-create-timeseries-and-root-causes.js b/src/migrations/20240520000001-create-timeseries-and-root-causes.js index 8e73a545ac..18278efb54 100644 --- a/src/migrations/20240520000001-create-timeseries-and-root-causes.js +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -50,13 +50,16 @@ module.exports = { rec record; BEGIN -- Get the column list for the main table + -- NOTE regarding string formatting used to assemble the queries that the + -- function uses to do its work: -- The format() function works like C string interpolation except - -- that by using %I and %L for (respectively) db object names and - -- string literals, it protects from SQL injection attacks. - -- It also means you don't need to manage the double quotes for - -- db object names and the single quotes for string literals. - -- %s also works for arbitrary string interpolation but doesn't - -- provide the same protections and utility. + -- that it protects from SQL injection attacks. Like C string interpolation, + -- the % is replaced by the comma-separated values following the + -- base string. + -- %I is formatted as a database object name and manages double quotes + -- %L is formatted as a string literal and manages single quotes + -- %s can be used for arbitrary string interpolation but doesn't + -- provide any protections or quote management. qry := format(' DROP TABLE IF EXISTS clist; CREATE TEMP TABLE clist @@ -132,7 +135,16 @@ module.exports = { ,rec.cname ,'text' ,rec.cname); - WHEN rec.ctype = 'ARRAY' THEN -- for string arrays + WHEN rec.ctype = 'ARRAY' THEN + -- Because the arrays are stored as strings, they need to be parsed + -- back into arrays. They look like: + -- ["element1", "element2", "", "element4"] + -- The X-X-X is a string very unlikely to be present + -- in the internal text and replaces the internal element separators (", ") + -- before the start ([") and end ("]) are stripped off. That step probably + -- isn't strictly necessary, but is in place because the end also trims + -- double quotes, so it's safest to already have the internal separators + -- containing the double quotes replaced with an alternative separator. wtext := wtext || format(' ,( string_to_array( @@ -154,6 +166,7 @@ module.exports = { ,rec.pgtype ,rec.cname); ELSE -- for everything else + -- All of these values can be cast as-is into their original types wtext := wtext || format(' ,(old_row_data->>%L)::%s AS %I' ,rec.cname @@ -170,6 +183,7 @@ module.exports = { qry := qry || wtext || format(' FROM %I UNION ALL + -- Add in the current value from the live table as a final record SELECT 9223372036854775807 --max bigint so these always sort last ,id From ff271999c0ad0946839c35bc15c3031132dedf69 Mon Sep 17 00:00:00 2001 From: Nathan Powell Date: Tue, 28 May 2024 01:16:46 -0700 Subject: [PATCH 70/78] [TTAHUB-2986]Correct Spanish coursenames with unknown characters --- ...0529000000-correct-spanish-course-names.js | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/migrations/20240529000000-correct-spanish-course-names.js diff --git a/src/migrations/20240529000000-correct-spanish-course-names.js b/src/migrations/20240529000000-correct-spanish-course-names.js new file mode 100644 index 0000000000..4e69b14a53 --- /dev/null +++ b/src/migrations/20240529000000-correct-spanish-course-names.js @@ -0,0 +1,90 @@ +const { + prepMigration, +} = require('../lib/migration'); + +module.exports = { + up: async (queryInterface) => queryInterface.sequelize.transaction( + async (transaction) => { + await prepMigration(queryInterface, transaction, __filename); + await queryInterface.sequelize.query(/* sql */` + -- Matches to created the mapping use hex encodings of the exact bits currently + -- stored in the database to avoid having to trust that the UTF-8 'unknown character' + -- value won't get corrupted somewhere along the deployment chain and cause the string + -- values to not match. An alternative method would be to match on IDs, but this is + -- vulnerable if anything else ends up changing the ID order in the meantime. + DROP TABLE IF EXISTS name_map; + CREATE TEMP TABLE name_map + AS + SELECT + id old_cid, + LEFT(name,30) old_name, -- here for validation convenience + 'Apoyar al desarrollo de bebés y niños pequeños (BTS-IT)' new_name + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '41706f79617220616c206465736172726f6c6c6f20646520626562efbfbd732079206e69efbfbd6f73207065717565efbfbd6f7320284254532d495429' + UNION SELECT id, LEFT(name,30), 'Apoyo para niños y familias que están experimentando la carencia de hogar' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '41706f796f2070617261206e69efbfbd6f7320792066616d696c6961732071756520657374efbfbd6e206578706572696d656e74616e646f206c6120636172656e63696120646520686f676172' + UNION SELECT id, LEFT(name,30), 'Autoevaluación: su viaje anual' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '4175746f6576616c75616369efbfbd6e3a207375207669616a6520616e75616c' + UNION SELECT id, LEFT(name,30), 'Capacitación de Liderazgo y gobernanza en Head Start: valores, reglamentos y habilidades' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '43617061636974616369efbfbd6e206465204c69646572617a676f207920676f6265726e616e7a6120656e20486561642053746172743a2076616c6f7265732c207265676c616d656e746f73207920686162696c696461646573' + UNION SELECT id, LEFT(name,30), 'Coaching basado en la práctica' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '436f616368696e672062617361646f20656e206c61207072efbfbd6374696361' + UNION SELECT id, LEFT(name,30), 'Evaluación continua (BTS-IT)' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '4576616c75616369efbfbd6e20636f6e74696e756120284254532d495429' + UNION SELECT id, LEFT(name,30), 'Gerentes de educación en vivo' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '476572656e7465732064652065647563616369efbfbd6e20656e207669766f' + UNION SELECT id, LEFT(name,30), 'La gestión es importante: Asignación de costos' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '4c61206765737469efbfbd6e20657320696d706f7274616e74653a20417369676e616369efbfbd6e20646520636f73746f73' + UNION SELECT id, LEFT(name,30), 'Planificación del aprendizaje (BTS-IT)' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '506c616e69666963616369efbfbd6e2064656c20617072656e64697a616a6520284254532d495429' + UNION SELECT id, LEFT(name,30), 'Práctica basada en la relación (BTS-IT)' + FROM "Courses" + WHERE ENCODE(name::bytea,'hex') = + '5072efbfbd63746963612062617361646120656e206c612072656c616369efbfbd6e20284254532d495429' + ; + + -- Insert the new courses + INSERT INTO "Courses" (name) + SELECT new_name + FROM name_map + ; + + -- Update the old courses with the mapsTo the new courses, and set their deletedAt + UPDATE "Courses" uc + SET + "mapsTo" = c.id, + "updatedAt" = NOW(), + "deletedAt" = NOW() + FROM name_map nm + JOIN "Courses" c + ON nm.new_name = c.name + WHERE uc.id = old_cid + ; + + `, { transaction }); + }, + ), + + down: async (queryInterface) => queryInterface.sequelize.transaction( + async () => { + // there's no point in un-fixing this data + }, + ), +}; From 1aa8bf0b636b4133d7b06e2195d5ff88ca5903d0 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 11:11:41 -0400 Subject: [PATCH 71/78] Rename objective migration --- ...0946-add-created-here-column-to-activity-report-objectives.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/migrations/{20240516140827-add-created-here-column-to-activity-report-objectives.js => 20240528150946-add-created-here-column-to-activity-report-objectives.js} (100%) diff --git a/src/migrations/20240516140827-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240528150946-add-created-here-column-to-activity-report-objectives.js similarity index 100% rename from src/migrations/20240516140827-add-created-here-column-to-activity-report-objectives.js rename to src/migrations/20240528150946-add-created-here-column-to-activity-report-objectives.js From 18743bca4987069f46e77e4698677bad0d127206 Mon Sep 17 00:00:00 2001 From: GarrettEHill Date: Tue, 28 May 2024 10:13:30 -0700 Subject: [PATCH 72/78] JDI to SSDI --- src/queries/class-goal-dataset.sql | 72 +++++++++---------- src/queries/class-goal-use.sql | 84 +++++++++++----------- src/queries/fake-class-goal-use.sql | 92 ++++++++++++------------- src/queries/fei-goals.sql | 80 ++++++++++----------- src/queries/monthly-delivery-report.sql | 48 ++++++------- 5 files changed, 188 insertions(+), 188 deletions(-) diff --git a/src/queries/class-goal-dataset.sql b/src/queries/class-goal-dataset.sql index 0765d5fda4..816ccc5ee6 100644 --- a/src/queries/class-goal-dataset.sql +++ b/src/queries/class-goal-dataset.sql @@ -1,20 +1,20 @@ /** * This query collects all the goals. * -* The query results are filterable by the JDI flags. All JDI flags are passed as an array of values +* The query results are filterable by the SSDI flags. All SSDI flags are passed as an array of values * The following are the available flags within this script: -* - jdi.regionIds - one or more values for 1 through 12 -* - jdi.recipients - one or more verbatium recipient names -* - jdi.grantNumbers - one or more verbatium grant numbers -* - jdi.goals - one or more verbatium goal text -* - jdi.status - one or more verbatium statuses -* - jdi.createdVia - one or more verbatium created via values -* - jdi.onApprovedAR - true or false -* - jdi.createdbetween - two dates defining a range for the createdAt to be within +* - ssdi.regionIds - one or more values for 1 through 12 +* - ssdi.recipients - one or more verbatium recipient names +* - ssdi.grantNumbers - one or more verbatium grant numbers +* - ssdi.goals - one or more verbatium goal text +* - ssdi.status - one or more verbatium statuses +* - ssdi.createdVia - one or more verbatium created via values +* - ssdi.onApprovedAR - true or false +* - ssdi.createdbetween - two dates defining a range for the createdAt to be within * -* zero or more JDI flags can be set within the same transaction as the query is executed. -* The following is an example of how to set a JDI flag: -* SELECT SET_CONFIG('jdi.createdbetween','["2022-07-01","2023-06-30"]',TRUE); +* zero or more SSDI flags can be set within the same transaction as the query is executed. +* The following is an example of how to set a SSDI flag: +* SELECT SET_CONFIG('ssdi.createdbetween','["2022-07-01","2023-06-30"]',TRUE); */ SELECT g."id" AS "goal id", @@ -32,59 +32,59 @@ ON g."grantId" = gr."id" JOIN "Recipients" r ON gr."recipientId" = r.id WHERE --- Filter for regionIds if jdi.regionIds is defined -(NULLIF(current_setting('jdi.regionIds', true), '') IS NULL +-- Filter for regionIds if ssdi.regionIds is defined +(NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR gr."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) AND --- Filter for recipients if jdi.recipients is defined -(NULLIF(current_setting('jdi.recipients', true), '') IS NULL +-- Filter for recipients if ssdi.recipients is defined +(NULLIF(current_setting('ssdi.recipients', true), '') IS NULL OR r.name in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.recipients', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.recipients', true), ''),'[]')::json) AS value )) AND --- Filter for grantNumbers if jdi.grantNumbers is defined -(NULLIF(current_setting('jdi.grantNumbers', true), '') IS NULL +-- Filter for grantNumbers if ssdi.grantNumbers is defined +(NULLIF(current_setting('ssdi.grantNumbers', true), '') IS NULL OR gr.number in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.grantNumbers', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.grantNumbers', true), ''),'[]')::json) AS value )) AND --- Filter for status if jdi.goals is defined -(NULLIF(current_setting('jdi.goals', true), '') IS NULL +-- Filter for status if ssdi.goals is defined +(NULLIF(current_setting('ssdi.goals', true), '') IS NULL OR g.name in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.goals', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.goals', true), ''),'[]')::json) AS value )) AND --- Filter for status if jdi.status is defined -(NULLIF(current_setting('jdi.status', true), '') IS NULL +-- Filter for status if ssdi.status is defined +(NULLIF(current_setting('ssdi.status', true), '') IS NULL OR g.status in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.status', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.status', true), ''),'[]')::json) AS value )) AND --- Filter for createdVia if jdi.createdVia is defined -(NULLIF(current_setting('jdi.createdVia', true), '') IS NULL +-- Filter for createdVia if ssdi.createdVia is defined +(NULLIF(current_setting('ssdi.createdVia', true), '') IS NULL OR g."createdVia" in ( SELECT value::"enum_Goals_createdVia" AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdVia', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdVia', true), ''),'[]')::json) AS value )) AND --- Filter for onApprovedAR if jdi.onApprovedAR is defined -(NULLIF(current_setting('jdi.onApprovedAR', true), '') IS NULL +-- Filter for onApprovedAR if ssdi.onApprovedAR is defined +(NULLIF(current_setting('ssdi.onApprovedAR', true), '') IS NULL OR g."onApprovedAR" in ( SELECT value::BOOLEAN AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.onApprovedAR', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.onApprovedAR', true), ''),'[]')::json) AS value )) AND --- Filter for createdAt dates between two values if jdi.createdbetween is defined -(NULLIF(current_setting('jdi.createdbetween', true), '') IS NULL +-- Filter for createdAt dates between two values if ssdi.createdbetween is defined +(NULLIF(current_setting('ssdi.createdbetween', true), '') IS NULL OR g."createdAt"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdbetween', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdbetween', true), ''),'[]')::json) AS value )) ORDER BY 6,5; diff --git a/src/queries/class-goal-use.sql b/src/queries/class-goal-use.sql index ba4736c4d8..e531a9b4e8 100644 --- a/src/queries/class-goal-use.sql +++ b/src/queries/class-goal-use.sql @@ -1,22 +1,22 @@ /** * This query collects all the Monitoring goals used on approved reports within the defined time range. * -* The query results are filterable by the JDI flags. All JDI flags are passed as an array of values +* The query results are filterable by the SSDI flags. All SSDI flags are passed as an array of values * The following are the available flags within this script: -* - jdi.regionIds - one or more values for 1 through 12 -* - jdi.recipients - one or more verbatium recipient names -* - jdi.grantNumbers - one or more verbatium grant numbers -* - jdi.goals - one or more verbatium goal text -* - jdi.status - one or more verbatium statuses -* - jdi.createdVia - one or more verbatium created via values -* - jdi.onApprovedAR - true or false -* - jdi.createdbetween - two dates defining a range for the createdAt to be within -* - jdi.startDate - two dates defining a range for the startDate to be within -* - jdi.endDate - two dates defining a range for the endDate to be within +* - ssdi.regionIds - one or more values for 1 through 12 +* - ssdi.recipients - one or more verbatium recipient names +* - ssdi.grantNumbers - one or more verbatium grant numbers +* - ssdi.goals - one or more verbatium goal text +* - ssdi.status - one or more verbatium statuses +* - ssdi.createdVia - one or more verbatium created via values +* - ssdi.onApprovedAR - true or false +* - ssdi.createdbetween - two dates defining a range for the createdAt to be within +* - ssdi.startDate - two dates defining a range for the startDate to be within +* - ssdi.endDate - two dates defining a range for the endDate to be within * -* zero or more JDI flags can be set within the same transaction as the query is executed. -* The following is an example of how to set a JDI flag: -* SELECT SET_CONFIG('jdi.createdbetween','["2023-10-01","2023-10-15"]',TRUE); +* zero or more SSDI flags can be set within the same transaction as the query is executed. +* The following is an example of how to set a SSDI flag: +* SELECT SET_CONFIG('ssdi.createdbetween','["2023-10-01","2023-10-15"]',TRUE); */ SELECT gr."regionId", @@ -41,66 +41,66 @@ JOIN "Recipients" r ON gr."recipientId" = r.id WHERE ar."calculatedStatus" = 'approved' AND --- Filter for regionIds if jdi.regionIds is defined -(NULLIF(current_setting('jdi.regionIds', true), '') IS NULL +-- Filter for regionIds if ssdi.regionIds is defined +(NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR gr."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) AND --- Filter for recipients if jdi.recipients is defined -(NULLIF(current_setting('jdi.recipients', true), '') IS NULL +-- Filter for recipients if ssdi.recipients is defined +(NULLIF(current_setting('ssdi.recipients', true), '') IS NULL OR r.name in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.recipients', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.recipients', true), ''),'[]')::json) AS value )) AND --- Filter for grantNumbers if jdi.grantNumbers is defined -(NULLIF(current_setting('jdi.grantNumbers', true), '') IS NULL +-- Filter for grantNumbers if ssdi.grantNumbers is defined +(NULLIF(current_setting('ssdi.grantNumbers', true), '') IS NULL OR gr.number in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.grantNumbers', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.grantNumbers', true), ''),'[]')::json) AS value )) AND --- Filter for status if jdi.status is defined -(NULLIF(current_setting('jdi.status', true), '') IS NULL +-- Filter for status if ssdi.status is defined +(NULLIF(current_setting('ssdi.status', true), '') IS NULL OR g.status in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.status', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.status', true), ''),'[]')::json) AS value )) AND --- Filter for createdVia if jdi.createdVia is defined -(NULLIF(current_setting('jdi.createdVia', true), '') IS NULL +-- Filter for createdVia if ssdi.createdVia is defined +(NULLIF(current_setting('ssdi.createdVia', true), '') IS NULL OR g."createdVia" in ( SELECT value::"enum_Goals_createdVia" AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdVia', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdVia', true), ''),'[]')::json) AS value )) AND --- Filter for onApprovedAR if jdi.onApprovedAR is defined -(NULLIF(current_setting('jdi.onApprovedAR', true), '') IS NULL +-- Filter for onApprovedAR if ssdi.onApprovedAR is defined +(NULLIF(current_setting('ssdi.onApprovedAR', true), '') IS NULL OR g."onApprovedAR" in ( SELECT value::BOOLEAN AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.onApprovedAR', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.onApprovedAR', true), ''),'[]')::json) AS value )) AND --- Filter for createdAt dates between two values if jdi.createdbetween is defined -(NULLIF(current_setting('jdi.createdbetween', true), '') IS NULL +-- Filter for createdAt dates between two values if ssdi.createdbetween is defined +(NULLIF(current_setting('ssdi.createdbetween', true), '') IS NULL OR g."createdAt"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdbetween', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdbetween', true), ''),'[]')::json) AS value )) AND --- Filter for startDate dates between two values if jdi.startDate is defined -(NULLIF(current_setting('jdi.startDate', true), '') IS NULL +-- Filter for startDate dates between two values if ssdi.startDate is defined +(NULLIF(current_setting('ssdi.startDate', true), '') IS NULL OR ar."startDate"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.startDate', true), ''),'[]')::json) AS value )) AND --- Filter for endDate dates between two values if jdi.endDate is defined -(NULLIF(current_setting('jdi.endDate', true), '') IS NULL +-- Filter for endDate dates between two values if ssdi.endDate is defined +(NULLIF(current_setting('ssdi.endDate', true), '') IS NULL OR ar."endDate"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.endDate', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.endDate', true), ''),'[]')::json) AS value )) -ORDER BY 1,2,3,5; \ No newline at end of file +ORDER BY 1,2,3,5; diff --git a/src/queries/fake-class-goal-use.sql b/src/queries/fake-class-goal-use.sql index 8421b7a0e5..16106388f2 100644 --- a/src/queries/fake-class-goal-use.sql +++ b/src/queries/fake-class-goal-use.sql @@ -1,22 +1,22 @@ /** * This query collects all the fake Monitoring goals used on approved reports within the defined time range. * -* The query results are filterable by the JDI flags. All JDI flags are passed as an array of values +* The query results are filterable by the SSDI flags. All SSDI flags are passed as an array of values * The following are the available flags within this script: -* - jdi.regionIds - one or more values for 1 through 12 -* - jdi.recipients - one or more verbatium recipient names -* - jdi.grantNumbers - one or more verbatium grant numbers -* - jdi.goals - one or more verbatium goal text -* - jdi.status - one or more verbatium statuses -* - jdi.createdVia - one or more verbatium created via values -* - jdi.onApprovedAR - true or false -* - jdi.createdbetween - two dates defining a range for the createdAt to be within -* - jdi.startDate - two dates defining a range for the startDate to be within -* - jdi.endDate - two dates defining a range for the endDate to be within +* - ssdi.regionIds - one or more values for 1 through 12 +* - ssdi.recipients - one or more verbatium recipient names +* - ssdi.grantNumbers - one or more verbatium grant numbers +* - ssdi.goals - one or more verbatium goal text +* - ssdi.status - one or more verbatium statuses +* - ssdi.createdVia - one or more verbatium created via values +* - ssdi.onApprovedAR - true or false +* - ssdi.createdbetween - two dates defining a range for the createdAt to be within +* - ssdi.startDate - two dates defining a range for the startDate to be within +* - ssdi.endDate - two dates defining a range for the endDate to be within * -* zero or more JDI flags can be set within the same transaction as the query is executed. -* The following is an example of how to set a JDI flag: -* SELECT SET_CONFIG('jdi.createdbetween','["2023-10-01","2023-10-15"]',TRUE); +* zero or more SSDI flags can be set within the same transaction as the query is executed. +* The following is an example of how to set a SSDI flag: +* SELECT SET_CONFIG('ssdi.createdbetween','["2023-10-01","2023-10-15"]',TRUE); */ SELECT gr."regionId", @@ -36,72 +36,72 @@ ON g."grantId" = gr.id JOIN "Recipients" r ON gr."recipientId" = r.id WHERE ar."calculatedStatus" = 'approved' -AND g.name like '%CLASS%' -AND g.name like '%improve%' -AND g.name like '%teacher%' +AND g.name like '%CLASS%' +AND g.name like '%improve%' +AND g.name like '%teacher%' AND g.name like '%child%' -and g."goalTemplateId" != 18172 +and g."goalTemplateId" != 18172 AND --- Filter for regionIds if jdi.regionIds is defined -(NULLIF(current_setting('jdi.regionIds', true), '') IS NULL +-- Filter for regionIds if ssdi.regionIds is defined +(NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR gr."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) AND --- Filter for recipients if jdi.recipients is defined -(NULLIF(current_setting('jdi.recipients', true), '') IS NULL +-- Filter for recipients if ssdi.recipients is defined +(NULLIF(current_setting('ssdi.recipients', true), '') IS NULL OR r.name in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.recipients', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.recipients', true), ''),'[]')::json) AS value )) AND --- Filter for grantNumbers if jdi.grantNumbers is defined -(NULLIF(current_setting('jdi.grantNumbers', true), '') IS NULL +-- Filter for grantNumbers if ssdi.grantNumbers is defined +(NULLIF(current_setting('ssdi.grantNumbers', true), '') IS NULL OR gr.number in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.grantNumbers', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.grantNumbers', true), ''),'[]')::json) AS value )) AND --- Filter for status if jdi.status is defined -(NULLIF(current_setting('jdi.status', true), '') IS NULL +-- Filter for status if ssdi.status is defined +(NULLIF(current_setting('ssdi.status', true), '') IS NULL OR g.status in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.status', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.status', true), ''),'[]')::json) AS value )) AND --- Filter for createdVia if jdi.createdVia is defined -(NULLIF(current_setting('jdi.createdVia', true), '') IS NULL +-- Filter for createdVia if ssdi.createdVia is defined +(NULLIF(current_setting('ssdi.createdVia', true), '') IS NULL OR g."createdVia" in ( SELECT value::"enum_Goals_createdVia" AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdVia', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdVia', true), ''),'[]')::json) AS value )) AND --- Filter for onApprovedAR if jdi.onApprovedAR is defined -(NULLIF(current_setting('jdi.onApprovedAR', true), '') IS NULL +-- Filter for onApprovedAR if ssdi.onApprovedAR is defined +(NULLIF(current_setting('ssdi.onApprovedAR', true), '') IS NULL OR g."onApprovedAR" in ( SELECT value::BOOLEAN AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.onApprovedAR', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.onApprovedAR', true), ''),'[]')::json) AS value )) AND --- Filter for createdAt dates between two values if jdi.createdbetween is defined -(NULLIF(current_setting('jdi.createdbetween', true), '') IS NULL +-- Filter for createdAt dates between two values if ssdi.createdbetween is defined +(NULLIF(current_setting('ssdi.createdbetween', true), '') IS NULL OR g."createdAt"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdbetween', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdbetween', true), ''),'[]')::json) AS value )) AND --- Filter for startDate dates between two values if jdi.startDate is defined -(NULLIF(current_setting('jdi.startDate', true), '') IS NULL +-- Filter for startDate dates between two values if ssdi.startDate is defined +(NULLIF(current_setting('ssdi.startDate', true), '') IS NULL OR ar."startDate"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.startDate', true), ''),'[]')::json) AS value )) AND --- Filter for endDate dates between two values if jdi.endDate is defined -(NULLIF(current_setting('jdi.endDate', true), '') IS NULL +-- Filter for endDate dates between two values if ssdi.endDate is defined +(NULLIF(current_setting('ssdi.endDate', true), '') IS NULL OR ar."endDate"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.endDate', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.endDate', true), ''),'[]')::json) AS value )) -ORDER BY 1,2,3,5; \ No newline at end of file +ORDER BY 1,2,3,5; diff --git a/src/queries/fei-goals.sql b/src/queries/fei-goals.sql index eb4f58f966..706376fa94 100644 --- a/src/queries/fei-goals.sql +++ b/src/queries/fei-goals.sql @@ -1,21 +1,21 @@ /** * This query collects all the FEI and near FEI goals based on several criteria. * -* The query results are filterable by the JDI flags. All JDI flags are passed as an array of values +* The query results are filterable by the SSDI flags. All SSDI flags are passed as an array of values * The following are the available flags within this script: -* - jdi.regionIds - one or more values for 1 through 12 -* - jdi.recipients - one or more verbatium recipient names -* - jdi.grantNumbers - one or more verbatium grant numbers -* - jdi.goals - one or more verbatium goal text -* - jdi.status - one or more verbatium statuses -* - jdi.createdVia - one or more verbatium created via values -* - jdi.onApprovedAR - true or false -* - jdi.response - one or more verbatium response values -* - jdi.createdbetween - two dates defining a range for the createdAt to be within +* - ssdi.regionIds - one or more values for 1 through 12 +* - ssdi.recipients - one or more verbatium recipient names +* - ssdi.grantNumbers - one or more verbatium grant numbers +* - ssdi.goals - one or more verbatium goal text +* - ssdi.status - one or more verbatium statuses +* - ssdi.createdVia - one or more verbatium created via values +* - ssdi.onApprovedAR - true or false +* - ssdi.response - one or more verbatium response values +* - ssdi.createdbetween - two dates defining a range for the createdAt to be within * -* zero or more JDI flags can be set within the same transaction as the query is executed. -* The following is an example of how to set a JDI flag: -* SELECT SET_CONFIG('jdi.createdbetween','["2023-10-01","2023-10-15"]',TRUE); +* zero or more SSDI flags can be set within the same transaction as the query is executed. +* The following is an example of how to set a SSDI flag: +* SELECT SET_CONFIG('ssdi.createdbetween','["2023-10-01","2023-10-15"]',TRUE); */ WITH bad AS ( SELECT * @@ -60,66 +60,66 @@ ON b."grantId" = gr.id JOIN "Recipients" r ON gr."recipientId" = r.id WHERE --- Filter for regionIds if jdi.regionIds is defined -(NULLIF(current_setting('jdi.regionIds', true), '') IS NULL +-- Filter for regionIds if ssdi.regionIds is defined +(NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR gr."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) AND --- Filter for recipients if jdi.recipients is defined -(NULLIF(current_setting('jdi.recipients', true), '') IS NULL +-- Filter for recipients if ssdi.recipients is defined +(NULLIF(current_setting('ssdi.recipients', true), '') IS NULL OR r.name in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.recipients', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.recipients', true), ''),'[]')::json) AS value )) AND --- Filter for grantNumbers if jdi.grantNumbers is defined -(NULLIF(current_setting('jdi.grantNumbers', true), '') IS NULL +-- Filter for grantNumbers if ssdi.grantNumbers is defined +(NULLIF(current_setting('ssdi.grantNumbers', true), '') IS NULL OR gr.number in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.grantNumbers', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.grantNumbers', true), ''),'[]')::json) AS value )) AND --- Filter for goals if jdi.goals is defined -(NULLIF(current_setting('jdi.goals', true), '') IS NULL +-- Filter for goals if ssdi.goals is defined +(NULLIF(current_setting('ssdi.goals', true), '') IS NULL OR b.name in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.goals', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.goals', true), ''),'[]')::json) AS value )) AND --- Filter for status if jdi.status is defined -(NULLIF(current_setting('jdi.status', true), '') IS NULL +-- Filter for status if ssdi.status is defined +(NULLIF(current_setting('ssdi.status', true), '') IS NULL OR b.status in ( SELECT value::text AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.status', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.status', true), ''),'[]')::json) AS value )) AND --- Filter for createdVia if jdi.createdVia is defined -(NULLIF(current_setting('jdi.createdVia', true), '') IS NULL +-- Filter for createdVia if ssdi.createdVia is defined +(NULLIF(current_setting('ssdi.createdVia', true), '') IS NULL OR b."createdVia" in ( SELECT value::"enum_Goals_createdVia" AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdVia', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdVia', true), ''),'[]')::json) AS value )) AND --- Filter for onApprovedAR if jdi.onApprovedAR is defined -(NULLIF(current_setting('jdi.onApprovedAR', true), '') IS NULL +-- Filter for onApprovedAR if ssdi.onApprovedAR is defined +(NULLIF(current_setting('ssdi.onApprovedAR', true), '') IS NULL OR b."onApprovedAR" in ( SELECT value::BOOLEAN AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.onApprovedAR', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.onApprovedAR', true), ''),'[]')::json) AS value )) AND --- Filter for response if jdi.response is defined -(NULLIF(current_setting('jdi.response', true), '') IS NULL +-- Filter for response if ssdi.response is defined +(NULLIF(current_setting('ssdi.response', true), '') IS NULL OR gfr."response" && ( SELECT ARRAY_AGG(value::text) AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.response', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.response', true), ''),'[]')::json) AS value )) AND --- Filter for createdAt dates between two values if jdi.createdbetween is defined -(NULLIF(current_setting('jdi.createdbetween', true), '') IS NULL +-- Filter for createdAt dates between two values if ssdi.createdbetween is defined +(NULLIF(current_setting('ssdi.createdbetween', true), '') IS NULL OR b."createdAt"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.createdbetween', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.createdbetween', true), ''),'[]')::json) AS value )) order by 3,1,2,4 diff --git a/src/queries/monthly-delivery-report.sql b/src/queries/monthly-delivery-report.sql index 30cda6259a..fe299b794d 100644 --- a/src/queries/monthly-delivery-report.sql +++ b/src/queries/monthly-delivery-report.sql @@ -1,15 +1,15 @@ /** * This query collects all the Monitoring goals used on approved reports within the defined time range. * -* The query results are filterable by the JDI flags. All JDI flags are passed as an array of values +* The query results are filterable by the SSDI flags. All SSDI flags are passed as an array of values * The following are the available flags within this script: -* - jdi.regionIds - one or more values for 1 through 12 -* - jdi.startDate - two dates defining a range for the startDate to be within +* - ssdi.regionIds - one or more values for 1 through 12 +* - ssdi.startDate - two dates defining a range for the startDate to be within * -* zero or more JDI flags can be set within the same transaction as the query is executed. -* The following is an example of how to set a JDI flag: -* SELECT SET_CONFIG('jdi.regionIds','[11]',TRUE); -* SELECT SET_CONFIG('jdi.startDate','["2024-04-01","2024-04-30"]',TRUE); +* zero or more SSDI flags can be set within the same transaction as the query is executed. +* The following is an example of how to set a SSDI flag: +* SELECT SET_CONFIG('ssdi.regionIds','[11]',TRUE); +* SELECT SET_CONFIG('ssdi.startDate','["2024-04-01","2024-04-30"]',TRUE); */ WITH reports AS ( @@ -22,17 +22,17 @@ WITH a."startDate" FROM "ActivityReports" a WHERE a."calculatedStatus" = 'approved' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + -- Filter for regionIds if ssdi.regionIds is defined + AND (NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR a."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) - -- Filter for startDate dates between two values if jdi.startDate is defined - AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + -- Filter for startDate dates between two values if ssdi.startDate is defined + AND (NULLIF(current_setting('ssdi.startDate', true), '') IS NULL OR a."startDate"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.startDate', true), ''),'[]')::json) AS value )) UNION @@ -47,17 +47,17 @@ WITH JOIN "ActivityReportCollaborators" arc ON a.id = arc."activityReportId" WHERE a."calculatedStatus" = 'approved' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + -- Filter for regionIds if ssdi.regionIds is defined + AND (NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR a."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) - -- Filter for startDate dates between two values if jdi.startDate is defined - AND (NULLIF(current_setting('jdi.startDate', true), '') IS NULL + -- Filter for startDate dates between two values if ssdi.startDate is defined + AND (NULLIF(current_setting('ssdi.startDate', true), '') IS NULL OR a."startDate"::date <@ ( SELECT CONCAT('[',MIN(value::timestamp),',',MAX(value::timestamp),')')::daterange AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.startDate', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.startDate', true), ''),'[]')::json) AS value )) ), users_is_scope AS ( @@ -69,10 +69,10 @@ WITH LEFT JOIN "Permissions" p ON u.id = p."userId" LEFT JOIN "Scopes" s ON p."scopeId" = s.id LEFT JOIN "reports" a ON a."userId" = u.id - WHERE (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + WHERE (NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR u."homeRegionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) GROUP BY 1,2,3 HAVING COUNT(DISTINCT a.id) > 0 OR 'SITE_ACCESS' = ANY(ARRAY_AGG(s.name)) @@ -172,11 +172,11 @@ WITH COUNT(DISTINCT gr."recipientId") AS recipient_count FROM "Grants" gr WHERE gr.status = 'Active' - -- Filter for regionIds if jdi.regionIds is defined - AND (NULLIF(current_setting('jdi.regionIds', true), '') IS NULL + -- Filter for regionIds if ssdi.regionIds is defined + AND (NULLIF(current_setting('ssdi.regionIds', true), '') IS NULL OR gr."regionId" in ( SELECT value::integer AS my_array - FROM json_array_elements_text(COALESCE(NULLIF(current_setting('jdi.regionIds', true), ''),'[]')::json) AS value + FROM json_array_elements_text(COALESCE(NULLIF(current_setting('ssdi.regionIds', true), ''),'[]')::json) AS value )) ), recipient_data AS ( From a8d19b4ad5f38660fdb5024caadc6f1fbbc45b7a Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 14:13:45 -0400 Subject: [PATCH 73/78] Fix for bug discovered in adding objective to existing goal --- frontend/src/components/GoalForm/constants.js | 2 +- frontend/src/components/GoalForm/index.js | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/GoalForm/constants.js b/frontend/src/components/GoalForm/constants.js index 7024d3fe87..de1565528d 100644 --- a/frontend/src/components/GoalForm/constants.js +++ b/frontend/src/components/GoalForm/constants.js @@ -181,7 +181,7 @@ export const grantsToGoals = ({ endDate: endDate && endDate !== 'Invalid date' ? endDate : null, regionId: parseInt(regionId, DECIMAL_BASE), recipientId: recipient.id, - objectives: objectivesWithValidResourcesOnly(objectives), + objectives, ids, prompts: goalPrompts, }; diff --git a/frontend/src/components/GoalForm/index.js b/frontend/src/components/GoalForm/index.js index 783b2c6241..e08cba616d 100644 --- a/frontend/src/components/GoalForm/index.js +++ b/frontend/src/components/GoalForm/index.js @@ -341,12 +341,6 @@ export default function GoalForm({ let isValid = true; const newObjectiveErrors = objectives.map((objective) => { - if (objective.status === 'Complete' || (objective.activityReports && objective.activityReports.length)) { - return [ - ...OBJECTIVE_DEFAULT_ERRORS, - ]; - } - if (!objective.title) { isValid = false; return [ @@ -416,6 +410,7 @@ export default function GoalForm({ const onSubmit = async (e) => { e.preventDefault(); setAppLoadingText('Submitting'); + setAlert({ message: '', type: 'success' }); setIsAppLoading(true); try { // if the goal is a draft, submission should move it to "not started" @@ -546,6 +541,7 @@ export default function GoalForm({ }; const onSaveAndContinue = async (redirect = false) => { + setAlert({ message: '', type: 'success' }); if (!isValidNotStarted()) { // attempt to focus on the first invalid field const invalid = document.querySelector('.usa-form :invalid:not(fieldset), .usa-form-group--error textarea, .usa-form-group--error input, .usa-error-message + .ttahub-resource-repeater input'); @@ -598,9 +594,7 @@ export default function GoalForm({ ...goal, ids: goal.goalIds, grants: goal.grants, - objectives: goal.objectives.map((objective) => ({ - ...objective, - })), + objectives: goal.objectives, }))); if (redirect) { @@ -614,6 +608,7 @@ export default function GoalForm({ type: 'success', }); } catch (error) { + console.log(error); setAlert({ message: 'There was an error saving your goal', type: 'error', From 84e26c8ff3aa09ff1f9bc9aff6a194b5237909aa Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 14:14:06 -0400 Subject: [PATCH 74/78] Remove console.log --- frontend/src/components/GoalForm/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/GoalForm/index.js b/frontend/src/components/GoalForm/index.js index e08cba616d..5c5d010496 100644 --- a/frontend/src/components/GoalForm/index.js +++ b/frontend/src/components/GoalForm/index.js @@ -608,7 +608,6 @@ export default function GoalForm({ type: 'success', }); } catch (error) { - console.log(error); setAlert({ message: 'There was an error saving your goal', type: 'error', From bdb0758cf430a420c8dd1c2a711babc3868a206b Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 14:46:27 -0400 Subject: [PATCH 75/78] Rename migration --- ...4550-add-created-here-column-to-activity-report-objectives.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/migrations/{20240528150946-add-created-here-column-to-activity-report-objectives.js => 20240528184550-add-created-here-column-to-activity-report-objectives.js} (100%) diff --git a/src/migrations/20240528150946-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240528184550-add-created-here-column-to-activity-report-objectives.js similarity index 100% rename from src/migrations/20240528150946-add-created-here-column-to-activity-report-objectives.js rename to src/migrations/20240528184550-add-created-here-column-to-activity-report-objectives.js From ce6390501fffc03a86f5e1c7012ca511ef0fe601 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 15:03:45 -0400 Subject: [PATCH 76/78] Rename migration --- ...0254-add-created-here-column-to-activity-report-objectives.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/migrations/{20240528184550-add-created-here-column-to-activity-report-objectives.js => 20240528190254-add-created-here-column-to-activity-report-objectives.js} (100%) diff --git a/src/migrations/20240528184550-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240528190254-add-created-here-column-to-activity-report-objectives.js similarity index 100% rename from src/migrations/20240528184550-add-created-here-column-to-activity-report-objectives.js rename to src/migrations/20240528190254-add-created-here-column-to-activity-report-objectives.js From 218cdfff29ce431ea8c8101b8db0ec2ac0d19815 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 15:05:54 -0400 Subject: [PATCH 77/78] Rename migration --- ...0405-add-created-here-column-to-activity-report-objectives.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/migrations/{20240528190254-add-created-here-column-to-activity-report-objectives.js => 20240520090405-add-created-here-column-to-activity-report-objectives.js} (100%) diff --git a/src/migrations/20240528190254-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240520090405-add-created-here-column-to-activity-report-objectives.js similarity index 100% rename from src/migrations/20240528190254-add-created-here-column-to-activity-report-objectives.js rename to src/migrations/20240520090405-add-created-here-column-to-activity-report-objectives.js From 6a4b4c9b147db81ab0e9471d2fc6f701f89737f0 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 28 May 2024 15:06:55 -0400 Subject: [PATCH 78/78] Rename migration --- ...0616-add-created-here-column-to-activity-report-objectives.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/migrations/{20240520090405-add-created-here-column-to-activity-report-objectives.js => 20240529190616-add-created-here-column-to-activity-report-objectives.js} (100%) diff --git a/src/migrations/20240520090405-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240529190616-add-created-here-column-to-activity-report-objectives.js similarity index 100% rename from src/migrations/20240520090405-add-created-here-column-to-activity-report-objectives.js rename to src/migrations/20240529190616-add-created-here-column-to-activity-report-objectives.js