diff --git a/.circleci/config.yml b/.circleci/config.yml index 34bbeee72d..c081a9583f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -420,10 +420,10 @@ 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" + default: "mb/TTAHUB-2530/update-RTR-objective-form" type: string prod_new_relic_app_id: default: "877570491" 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/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/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/GoalCards/GoalCard.js b/frontend/src/components/GoalCards/GoalCard.js index 54bd38d5ce..b634e181bb 100644 --- a/frontend/src/components/GoalCards/GoalCard.js +++ b/frontend/src/components/GoalCards/GoalCard.js @@ -69,7 +69,7 @@ ObjectiveSwitch.propTypes = { dispatchStatusChange: PropTypes.func.isRequired, }; -function GoalCard({ +export default function GoalCard({ goal, recipientId, regionId, @@ -100,10 +100,12 @@ function GoalCard({ isReopenedGoal, } = goal; + const { user } = useContext(UserContext); + const { setIsAppLoading } = useContext(AppLoadingContext); const [invalidStatusChangeAttempted, setInvalidStatusChangeAttempted] = useState(); const sortedObjectives = [...objectives, ...(sessionObjectives || [])]; sortedObjectives.sort((a, b) => ((new Date(a.endDate) < new Date(b.endDate)) ? 1 : -1)); - + const hasEditButtonPermissions = canEditOrCreateGoals(user, parseInt(regionId, DECIMAL_BASE)); const { atLeastOneObjectiveIsNotCompletedOrSuspended, dispatchStatusChange, @@ -123,10 +125,8 @@ function GoalCard({ const goalNumbers = `${goal.goalNumbers.join(', ')}${isReopenedGoal ? '-R' : ''}`; - const { user } = useContext(UserContext); - 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 = [ @@ -154,33 +154,39 @@ function GoalCard({ setObjectivesExpanded(!objectivesExpanded); }; - const hasEditButtonPermissions = canEditOrCreateGoals(user, parseInt(regionId, DECIMAL_BASE)); - const determineMenuItems = () => { - // Add reopen button if user has permissions and the goal is closed. - const createdMenuItems = []; + const contextMenuLabel = `Actions for goal ${id}`; - if (goalStatus === 'Closed' && hasEditButtonPermissions) { - createdMenuItems.push({ - label: 'Reopen', - onClick: () => { - showReopenGoalModal(id); - }, - }); - } + const menuItems = []; - // Add edit button if user has permissions or if the goal is closed. - createdMenuItems.push({ - label: goalStatus === 'Closed' || !hasEditButtonPermissions ? 'View' : 'Edit', + if (goalStatus === 'Closed' && hasEditButtonPermissions) { + menuItems.push({ + label: 'Reopen', + onClick: () => { + showReopenGoalModal(id); + }, + }); + menuItems.push({ + label: 'View', + onClick: () => { + history.push(viewLink); + }, + }); + } else if (hasEditButtonPermissions) { + menuItems.push({ + label: 'Edit', onClick: () => { history.push(editLink); }, }); + } else { + menuItems.push({ + label: 'View', + onClick: () => { + history.push(viewLink); + }, + }); + } - return createdMenuItems; - }; - const menuItems = determineMenuItems(); - - const contextMenuLabel = `Actions for goal ${id}`; const canDeleteQualifiedGoals = (() => { if (isAdmin(user)) { return true; @@ -218,16 +224,16 @@ function GoalCard({
{ !hideCheckbox && ( - + )}
{ !hideGoalOptions && ( - + )}
- Merged - + + Merged + )}

@@ -356,5 +362,3 @@ GoalCard.defaultProps = { hideGoalOptions: false, erroneouslySelected: false, }; - -export default GoalCard; diff --git a/frontend/src/components/GoalForm/Form.js b/frontend/src/components/GoalForm/Form.js index 05387bb6a9..dc555be31b 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'; @@ -44,7 +45,6 @@ export default function Form({ objectives, setObjectives, setObjectiveError, - topicOptions, isOnApprovedReport, isOnReport, isCurated, @@ -54,7 +54,6 @@ export default function Form({ fetchError, goalNumbers, clearEmptyObjectiveError, - onUploadFiles, userCanEdit, source, setSource, @@ -101,17 +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 @@ -134,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} + setObjective(data, i)} - topicOptions={topicOptions} - onUploadFiles={onUploadFiles} goalStatus={status} userCanEdit={userCanEdit} /> @@ -302,10 +286,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 +305,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/GoalFormTitle.js b/frontend/src/components/GoalForm/GoalFormTitle.js new file mode 100644 index 0000000000..b2e10cff03 --- /dev/null +++ b/frontend/src/components/GoalForm/GoalFormTitle.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const GoalFormTitle = ({ goalNumbers, isReopenedGoal }) => { + 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/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) && ( 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/ObjectiveForm.js b/frontend/src/components/GoalForm/ObjectiveForm.js index 31dd2fda72..6a713a4ae9 100644 --- a/frontend/src/components/GoalForm/ObjectiveForm.js +++ b/frontend/src/components/GoalForm/ObjectiveForm.js @@ -1,30 +1,15 @@ -import React, { useMemo, useContext, useRef } 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 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'; import FormFieldThatIsSometimesReadOnly from './FormFieldThatIsSometimesReadOnly'; -const [ - objectiveTitleError, - objectiveTopicsError, - objectiveResourcesError, - objectiveSupportTypeError, -] = OBJECTIVE_ERROR_MESSAGES; +const [objectiveTitleError] = OBJECTIVE_ERROR_MESSAGES; export default function ObjectiveForm({ index, @@ -33,57 +18,20 @@ export default function ObjectiveForm({ objective, setObjective, errors, - topicOptions, - onUploadFiles, - goalStatus, userCanEdit, + goalStatus, }) { // the parent objective data from props const { title, - topics, - resources, status, - files, - supportType, + onAR, } = 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 { 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 = () => { @@ -98,180 +46,48 @@ 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 (

Objective summary

- { !isOnReport + { !onAR && 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, setObjectiveError: PropTypes.func.isRequired, setObjective: PropTypes.func.isRequired, objective: PropTypes.shape({ + onAR: PropTypes.bool.isRequired, + onApprovedAR: PropTypes.bool.isRequired, closeSuspendReason: PropTypes.string, closeSuspendContext: PropTypes.string, isNew: PropTypes.bool, @@ -306,12 +122,8 @@ 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, + 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/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..f90c56a3a0 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__/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(); + }); +}); 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( { const defaultObjective = { @@ -107,120 +101,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('Did you use any other TTA resources that are available as a link?'); - expect(label).toBeVisible(); - }); - - it('displays support type as read only when user cannot edit', async () => { - const removeObjective = jest.fn(); - const setObjectiveError = jest.fn(); - const setObjective = jest.fn(); - - renderObjectiveForm( - defaultObjective, - removeObjective, - setObjectiveError, - setObjective, - 'In Progress', - false, - ); - - expect(screen.getByText('Support type')).toBeVisible(); - expect(screen.getByText('Maintaining')).toBeVisible(); - expect(screen.queryAllByRole('combobox', { name: /support type/i }).length).toBe(0); - }); - - it('displays support type as read only when goal is closed', async () => { - const removeObjective = jest.fn(); - const setObjectiveError = jest.fn(); - const setObjective = jest.fn(); - - renderObjectiveForm( - defaultObjective, - removeObjective, - setObjectiveError, - setObjective, - 'Closed', - true, - ); - - expect(screen.getByText('Support type')).toBeVisible(); - expect(screen.getByText('Maintaining')).toBeVisible(); - expect(screen.queryAllByRole('combobox', { name: /support type/i }).length).toBe(0); - }); - - it('displays support type when goal is not closed and user has permission', async () => { - const removeObjective = jest.fn(); - const setObjectiveError = jest.fn(); - const setObjective = jest.fn(); - - renderObjectiveForm( - defaultObjective, - removeObjective, - setObjectiveError, - setObjective, - 'In Progress', - true, - ); - - expect(screen.getByText('Support type')).toBeVisible(); - const supportType = await screen.findByRole('combobox', { name: /support type/i }); - expect(supportType).toBeVisible(); - expect(screen.getByText('Maintaining')).toBeVisible(); - }); }); diff --git a/frontend/src/components/GoalForm/__tests__/ObjectiveTopics.js b/frontend/src/components/GoalForm/__tests__/ObjectiveTopics.js index c4cc9a27aa..1a57df76a4 100644 --- a/frontend/src/components/GoalForm/__tests__/ObjectiveTopics.js +++ b/frontend/src/components/GoalForm/__tests__/ObjectiveTopics.js @@ -19,14 +19,10 @@ describe('ObjectiveTopics', () => { { 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/ReadOnlyField.js b/frontend/src/components/ReadOnlyField.js index 0a74ff2f56..4b9cae73de 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 || !label) { + return null; + } + return ( <>

    {label}

    @@ -11,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 new file mode 100644 index 0000000000..0aac7773de --- /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.string, + })), +}; + +ReadOnlyGoalCollaborators.defaultProps = { + collaborators: [], +}; 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/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/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(); + }); +}); 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({ { @@ -284,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); @@ -327,33 +337,35 @@ 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/frontend/src/pages/RecipientRecord/index.js b/frontend/src/pages/RecipientRecord/index.js index 5f1eff335c..4f29bc68ec 100644 --- a/frontend/src/pages/RecipientRecord/index.js +++ b/frontend/src/pages/RecipientRecord/index.js @@ -22,6 +22,7 @@ import CommunicationLog from './pages/CommunicationLog'; import CommunicationLogForm from './pages/CommunicationLogForm'; import ViewCommunicationLog from './pages/ViewCommunicationLog'; import { GrantDataProvider } from './pages/GrantDataContext'; +import ViewGoals from './pages/ViewGoals'; export function PageWithHeading({ children, @@ -283,6 +284,15 @@ export default function RecipientRecord({ match, hasAlerts }) { )} /> + ( + + )} + /> ( 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 new file mode 100644 index 0000000000..ac3a72d6c9 --- /dev/null +++ b/frontend/src/pages/RecipientRecord/pages/ViewGoals/index.js @@ -0,0 +1,269 @@ +import React, { + useEffect, + useState, + 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'; +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'; +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; + 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 goalDefaults = { + name: '', + endDate: null, + status: 'Draft', + grants: [], + 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 [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; + + // 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(), + ); + + if (!fetchedGoal) { + setFetchError(true); + return; + } + + // 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 && canView) { + setAppLoadingText('Loading goal'); + setIsAppLoading(true); + fetchGoal(); + } + }, [fetchAttempted, isAppLoading, recipient.id, setAppLoadingText, setIsAppLoading, canView]); + + if (!canView) { + return ( + + You don't have permission to view this page + + ); + } + + if (fetchError) { + return ( + + There was an error fetching your goal + + ); + } + + const { + collaborators, + isReopenedGoal, + goalNumbers, + source, + objectives, + endDate, + name: goalName, + grants, + } = goal; + + return ( + <> + + + Back to RTTAPA + + +

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

    + + +
    + +

    Goal summary

    + + + {grants + .map((grant) => `${grant.recipient.name} ${grant.numberWithProgramTypes}`) + .join('\n')} + + + {goalName} + + + {}} + validate={() => {}} + errors={{}} + selectedGrants={grants} + isCurated={goal.isCurated} + goalTemplateId={goal.goalTemplateId} + /> + + + {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) => ( + + +
    +
    + ))} +
    + )} +
    + ))} + +
    + + ); +} + +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/createOrUpdateGoals.test.js b/src/goalServices/createOrUpdateGoals.test.js index a992e9971f..448145cc90 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, - }, - ], }, ], }, @@ -212,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'); @@ -246,7 +198,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,312 +207,15 @@ 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'); 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/extractObjectiveAssociationsFromActivityReportObjectives.ts b/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts new file mode 100644 index 0000000000..b977533326 --- /dev/null +++ b/src/goalServices/extractObjectiveAssociationsFromActivityReportObjectives.ts @@ -0,0 +1,22 @@ +import { + IActivityReportObjectivesModelInstance, + 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 | ICourseModelInstance, + ) => a.toJSON())).flat(); +} 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..9c47a5495f --- /dev/null +++ b/src/goalServices/getGoalsForReport.ts @@ -0,0 +1,187 @@ +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, + GoalStatusChange, + 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: { + 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 1=1 + )`), 'prompts'], + ], + }, + include: [ + { + model: GoalStatusChange, + as: 'statusChanges', + attributes: ['oldStatus'], + required: false, + }, + { + 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/goalByIdAndRecipient.test.js b/src/goalServices/goalByIdAndRecipient.test.js deleted file mode 100644 index 13c781ea54..0000000000 --- a/src/goalServices/goalByIdAndRecipient.test.js +++ /dev/null @@ -1,374 +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 { goalByIdAndRecipient, saveGoalsForReport, goalsByIdAndRecipient } from './goals'; -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 goalByIdAndRecipient(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 goalByIdAndRecipient(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 goalByIdAndRecipient(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/goals.alt.test.js b/src/goalServices/goals.alt.test.js index a0b2d8a500..b42b61b6ee 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, @@ -451,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, @@ -464,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 5c16912d85..fd745ad53a 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,14 +7,11 @@ import { } from '@ttahub/common'; import { processObjectiveForResourcesById } from '../services/resource'; import { - CollaboratorType, Goal, - GoalCollaborator, GoalFieldResponse, GoalTemplate, GoalResource, GoalStatusChange, - GoalTemplateFieldPrompt, Grant, Objective, ObjectiveCourse, @@ -23,24 +19,16 @@ import { ObjectiveFile, ObjectiveTopic, ActivityReportObjective, - ActivityReportObjectiveTopic, - ActivityReportObjectiveFile, - ActivityReportObjectiveResource, - ActivityReportObjectiveCourse, sequelize, - Recipient, Resource, ActivityReport, ActivityReportGoal, ActivityRecipient, - ActivityReportGoalFieldResponse, Topic, Course, - Program, + GoalTemplateFieldPrompt, + ActivityReportGoalFieldResponse, File, - User, - UserRole, - Role, } from '../models'; import { OBJECTIVE_STATUS, @@ -67,251 +55,19 @@ import { } from '../services/goalSimilarityGroup'; import Users from '../policies/user'; import changeGoalStatus from './changeGoalStatus'; +import goalsByIdAndRecipient, { + OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR, +} from './goalsByIdAndRecipient'; +import getGoalsForReport from './getGoalsForReport'; +import { reduceGoals } from './reduceGoals'; +import extractObjectiveAssociationsFromActivityReportObjectives from './extractObjectiveAssociationsFromActivityReportObjectives'; +import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; 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,466 +215,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.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 @@ -971,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', @@ -997,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, @@ -1066,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) => { @@ -1170,90 +497,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: [ @@ -1459,15 +702,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] @@ -1480,6 +717,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, @@ -1488,12 +726,7 @@ export async function createOrUpdateGoals(goals) { }); if (objective) { - return { - ...objective.dataValues, - topics, - resources, - files, - }; + return objective.toJSON(); } } @@ -1501,6 +734,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, @@ -1513,6 +747,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, @@ -1526,60 +761,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; })); @@ -2094,6 +1297,7 @@ async function createObjectivesForGoal(goal, objectives, report) { courses, closeSuspendReason, closeSuspendContext, + createdHere: objectiveCreatedHere, ...updatedFields } = objective; @@ -2157,6 +1361,7 @@ async function createObjectivesForGoal(goal, objectives, report) { closeSuspendContext, index, supportType, + objectiveCreatedHere, }; })); } @@ -2285,6 +1490,7 @@ export async function saveGoalsForReport(goals, report) { ttaProvided, supportType, courses, + objectiveCreatedHere, } = savedObjective; // this will save all our objective join table data @@ -2315,6 +1521,7 @@ export async function saveGoalsForReport(goals, report) { ttaProvided, order: index, supportType, + objectiveCreatedHere, }, ); })); @@ -2418,157 +1625,6 @@ export async function updateGoalStatusById( context: closeSuspendContext, }))); } - -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 1=1 - )`), '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/goalsByIdAndRecipient.ts b/src/goalServices/goalsByIdAndRecipient.ts new file mode 100644 index 0000000000..f335274869 --- /dev/null +++ b/src/goalServices/goalsByIdAndRecipient.ts @@ -0,0 +1,260 @@ +import db from '../models'; +import { CREATION_METHOD } from '../constants'; +import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; +import { reduceGoals } from './reduceGoals'; +import { + IGoalModelInstance, + IObjectiveModelInstance, +} from './types'; +import extractObjectiveAssociationsFromActivityReportObjectives from './extractObjectiveAssociationsFromActivityReportObjectives'; + +const { + Goal, + GoalCollaborator, + GoalFieldResponse, + GoalTemplate, + GoalStatusChange, + GoalTemplateFieldPrompt, + Grant, + Objective, + ActivityReportObjective, + sequelize, + Recipient, + Resource, + ActivityReportGoal, + ActivityReportGoalFieldResponse, + Topic, + Program, + File, + User, + UserRole, + Role, + CollaboratorType, + Course, +} = db; + +export const OBJECTIVE_ATTRIBUTES_TO_QUERY_ON_RTR = [ + 'id', + 'title', + 'status', + 'goalId', + 'onApprovedAR', + 'onAR', + 'rtrOrder', +]; + +const OPTIONS_FOR_GOAL_FORM_QUERY = (id: number[] | number, recipientId: number) => ({ + attributes: [ + 'endDate', + 'name', + 'status', + 'source', + 'onAR', + 'onApprovedAR', + 'id', + [sequelize.col('grant.regionId'), 'regionId'], + [sequelize.col('grant.recipient.id'), 'recipientId'], + 'goalTemplateId', + [sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`), 'isCurated'], + 'rtrOrder', + 'createdVia', + 'goalTemplateId', + ], + 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: ['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', 'url'], + through: { + attributes: [], + }, + }, + { + model: Course, + as: 'courses', + attributes: ['name'], + through: { + attributes: [], + }, + }, + ], + }, + ], + }, + { + model: Grant, + as: 'grant', + attributes: [ + 'id', + 'number', + 'regionId', + 'recipientId', + 'numberWithProgramTypes', + ], + include: [ + { + attributes: ['programType'], + model: Program, + as: 'programs', + }, + { + attributes: ['id', 'name'], + 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 default async function goalsByIdAndRecipient(ids: number | number[], recipientId: number) { + const goals = await Goal + .findAll(OPTIONS_FOR_GOAL_FORM_QUERY(ids, recipientId)) as IGoalModelInstance[]; + + const reformattedGoals = goals.map((goal) => ({ + ...goal, + isReopenedGoal: wasGoalPreviouslyClosed(goal), + objectives: goal.objectives + .map((objective: IObjectiveModelInstance) => ({ + ...objective.toJSON(), + topics: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'topics', + ), + courses: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'courses', + ), + resources: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'resources', + ), + files: extractObjectiveAssociationsFromActivityReportObjectives( + objective.activityReportObjectives, + 'files', + ), + })), + })); + + return reduceGoals(reformattedGoals); +} 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/reduceGoals.ts b/src/goalServices/reduceGoals.ts new file mode 100644 index 0000000000..6005329501 --- /dev/null +++ b/src/goalServices/reduceGoals.ts @@ -0,0 +1,542 @@ +import { uniq, uniqBy } from 'lodash'; +import moment from 'moment'; +import { auditLogger } from '../logger'; +import wasGoalPreviouslyClosed from './wasGoalPreviouslyClosed'; +import { + IGoalModelInstance, + IGoal, + IObjectiveModelInstance, + IFile, + ITopic, + IResource, + ICourse, + IReducedGoal, + IReducedObjective, + IPrompt, +} from './types'; + +// this is the reducer called when not getting objectives for a report, IE, the RTR table +export function reduceObjectives( + newObjectives: IObjectiveModelInstance[], + currentObjectives: IReducedObjective[], +) { + // objectives = accumulator + // we pass in the existing objectives as the accumulator + const objectivesToSort = newObjectives.reduce(( + objectives: IReducedObjective[], + objective, + ) => { + const exists = objectives.find((o) => ( + o.title.trim() === objective.title.trim() && o.status === objective.status + )) as IReducedObjective | undefined; + + const { + id, + otherEntityId, + title, + status, + topics, + resources, + files, + courses, + } = objective; + + 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; + } + + const newObjective = { + id, + otherEntityId, + title, + status, + topics, + resources, + files, + 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) => { + 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. + */ +type IAcceptableModelParameter = ITopic | IResource | ICourse; +const reduceRelationThroughActivityReportObjectives = ( + objective: IObjectiveModelInstance, + join: string, + relation: string, + exists = {}, + uniqueBy = 'id', +) => { + const existingRelation = exists[relation] || []; + return uniqBy([ + ...existingRelation, + ...(objective.activityReportObjectives + && objective.activityReportObjectives.length > 0 + ? objective.activityReportObjectives[0][join] + .map((t: IAcceptableModelParameter) => t[relation].dataValues) + .filter((t: IAcceptableModelParameter) => t) + : []), + ], (e: string) => e[uniqueBy]); +}; + +export function reduceObjectivesForActivityReport( + newObjectives: IObjectiveModelInstance[], + 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 : 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.objectiveCreatedHere === objectiveCreatedHere + )); + + 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: IFile) => 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, + 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 + // 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: boolean, + newPrompts:IPrompt[] = [], + promptsToReduce:IPrompt[] = [], +) { + 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, + } as IPrompt; + + 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: IGoalModelInstance[], + forReport = false, +): IReducedGoal[] { + const objectivesReducer = forReport ? reduceObjectivesForActivityReport : reduceObjectives; + + 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: IGoalModelInstance, + ) { + // 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: IReducedGoal[], currentValue: IGoalModelInstance) => { + 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 as IGoal), + ...getGoalCollaboratorDetails('Linker', currentValue.dataValues as IGoal), + } as { + goalNumber: string; + goalCreatorName: string; + goalCreatorRoles: string; + }, + ], 'goalCreatorName'); + + existingGoal.isReopenedGoal = wasGoalPreviouslyClosed(existingGoal); + if (forReport) { + existingGoal.prompts = existingGoal.prompts || []; + existingGoal.prompts = reducePrompts( + forReport, + currentValue.dataValues.prompts || [], + (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; + } + + const endDate = (() => { + const date = moment(currentValue.dataValues.endDate, 'YYYY-MM-DD').format('MM/DD/YYYY'); + + if (date === 'Invalid date') { + return ''; + } + + return date; + })(); + + const { source: sourceForReport } = currentValue.dataValues; + const promptsForReport = reducePrompts( + forReport, + currentValue.dataValues.prompts || [], + [], + ); + + let sourceForRTR: { [key: string]: string }; + let sourceForPrompts: { [key: string]: IPrompt[] }; + + if (!forReport) { + sourceForRTR = { + [currentValue.grant.numberWithProgramTypes]: sourceForReport, + }; + sourceForPrompts = { + [currentValue.grant.numberWithProgramTypes]: promptsForReport, + }; + } + + const goal = { + ...currentValue.dataValues, + isCurated: currentValue.dataValues.isCurated, + goalNumber: currentValue.goalNumber || `G-${currentValue.dataValues.id}`, + grantId: currentValue.grant.id, + collaborators: currentValue.collaborators, + id: currentValue.dataValues.id, + name: currentValue.dataValues.name, + endDate, + activityReportGoals: currentValue.activityReportGoals, + status: currentValue.dataValues.status, + regionId: currentValue.grant.regionId, + recipientId: currentValue.grant.recipientId, + goalTemplateId: currentValue.dataValues.goalTemplateId, + createdVia: currentValue.dataValues.createdVia, + source: forReport ? sourceForReport : sourceForRTR, + prompts: forReport ? promptsForReport : sourceForPrompts, + isNew: false, + onAR: currentValue.dataValues.onAR, + onApprovedAR: currentValue.dataValues.onApprovedAR, + rtrOrder: currentValue.dataValues.rtrOrder, + isReopenedGoal: wasGoalPreviouslyClosed(currentValue), + goalCollaborators: currentValue.goalCollaborators, + objectives: objectivesReducer( + currentValue.objectives, + ), + goalNumbers: [currentValue.goalNumber || `G-${currentValue.dataValues.id}`], + goalIds: [currentValue.dataValues.id], + grant: currentValue.grant.dataValues, + 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], + 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; + }, + ]; + + goal.collaborators = goal.collaborators.filter( + (c: { goalCreatorName: string }) => 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/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/goalServices/types.ts b/src/goalServices/types.ts new file mode 100644 index 0000000000..dfc0013287 --- /dev/null +++ b/src/goalServices/types.ts @@ -0,0 +1,302 @@ +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 ITopicModelInstance extends ITopic { + dataValues?: ITopic; + toJSON?: () => ITopic; +} + +interface IFile { + key: string; + originalFileName: string; + url: { + url: string; + } +} + +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; +} + +interface IActivityReportObjective { + id: number; + objectiveId: number; + activityReportId: number; + createdAt: Date; + updatedAt: Date; + name: string; + status: string; + endDate: string; + isActivelyEdited: boolean; + source: string; + arOrder: number; + objectiveCreatedHere: boolean | null; + supportType: string; + ttaProvided: string; + closeSuspendReason: string; + closeSuspendContext: string; + activityReportObjectiveTopics: { + topic: ITopic; + }[]; + activityReportObjectiveResources: { + key: number; + resource: IResource; + }[]; + activityReportObjectiveFiles: { + file: IFileModelInstance; + }[]; + activityReportObjectiveCourses: { + course: ICourse; + }[]; +} + +interface IActivityReportObjectivesModelInstance 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; + } + } + goalId?: number; +} + +interface IGrantModelInstance extends IGrant { + dataValues?: IGrant +} + +interface IActivityReportGoal { + id: number; + goalId: number; + activityReportId: number; + createdAt: Date; + updatedAt: Date; + name: string; + status: string; + endDate: string; + isActivelyEdited: boolean; + source: string | { + [key: string]: string; + }; + closeSuspendReason: string; + closeSuspendContext: string; + originalGoalId: number; +} + +interface IObjective { + id: number; + title: string; + status: string; + goalId: number; + onApprovedAR: boolean; + onAR: boolean; + rtrOrder: number; + 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 { + 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 IGoalCollaborator { + id: number; + collaboratorType: { + name: string; + mapsToCollaboratorType: string; + }; + user: { + name: string; + userRoles: { + id: number; + role: { + name: string; + }; + }[]; + }; +} + +interface IGoal { + id: number; + name: string; + endDate: string; + isCurated: boolean; + grantId: number; + createdVia: string; + source: string; + goalTemplateId: number; + onAR: boolean; + onApprovedAR: boolean; + prompts: IPrompt[]; + activityReportGoals: IActivityReportGoal[]; + objectives: IObjective[]; + grant: IGrantModelInstance; + status: string; + goalNumber: 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; + }[]; +} + +interface IReducedGoal { + id: number; + name: string; + endDate: string; + status: string; + regionId: number; + recipientId: number; + goalTemplateId: number; + createdVia: string; + source: { + [key: string]: string; + } | string; + onAR: boolean; + onApprovedAR: boolean; + isCurated: boolean; + rtrOrder: number; + goalCollaborators: IGoalCollaborator[]; + objectives: IReducedObjective[]; + prompts : { + [x: string]: IPrompt[]; + } | IPrompt[]; + statusChanges?: { oldStatus: string }[]; + goalNumber: string; + goalNumbers: string[]; + goalIds: number[]; + grant: IGrant; + grants: IGrant[]; + grantId: number; + grantIds: number[]; + isNew: boolean; + isReopenedGoal: boolean; + collaborators: { + goalNumber?: string; + goalCreatorName: string; + goalCreatorRoles: string; + }[]; + activityReportGoals?: IActivityReportGoal[]; +} + +interface IGoalModelInstance extends IGoal { + dataValues?: IGoal + toJSON?: () => IGoal; +} + +export { + IGrant, + IPrompt, + ICourse, + ITopic, + IResource, + IFile, + IActivityReportGoal, + IActivityReportObjective, + IObjective, + IGoal, + // -- model version of the above -- // + IGoalModelInstance, + IGrantModelInstance, + ICourseModelInstance, + ITopicModelInstance, + IResourceModelInstance, + IFileModelInstance, + IObjectiveModelInstance, + IActivityReportObjectivesModelInstance, + // -- after going through reduceGoals -- // + IReducedObjective, + IReducedGoal, +}; diff --git a/src/goalServices/wasGoalPreviouslyClosed.ts b/src/goalServices/wasGoalPreviouslyClosed.ts new file mode 100644 index 0000000000..408a4fedcd --- /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/migrations/20240520000000-merge_duplicate_args.js b/src/migrations/20240520000000-merge_duplicate_args.js new file mode 100644 index 0000000000..b54dc906b9 --- /dev/null +++ b/src/migrations/20240520000000-merge_duplicate_args.js @@ -0,0 +1,237 @@ +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 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. + -- 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 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 ( + 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 + AND target_arg != 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 + DROP TABLE IF EXISTS relinked_argrs; + 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 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 ( + 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 + ; + + END + $$ + ; + -- Actually call the function + SELECT dedupe_args(); + + 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; + `, { 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..18278efb54 --- /dev/null +++ b/src/migrations/20240520000001-create-timeseries-and-root-causes.js @@ -0,0 +1,373 @@ +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 + -- 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 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 + 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 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 + 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 + -- 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( + 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 + -- 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 + ,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 + -- Add in the current value from the live table as a final record + 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 }); + + 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. 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 + SELECT + arg.id argid, + gfrt."goalId" gid, + gfrt.data_id gfrid, + COALESCE(gfrt."goalTemplateFieldPromptId", gfr."goalTemplateFieldPromptId") "goalTemplateFieldPromptId", + COALESCE(gfrt.response, gfr.response) response + FROM "ActivityReports" ar + JOIN "ActivityReportGoals" arg + ON ar.id = arg."activityReportId" + 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 + INSERT INTO "ActivityReportGoalFieldResponses" ( + "activityReportGoalId", + "goalTemplateFieldPromptId", + response, + "createdAt", + "updatedAt" + ) + SELECT + argid, + "goalTemplateFieldPromptId", + response, + NOW(), + NOW() + FROM argfrs_to_insert + ; + `, { transaction }); + }); + }, + + 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 }); + }); + }, +}; 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 + }, + ), +}; diff --git a/src/migrations/20240529190616-add-created-here-column-to-activity-report-objectives.js b/src/migrations/20240529190616-add-created-here-column-to-activity-report-objectives.js new file mode 100644 index 0000000000..19ea2d7741 --- /dev/null +++ b/src/migrations/20240529190616-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, 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 new file mode 100644 index 0000000000..fe299b794d --- /dev/null +++ b/src/queries/monthly-delivery-report.sql @@ -0,0 +1,250 @@ +/** +* This query collects all the Monitoring goals used on approved reports within the defined time range. +* +* 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: +* - 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 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 ( + SELECT + a.id, + a."userId", + a.duration, + a."deliveryMethod", + a."calculatedStatus", + a."startDate" + FROM "ActivityReports" a + WHERE a."calculatedStatus" = 'approved' + -- 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('ssdi.regionIds', true), ''),'[]')::json) AS value + )) + -- 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('ssdi.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 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('ssdi.regionIds', true), ''),'[]')::json) AS value + )) + -- 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('ssdi.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('ssdi.regionIds', true), '') IS NULL + OR u."homeRegionId" in ( + SELECT value::integer AS my_array + 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)) + ), + 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 + + 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 + + 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, + 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" + 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 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('ssdi.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", + 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 ( + 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; 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..e037da474e 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(); @@ -311,6 +185,7 @@ describe('createGoals', () => { goals: [{ goalId: 2, recipientId: 2, + regionId: 2, }], }, session: { @@ -339,6 +214,7 @@ describe('createGoals', () => { goals: [{ goalId: 2, recipientId: 2, + regionId: 2, }], }, session: { 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, 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..905d6d84ea 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'; @@ -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'); 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) { 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 }, diff --git a/src/services/reportCache.js b/src/services/reportCache.js index 3411eb0f63..43a0c3e44a 100644 --- a/src/services/reportCache.js +++ b/src/services/reportCache.js @@ -271,6 +271,7 @@ const cacheObjectiveMetadata = async (objective, reportId, metadata) => { 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, 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); diff --git a/tests/api/goals.spec.ts b/tests/api/goals.spec.ts index 47ecb4d692..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,71 +77,9 @@ test('get /goals?goalIds[]=&reportId', async ({ request }) => { newStatus: Joi.string(), })), isReopenedGoal: Joi.boolean(), - })); - - 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); }); diff --git a/tests/api/recipient.spec.ts b/tests/api/recipient.spec.ts index 715758946e..ac56a3066c 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(), @@ -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({ @@ -213,8 +214,10 @@ 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(), @@ -222,7 +225,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({ diff --git a/tests/e2e/activity-report.spec.ts b/tests/e2e/activity-report.spec.ts index 95db8202bf..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,24 +448,7 @@ 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(); + await page.getByRole('link', { name: 'Back to RTTAPA' }).click(); // Check g2 await page.getByText('g2', { exact: true }).locator('..').locator('..').locator('..') @@ -478,25 +462,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 +494,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(); @@ -575,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 2cfa31ae3a..c79ea51f1d 100644 --- a/tests/e2e/recipient-record.spec.ts +++ b/tests/e2e/recipient-record.spec.ts @@ -37,28 +37,6 @@ 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 - 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,17 +77,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(); - await page.getByRole('button', { name: 'Save and continue' }).click(); await page.getByRole('button', { name: 'Submit goal' }).click(); // verify the goal appears in the table 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"}}}