diff --git a/README.md b/README.md index 3ce25f1288..27610c0c00 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ If you are already using git hooks, add the .githooks/pre-commit contents to you It's important that our tests fully clean up after themselves if they interact with the database. This way, tests do not conflict when run on the CI and remain as deterministic as possible.The best way to do this is to run them locally in an isolated environment and confirm that they are sanitary. With that in mind, there a few "gotchas" to remember to help write sanitary tests. -- ```Grant.destroy``` needs to run with ```individualHooks: true``` or the related GrantNumberLink model prevents delete. +- ```Grant.destroy``` needs to run with ```individualHooks: true``` or the related GrantNumberLink model prevents delete. Additionally, the hooks on destroy also update the materialized view (GrantRelationshipToActive). - When you call ```Model.destroy``` you should be adding ```individualHooks: true``` to the Sequelize options. Often this is required for proper cleanup. There may be times when this is undesirable; this should be indicated with a comment. - Be aware of paranoid models. For those models: force: true gets around the soft delete. If they are already soft-deleted though, you need to remove the default scopes paranoid: true does it, as well as Model.unscoped() - There are excellent helpers for creating and destroying common Model mocks in ```testUtils.js```. Be aware that they take a scorched earth approach to cleanup. For example, when debugging a flaky test, it was discovered that ```destroyReport``` was removing a commonly used region. diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index a255266227..ec5698656b 100644 --- a/docs/logical_data_model.encoded +++ b/docs/logical_data_model.encoded @@ -1 +1 @@ -xLt_RzqsalzTVuNW_Q5jyBBOjjS3pjWxhECuRN29OzXE5zkYC6Y9Ve-DHBubAQTkh__xWQI-a1GbaPAUaxJyoNuIoHbzE1mEPyZXFnW7b5TbaQJfPocu9oXFzvJS5h1awNl4Tod0smBcQKR9UvRUGSYJD6Nl4Du32igqG1ZoXprC2UKxOhpA2i1O-bkIfcdwZD0SqbDI49h-vUkl__Fal_wcFR_UeUmTXeobNoLf-hjavLJKXYn9wtPECexk4N-uX1dQ8uWv-or9dwGeJuzJX3dSGfeUft_VGWmfu33_doJTKe3vIxF0vTcBiykpyzEpg_JeM_6U7VqLvKbA-0xICymHVnYfyQOjXM1TyKaAMixsX8xHEp4AjlKp2WN39pmzJZag8UEWt2linS_qLu9XUfnYylxpBqJvQf_xb_yzIMpy1F6J_PwIUqYdGLp-VHM3TAJMMmRfus2Hvh20KwMJ51Ckwd3u2qzInW6lM7gSGboLGsYUag80juG2JlvSY0xWyG8Ly35mk4C57AabsGZ1mVVxXxxxMmAA5mWM-ILaUuBP31144zv0YCBj1su9AAvXkIy0XvA92qhA_QZ-SzSu36GfhJ6fXzQFFusXU0aPgMZsg03IckP8xPQjmq2a6fTTqQWS1PFuJ_wy6O7gngZ-Mv9YK2hSvVD4KhgZuc7Tf7zhV_-_fq_7kccu9wTxa52qHHWK4prdzDLIpR4DeBLk7sS5OV2gOdk8yrE2nL1e9nlztIK4ZE1cuaaXpfy3EIy98acAcFmoM3oSvOX3yIctX1GGY2fte62op2NdO0_QHeH0T3ERxBdsNJlHtGEQxG3z_Ajlllb2avccti2WVNFxCvGCdPAApb5yRGFUdTdMw7fAqXvfe6RkGywDBTVp9iarOFhOfisMm_bQVmrOFa7ztqUkDmdewfyNuWy75vJSenpc0_F0LAkEoQ1VPQAhhyaUPH-cdxaWFL9ViVP11e3_PEa3HF_JbqwRP1RLmoxg07qZV0W6Fr_EdcVRBpnLJ7y7AUpcri5fabLx0TIry8GqJSdnrjBKXjt4L3fV2uPtw2UeyuPLtTdfg_yzRL-_PJ1FKf3dY2E2GN5Ek70dPKeMn38Vkogq-jAj6_rLueGcdExT0Z3-Lc7uxC5lkH_Y6A1mYmWUxNXHu956yPDKUAEG78aTErybceJNVhzwZ9dZp-hcTjG1vUUQDCf1aLLY6PnV6VwUHbewpI508FII5Dqups61xY5zU0tvZkGzrlAA0gu-B8SPAa7n-TURBxzmYMktDExR-wKG6bdaPqRmhH0FlAt0riP2L2xW5cHAP4nUd3AlKSLLUGlfcxiBG3dAq5xuHxiEIT8zs4A14ApP44-CaZOdUqgs59IYprolQQDy-o_-NK76fH1OcbRugmreM7LR8YPan-ey-O9pQ2B90Tr2Va7seiE5W3l8InF1-O83X_o9D_U8yWeKpsjXS4bfg-zrVw4GfN1DhxVWhVKDDsMh1iZeOa2oTrXRI6TB2iea6YNOisioiigp6cpulmS5ob-C8lPzNMIkrgkNsARCFX7ZO0-sWCCt-3MaMsWirpcju86SsHh8PLbXb4cHVqMslglFpAetkhWsYtv4wyqUQnvVJfi9PtIAR89HXtBDYt8vuA3J--7VxkUGcHnKYrmdXklfIReNaptZx0r-koy8SBgl3ZiWpkuXvaelNg22y7EWx8TJY3caifyyX0HQhOnqlKsmXH8Rb4TQC5byb6YGoTkvMxc5Mdg2OLTJWttZfwZq6L6pMiJaxFtXfsylBy_Ehs-kNxu-kxe-UNtX6d_aFJOHTvlJ0T0uoFODlZt9x0zh4VDMvvugHwZiA_GhDB_c7NnmT7_ZaX0y34Y86KKDLc727HyQOyqi8s1quXgm5qAY19zQbBj0-DqpaBrhvSj0EpYA6GBLXujK4dpIB0WlTQ-EV6CzMHpL38rZXa1uIB-D6oHzNZR12fsnlNm-IoERKx5CP_HIFBn29Ie0HVpXMqgglLy8Crp-BJ8JLQtI1tB5mL-oeifX_lBkdF2Jl4JOqSc_Zu0r3THTQA1tdXM-BYvAkNy7s4iiLv1W8DxQys1POCb9y7G7yDP37uJC9sj7i-VFFRQ_3CFtLswFEIdoWJUjKqWrCMGjoXVIFT7tnXYukoOEpn7TLFMSci9LBSt5W32jhBraKLiicO44xuq6eAhTZghUdVXy8q2nGxWZG_QDCwnn1jQoNZ-LxZudhbrChu7XmEWIcw1FD6fs7uMRgWsfqCb_BXwVH1372SpKnAxFvXELsrczLyxLDTDTSeZmI5iE4Tn62vebqGwmdJxeQqxFJSU9_lWroT4TMgqJPRofZWTCzFJWojtFN9elxCf9TG4en3p4Z0ZP2lunMSer9zyZe8URF9Pt_VrYBoWfhqf0ETj1oAyvk2W0rfSyuzl5A7V7sPTk8vF8dykuqok4twsxgz-_AxST6y4pRPZZKV7N-qnf4NqRy6sX2kfmbhJXC6mWNEOa11XNe7XHuHpXxMk2-QxNP9OZryYgEyFrgXiJmtqfvG2w_LZotTKUPWWKYF6p4g1Ygf2p8OHbD4_E0vCSwOMjpIfrT7MAfLun9rDwO7L4_ulexod9dVKT8ryJUrBjFuWJRsfzRQoe_q1kFEc-S2iRX_qPf5N3C3sGfUtdSV0dRuMe_7G_OXVLRO4oOu_O3b2O_nD9fMMU6teflVhF0GU-7diL1tWn7a9zsHOA1jrmpQV7wMSldv-TNxzyyVBi-MV7nqVI_BQulb5rZlnHaX5WV4UwO8PXxLAO4PgVaafj8gcBi6m7Iz0NRErZFVnD_g5dXVGaok7XM_8UYnZd2HV6kxwX9ZrE_rQl-3F8s701_qwQsq5y_sAqZopqrnntERn36Eb-1XbbWnZS5d0WCdp7zrUGSuItHXrh20i9ogiPlXCXDgy1UzTMt0FUX-8rG0MavdQ2Mn4Bu-xjEzhCRU_dcHklRESMA6P-6RwtDoOzINycjuwzO4_4_YZX75bih578geAtMy9ObGbfrZIYZXZc40ESGH_OE4NRBQJj_iUsb3ughJRRqGaut1Bwe44-Ox-5N7BhFZF_UU5AmN1N5Kua24QSK8Ityg3pLLMMW2BeFLKRInNNNAqjgOx-UgJLELABWNi4tx_CtGrqp5tASaqhJQsZe0b6BX1FWqg_zVNBUnwv0enHGietT0im-jNUZldDvZ-yxIrO4hKZuahj-EurOqni23DEgsoy1fKQeDauE6s1IuIU1wqvkmvzWUIIojikl5FbfoMbRhWHbRRUKIko44wtygqgQ8Yjg7VKpO8Z4tIF6nLXQ5fhwX-5xOIGBTMEysOQiPnr7uEC4mQdbxSPeHHAUNmEGVuAORZSir4Bi0VrrbUadq1SACdV8IP-Uzc2oqKvN0GLVmyZalY8klZEuAab-N68i9-mvP13MY8CZE2MCHune6BB_NqdQOuusuh2ftVkYdPnNzOEvP8S_oule-GG0sFcmlrJaUHL971jeRELvAE9t1K7E0SqVpoZXDKKlOIsOKyJrBho9sOCFw9i_k7Kua7rOLI4Y_hcKVL6N8R4POXZnRgYbhDNp8VNSMfvKN-EjnzgyQB-4_DcgNBNg8GVVAP_zX0i6qsvIVuVGt5hlR7YmB5auR76pH_7rj6_PsrJfuTLj7I2UyRlxEfYXRXghufTuVNsWgRB_cWh9dl3dUMHqiILL3mZ-iuwbP6LSv3jIXEcmD20wa33HWoGk7vg1OSC6klWTJLTRp5-U5qNudHOrLs0k0Xkcv-wP9HyV0i1n4qFhTyesbOe5v7SMVAhdpnfEtlUkUwFxKns-FOeYx6bn_y7llEGUwkwJu6_y2iEzo7dAN1TPwphwYAVrZcA7XZzCnI4kyvGJ5IKvoTwPUmkU8oSPDuofUVfLZ-lvspLo4Yv5-h5EVDfUKqVV-lgl7HxVvmjh3DEbdqintQkhJDxnlCFS72dZwdVCHs_0pRwiPmvcIdNED6tu0Q7q55mw7C_sddQULaiPnyRkaEKyTDQ8VgKVOalnEa2kNjrklGvuLTYaU77H_rjNlBloRCewisoAEpDiIZio_3WMQgXmrEQBka6iYx9Awt4F3APpSk1VI7sRCWSVNxkYp0bpZ8oEi5UYVSVyUqP5-g0pWMaZ60CYwN6kjsUaVuNccbEGh-mTr2ati0k2FPo8gaqcSjs5yExEwNl9zqVq7dwIIaUbp3ByUrwiQVm3ltYIF9xvthS34_tkQUwBrm7_WvnB7M-iTOviAoSeMw4shoUemV976_-1G4DFJmsSkdJXr9jOmSDwMYksWeYwvauqV7PyR7u3JAHzFGUq0U4jpe0Mq_TYSSIgrsgjAfdb1gBcA532-ikRajtVbooXss2N3iHYEJs6SaVsktuJRubvqFdkSIAeX4y_3qA_iTzIe3mdD0SBTfQqffG-MBKDxNINa8Ep8HqpHC_1rw9IkWKIxUL8HAiUEVg3yab-p5auVVDlUjB7R7kZAqvooxGEDwcUrKKpjdCDXQwxrTsqG5innooZAAzSrIlR5bUGMbMQjaYVKzMXfmDP3qgO-Pch_21vRXFi3vhWl2sYolzsJ7npkSw9udhclb5PoSDxJuUbLSPeL0cpWJPRbKj9zn2NrfsB075LtO-rDe6kh6jDIOuwsN33-vVnE590VsEeXuY7EEJJU99MaB6L9B-DZQdqges9tcRo61xZhVxi1jHj0d6U-Hcc9ifpChEUWye0j8SDC-kUvi1WK6XGqwAmtFVsoPxtCfspOUF_0aIzU7eD_UGKfJ3jST8rP6OgDASqZvKPiPxK5Im7pdGIVj8hApK5yM08-oWEm9zEvmhERmdls-pyAdrm0VITfoyWovRZhkM4hxTrsRJ3C1Z6e0TAU7bWI-tsJ5vTmeTK65Y-VQARDKoS7rFZTlFLO1j5pHEZzEJoykJV_tuKpElIF1kPAaZaHT0VC99nNU4IW-o8tD0yv6kYsISJg4TmV3ScG3A265hd8XuDS3tCc45OXnbhDb3VYDiYfjIN9PnGser9lIvo0jrKmBwJXOGSwHCGPka_wv0WVZ33M2iec8FoKqEsEwM0DecomA5QbLxmIb06E7jZTfgnRHtJU1SPrT6oUM3HYQR2qEM-coNNZo2c39BtL6d6VilvbwiyCF9ythrvm5AjrGajETx-E7a1Ieuf7xHzgh4pkMlt6arUjt03oUlncwN7_AE2RFzHYiXdvN53M2zHxSFlKSTa1DJYoZxYoHJLKeqv2DzfqE7eMLiO_Z-XfBtD-OAWnIvusczdBjXQEleYLUJzyT7cFem3MmQYL3reRAyDX6YFaHZ5pD4lDnuEJpy4IetL16YKOY6RpYFQ8sW5VMwFrPHMONXPNia8rWZO4MSHd6AOyyBfWYF1S_8NRuH81iWwthV2902QFHgtWYI2wG8K0-Wk2fnmFBy-IiFIFA03ykj3nCtP0EStt2mxVcUHM49OljfWz-iZi3Se1pA8opzRPBglJFUfZAbtpY3CXFu15s374NA1La4o08eJc0EK4RkC8u0DG15x_OKGd6PEWBE-Dsz4QS1d296BepSj_L6W1g0ng_LZJZPE8qbCuXesMZ5USu3f0YE0vm8YWtAu0sh4PyMduXA3vNX-peZGYr2KNW6eOceMAWCNluPuUdWYT0CGXDXiM09kO3pJuTCo4MRVpfu9c_VZ3YPE0tS6nbXSiBak9Gm8ZM2bFi6lGZPD6HHn2RnYbBbWv8Z5Nv4QWYfgAk2i8fAQXAeYD0WGkuRrY9A9af3rxT5d5muHM7o02bET85GnN4tXLC4Q12WWPkr8ankc2945aHnvtuWH14U6vxXUxOYG3f04HWBcAbRiY9A1afn_zuKGX4P6ndtRJyH706EytQX28s4Pl1geOYFXa-K6mkNc0Xp69bjL3GkMEAYmGsCumQTY4o3J0J50iM-aoUY8b6PkkKdmkI6QU8Ye0YR6xO8aGtHo8r24I65KFz2eeYCG2PQFovK71SGHM0QO8RlBueOMHX5R1pO3D-Q535gC8gWMA3qh_A9k0XSt3VwH6mKDlOTHNzgN9BFqXF6I8QaxcL9aiXrUdhqvL7wzEcFVxajGFPkioy0mXk-Gzax31dxxwz-UlfK5j6E00k-sbyRUJ5jeawV-JPcvxr8ZzEjUBHHVxetIJ9oQuz5JLmF1dEBt6zfUeIFqbugwFHON4nbFjkaO91OuhYK8u3-r6qnwR55MnJPE9wPrLRdgdKLUpFKraY6ajlFPiY-rgF7KrjWC_IYhdb0tfeH4SgPh7T0lePxSfgtn-XJjVASmPogQ74sZw4cZjRZGYVU55g5ed51cujiysurQgR67FlGNkdLU72FaKrAkxUhqL8wSEexPQdLQ6hZOefmLtGrwiCZtIO-DAv3QBSRxTBZbErXXa2166-VRhhRnRLd9Xj9xzw1cJEvgdjStzzflzd8voyeANjr5d7GInSih1VhsSQmBWI5PIHx6P-anMGzN9ceU-TWUbpdExTUE8bR5mC4TRFZvWLfj9dFDxqdIfqq9iEAjaSc_viqDS8fToGwWLMa6i4Mlv9USlC04XwvvCKQH9JQg3_ZXHSnuuZf26MYFsKr5yurIlZ4SoSNAlfqiwpTdlMd4x1Cl51AGvAlIBYbFPJ6GCoswmNvirxQBM_S6UaKn5pFcmMb8nboe99svPokTRNrQkDwo9AXNBhhBlpsij56UhgrtGhK9-YvA8Ksx-HGWToysjGaro7KtwhT8xSTBRuA5xhHDKoyCxYMq6neCBFnPFbolsbvan8gV5gW65hcIyCEC8Qs6EQEXeAP6RAYDZrRQbxRFR5NZPj27zgetUkAMRENLzHIJhKq7-wUEXMJIjldWnemc8oUdhtz4gO_dTMkr5s0Gbpxeh8TAl9vQ1WBjjQShSTNS6lz3HXYRXDFtc2uKVzTloUgTVb83kFt9EdvGVs93kL94rRamAk-TtKL-cPw0uE--Mg0_cBVJUqwThXiWjjlHr_jZLRI5ToAgd0i_FFg__hJvpJLPh9jbdmBMxIF7IYBm7Yqj6tMjQdn6Mh13QvvD_sbBvlPlediHWSuqICU_irQcMIjM0aotzOqmjitszveD5XY9PWC6TDMpUEKKxJTGWhpPm-RxaVYaIjkbYgnS_GPogINusdJVzdMV1mMsqnlBaQj8iqKA1MVEnFQ4ePQIRxh6dG0LCls-lH7-ooyjnRpqoOZ690XzNAZmtK0BfpKfwYk9AAjHB2n4JCa42inCJP0_4R6IemCzOIJj9xNQfKks7OD-lJ1Ole1DbdfefeuipAjRPvsJMy6ZTOLEHdLL6vUHXJLKcuckyZcnQomEYSk5jZ6oLKoaevg0GTENM6-6XYlQt8nW8EJhf4OQAtPQbHTjmLNvS8zMJPFBTYjL2shHo6myTGl736V9pyUy05fTX7CSujg4mwooYTT33OyRgkxi73zRDF-rxco76xM5Vsrn7uuwRHTnC-WlwV7HxBc7XU3MkTZdTxZCk9jrJOGp69rEggxSGTubzBht7VQzqiVdlv9UYwDfNuXqZRsuFGEpILczgzEf1EjfETcMUIXQJJ1HSzJepI7_JnfFJ5hE8OsFGzHeW4zFxGfy6ktY_HZcsmUbG5NjqR8wUgUHwinyFNMbJK_gpstAxb-g6-kIUfS1xELb69Qj65h8JQZRJEyrRMiA_LPxNRurWqsu8Qsv9o6cbP9Vxu9fw2bkj6QtdTFPxJPs2EL-Bj9TwI4gf6acyVNskzBw__7T_akf-lSeZf4tB4dTJa7FeINqHl6ThiOpQ4mj4cZ7uAUGyjbDNPq-bQZsbk_R5y-9MgZY_KckEqkkqVRA6qV1RVMdWcjHsDzOa2qcAZ44BDL1HFLJKtXsYArulQUPdkeljjdTPg3ktwSF3XB9tN_hRVRd8j_flQM1rCrHAqceScQVYGqKEJiqxOIF_be8yNGVf-e6MEDlUcY-MPNKljyOVlseaEdOIusbj6HUw4hlXFxKWl0-DjzBavxYIWMhOsSK08kuxKXqRTSGrltSuqek7mS8g7qNTjn5BN-TfmvRTkRGhgsZJVXRRkYn7NZ_I1nQEjZlO1NG7rmCQv83Ap3GwcOiurgMfziGjlhqcc8NT4KgktVh7qQtA0zjsww6NPMMmwbsJMxM6Ew3h6t5Ewj5qbpSddX4OVxkfd-DljP9DegZUln-xRznjNGF9auWecFqjyrceHp1yrcZcmiCMP-ni0vHl-c7TOfypdoetkqlAdP9m9OR-FtAcJqVTy78k65sxeaJIk9XHoFMeRSaWQjJRYsksH1mqPyUDMc1rpU_qHyHwfcIw_vlFTmLNuCPwlkDqNh4l8IQkS9iBdoaFaSBmkGYTfXAUsNrPiV38MiIyPmN5tlAAcskvwDkJ5Vz-mryxtRM78mFa1kfEJqIRfH9bmdtUPggqAi7kfDXzvjZ9weOkqQMuxog-53DwAaSndkFempTxmm_tCsSqaC73XPgaHCT_PAPifMty8-Ru3PEvNdAvlQlnxSnJOxgcDx8VAvxU7X2FXI-1idfXdVdCke5W5ilVt-mrZv4MkFe3HTjYfmG8fbLXRLFQXplqqrqZWxvxQYlYr9_W5nNfL5yly0 \ No newline at end of file +xLrjSzmsalxENy7IVaZYccnjaijLdMhlQcLPTbndoof9pjOcbL9193I3c0Hc0L2Eaij_lmB05m04IO3ao7Q2JzA0O7VpGQFHwCRBFnW7b5TbaQJfPocu9oXFzvJS5h1awNl4Tod0smBcQKR9UvRUGSYJD6Nl4Du32igqG1ZoXprC2UKxOZpA2i1O-bkIfcdwbD0SqbDI49h-vPjl_lFal_wcbL_lKFOEGyRIhvCq_LsASYhUXYn9Qped6KVtY3zSmWpj4SIS_PQaJrAKfySfmXnk8KsFqxzl8OOKS9Z_Jv9kAS3yfLdXykpvsUNP-UdPLNhqGZndHtz5UP8IleFq37F47qRUUDFMGh1-yKaAMidsX8xnEp4gjkzd50g1JtXwd79KGiP1kLVOYv_fhmJ3z3Z5vFtdNuZwrJxth_zxajZu2UOd-pqbzv1EWxZy-oe6wSXRRncaZuD5ciC2JjLEKaouU8V3NtYICWvunTBZ5EAg74Bpb1G5k2KKSFBdGdG0Z-UeW8-1mnqguCXRKemG77o_V-YxNn1GF4Umo2yXsnFCPe0WI7W38Igt7xWbeBY6vRu074eXBYWfzwFsp_t6OI1BQOr9tRL-_6aCnKl8I4MpHmMGrZn7tcx5EWn4YhXiYiRaA9Z4V_BdpGXKDyRhloKf1gN2NPOdajCT5OKrINzgV_-_BxySQwRXbfnkGKBH5c5GJBISqLPBBSSsWDUwRPmLXC6hYkqXpay95bsX7MpqPvSGC8ARWIU5E7y6vBWac2GfOlBBOF5mbYCEnQVS4b9085wv0mMJTIuv1tlG4X4AqOriikNQP-r0TmzejWFqyw-__VOBJMUQzmOwxPlRdw4Kw9HKT8xXQmbuDsRxHjTIalS8WLgu3pasjbBF6oJNWEbZ6pPR2UTR_JLW-GJjVn-vtY2WctzUY3ySN51oZtAO3um3KrGTWq7_bOcclYPxb7sOTkQ2zSZxIhGFqW7y1qiV8FgVldJO9BEu7dPI1-WR4KCmvFbgzZZRVk6f4lexI64tjWuEawpQ2w2kHIIa8INxMyjJwtOJG-byAnZUe9-WpHisTMUdh_xtj7tybi4yIK6U8uu81iKvuIASb2bT4DDyx7gXjPTks-Xl4YSiuNJh5e3njmh3fmz-q_sGm025MqJmQCEB2e_KYv-a8Xg5P4_inFawqJIyZllLVSpiVrOpjxqEg3ZNf5CEYgmIq-3yoeZtDDhIMGu10g6NfEZwUGuBT0_fmsl8ToJlYPPN573rOJdCK0cApx_PVFM5IrsxgN7Vto-5qiWgFpE2RuLwu6q5jZOMukK2Ra6cLCQKnQdp6bTSbRUGlhcx09IZ3Er5VBHha2JTWoqK2C5Q355YrBGbsLEofQ0KRUPwJ1lZst_mxmeoBfN0ihGYNcr0mwhR4Z6XErRZoGUSGnD93kWMyWko5mqk5Dp1Nhe8pWSSEFHFFBb7b5UWU5OhWqjBMtklnmk5AeLRUfs2jzKtmfIj6I2Zam4ftc5z8LqigIYJQ9HWpwx9o2pF8jZ8VmyAb3yVHUpxkifSgrTFiKsTVIBwmHvi0OTly6j8jz2OhdDQmGC5ipMGoxB2A9Cg_ufiVMUVcVNUwEJQhFeHpJTxedbyEMq6dD4Xiab67IeqB-hbW8FExeT_krz0Ph9JBGgT6A6d9-bU3FQCipVuxRuWm1c-Eco7EBk7c2kxU84Ami-1yXzE8UQGodRoK19eKHbfUfjX2oKsE8zGC4Lyb6oGATkvsxY5Udf2O1UfmRRnCrJwYAXvBM9mzlxmqxUNbsVdrpVNBr-VNLsVlBvmzJ_o7fk8kztf06WSRFiEtvvazeUrY7cRSq-J8qps5VeLcb_p3Zuuchy9IGWP1ZJ4pA86cp0HZW_DiMQM4p0wS0tu2o5PWayjorqW_EufoDwr-cMW7Pp5387gmsKg2Jvf9eINUbH7Fh5A5cSrG_EO8H0UCcxZ1aaVbqsmGaTihvZFqipcL2xJcRqKXLUeH0MWY3-yeAJQ-wKmm_NVOZFngbNxGA4u-4jML3dCNzwT4twIbmWx6ldt4R2cWReBJVJEyyBNnKKPz-y0Uudb318Cv5jx4ImBB3a8dku0tdhOWv3nPErizlonnuwNOVZ-oXqn9qK-yAGD2YaIGzwYV2334_reZ86xQ-3mxD7Dgvv3KxYffRde4AQNjPSYQfCbCv7WlT00L7NOKRrt9pxVI53i4ExOa7qJ36k2WRMkrwKotEV4jSlfDJ0S65rYC_J9Hirket1NjM4LElZFnUEJIE8uWPaQsFNHVEBbTbJlrNCrpNIJ728zqjP31BVPWcPAj0DiftVwMj9pCt2Y5xycERf3gpMYp3VL-O29bXwSkVkvI_ELNGMfBW25c8Uu4I5BmJycJxcMv7i4z7IJn_IklrxiEINbLGaePpi8-JK75mN0-fHdVBaOvUuy-z9bP1BvSvbtUeNmktLNzVitvROJ8tYa3GiyIlakuRu06IphVERYwAoJw4gnsaD6OMsvFPlV-j8fRHW2Xgho_4MGIgct4Dw-M5qJvplUjFj4lT-RGFVH3H_d3ZSXCrImwOdBOiq0umeKpokS8xnzJJ3VzKXcEN8Pw5gDNOywqpfi4gIMGdSuL3wxsiCCWG8nVbO2L4nLSX92M2Q5fJinAP5UsLhpL2VffqJgLNCAfUVdHzm8iOZWbEJEznq3YAR7fDf_a5ZmL9Z62CpyWQB1CWhcjXwEQKoGIGp3z40JDh_0bJ_x6QEdyBqkgji2PSPImnsWCFydaaRBt7xsKYQ7dW4EV1C_AftqV3mJ_xAj50owaU59ZzFFNZuxExz--VpbsRFFZe-Fv2pEalf6lNFaZv9SI83lI9SJCoPD56Q4vgUaKckHwYeisy4Iz1LR-rYFiAN_2WQ5z2JAuQi0o7iiKkKdNCm1zNLPwN7wjpfmb476XYluDzBS1kBxvwDjPA6_Ph_BuHt2I0zNoMGjnE2sW0CB3Exu_WfoZl3MUCVgmaA3ycf624wepMl0thKlxW7lG_4Qe09ISrNAhKYfSlSgC6tajfU5pKrxjsCB52E_d6VOwzkUf3-JMyUImYTYcPs8iIrsrYXaLS7RbOMioeGsQnPHHmnpY07Ee0zidABj5j9s6MrRI--L5issTm8EjuG-Q93FMBOZ8pTwwDL_NBXICDnLXHC9WX4d5E6jt2z_LPKbe0Xw3zN6ZDSrLcipywE_7cdl7Ad9m3s2xr_cRWATS9UoNaEbUUieQ82HSORJeDflVTCxdZlt06EKaF8DtGACVhLNzNwx-GzlVmkMH2sikL8R1tfaZ84DOSPfbOs_GQK5QDQVJdlW4g5dGRFrha6V8pcbylOBxrJzQKsf2JUgqhvtrAeiXDDKNgYAXjgAzuwwKN1aWEv9en9L3RNc_QEm1JMIKfr_QOOHA-RxCUWuON1wUfiHIg7KnsSGvAyGXii3PBK2Ut1lGuxwHjQ5clmTOkAtbo-uN9N3LL39zp2YYOzeY-yCcLgM7uSA-obR3Zca9SB0o94Q_H43AdPkt7UQvaZNhgXyUkEkw1VtTUrGaUtnx_9oIWxHCMPEt3yLJLv50zTMEjj8FfhANN60Sq3pntDAMCtHIsaRyJH5hL_-n9pXHzJDFvnPFAWzZ8PmKTiygjs8qv0u3N7CM5UqSlQC-V0sJaqlwi_nZaCjdjH_Oj4iLUyQbV23Z_HF7u9rewatAVzZ66wjDLRiE9OkF5Q_-UFuElhthEtgzB2Adavmo_XjWLvi45TjLR6ve5vUq3JP7st6PA_Rj29IchgIYcT4Nw2f3lRb76IxqeGfC3MW6j3m4GCqhdz2XqC7ZNdmkfQkDna_lAqB2Jgi6Yv0N2ItpKzTqai-lea0ucQ4rgyNRIkKYqpkBFtLDp2rZJtlU82FRMj_-F8eIx2bwvG7llEGQwkwJu6_y2iEzo7RSd3zpbZJrLK-hUquFJ3wMt48Tst5cNXHRiFfrR6Ru36EjlFMAVsuT_NpUivIZOoKUs5Ud2EVTZpkuozTdNTgunzd2UkC3OeVo-wlQslhfr7-0VP_zJ7rs-tbjqzBtzqrvu3JDJQ6BhW89BGN77hxcBOjUzvXgBDtdzqXolXfRH1zwhx45s9tWLozEbtxdF0hCSdmawF-DY_zzvHPLFKMMLJsbbXKzXLuS4lUq1uypPPq7LdNv9LcOXu7uiqxIlr2hDcGHptwXI_2Z3ZBoEW0UoMUVyINi5oe3JeNa3A2CIoMEkisFUCKQMYUbXFwXRsD8lK2Ti6mTxIMEcrrsUfYU7UdxFra-0CwTpyfm-DIPjc_NG_s5DwXNnndHT-vYflkwTxtLBU5QmVV8NOtptVMkW2MTVIi6w7sUDZHZMIE9_-2W8Pk7XkvE-n3cRQnZOPqzCZZ2YAhJEpe-EpuMFm6cJWxznxG1uItEW1RJzk9nyxdkrIdyvvHQanYXZOjh3kvBTt7B9CzRCzPji-JaT8-9Vb3kno_ERRBMUZSbrXHD8A7VqJ1VxjF2H2UavgZHVkMz2RKiKFgcrfjBo47vhc6JObV0Az49NIgfTlB4EVqU5_v478aySTmcOFl4Sw-t17hzjYQe-m2Y-FjkbT5uHpDR6UX-txb5nt0TymXooZQEtlrofxb5PHkfNRegE_Eof2zWQp7PSpTx5K-i1xt6TPxJH3UTdcbFpictiS2ppaBUCrZQcUVFTjzF2glCaAXF7yLPBjLhPm82trb-R4455_Rn-hMFj2DxQqn8LdlKeNWzaKSdXJGRolg8T8nFgT9F4bBo9Ywww7DpQdJUPkTl4rciDt7sttOZQZQ9ECzpcQOU-2STSZlFQ08I7VGtBlkxGO49eKEEIWEZtrlcqC8L_PhFdpcJx2eDqU_kOUKeXoktKUgZiN4AyqkxKCTy7aFYWhsCmSwQn-DbI_rLJ5W2pleja3V3X-v7TwItxU7EQhvm0UoTfoyWovVZhkc4hxDrnxA6e37D00xKiBB0z-Hj6Fo2XuwOCB4zEqLsQffuFgH6dUSgm3RBccS7gSdbvSd__hnfsPSaU3To5f7ubm0yGad5TyHAYF8ZfC3cezqtKVa30hj28RdpWEWZ1Is9ed8LmDyBm-iG8oZZ3Lho6-4Jt5JYZjNRaXjneGz5-T2tPI0lfCLn1WfJp9EqdzNe43yuGOmLb6nXkGc1sntIm3DcxGWA1HLxG6d0664jpTggHNJdZM9SrrScIMN1OrEDlVEB8tPBhs_1Z7bbhgpJezuCjmF9y_gsPy7ADrKaT1UxE67a-Ueu93wPTkh4ZkdltAbrUYj5J-Sl3gnBolpY8u9j_r6go7Bok86i5xHxKElg0DoOcWHTN-HRAfAgOOiqbSwT1XQpZQ6xsyeUNzZpZ18aJjAw-dS3KDRHqkyCXThrFbF6c4QXMbWqKY4gW-L5RM94FKX6hlV8-BbniiHuOzGkQ6A48r2fAq4jEvbYsYDe1NjkZzMqLY5uML792DO8s15d4PnYdtF2wO8ZmNFo9NG4I0R8ElAX0YG0cZqXkS8aWka250Fe3WhSS3oxDah3qZoW0_BhGyJ5wq3dDzmiEt5daLb2MBxQOFVhBR0tA0SoYqi_MMIwhaptwPEIhzn9cKcy0cwJJkgb0ko2905K1p07A2CN8KT0Me0Yj7jAOJYD7K4dF4he2DE0pX5Z5mOkIzzZW0r08tVpXnnid4QEymWecMZ5SSv3v0YE0vm8YWsA8CNzKPyMdo6x_MD8Bb0YW2v08gez50PllKr8jGY59Ld49g8YeNAu1Mo4PuEdWZD83GH5WkMa6jS_tJ8X5pN-gU3P_8EruYJYTF0xQCH9YTCZgij8r8ZIBdlOOoGJKCMLMmIhwXLFYWvBbI8I0qX5MrL45RHI4q25H4QHCXzJrz4oKHfw1B0Y9CBfwXC7Y35WIuGgjZlfd2A00q2D70_MuJ93LE4o88exly8X2Z2OyCpV5jR14W7A09Z0JDrrrf4oK0fhWATGYWYOoFZlZCgWYF0STvH5Q6Hq0nUK1NnqV29UcFX4ezJ7KPc6msD2vOuQN03uxY7AIBJ81D1iK2nRtQAg4XKfkwAYZ2vOPguJYh2veOjGcH3D78lLKH88TI_rYeY8v29teyB5SSrLX6O1fZXMx0YHXP64Hi7DiDtRqMC6emYA1Oe_GkX8Yu25_UzK14RXOrzqzMV6fVai_I4CP8XQNjPaYHodfuUVNMKVldbZt-vBK3sRhCl0C8RlaFPEmmP---lV_hoIrb5sm8iv6f_RUJrnedQVUPBp3uz9JrEjw9ee_vqJv9avEudYvkuP0xc5hlVqlG87wMz0yNJOt6zcLcsIS4WiyHnjKi0_waxXz9ZYxUeid6KcQ-jBbKRAlOMg7qZ6ReYORP0zkKmFPdQ2fkX1_MR1vIjwH6Xaidt3YZNS4jEo_OORQfcdZE4KozQaiqJgCcZzQJGcJULLk5eNFFdOjiyMusRQJ77FhHNUpKPt7raazBkwMgyb0sS-avPQpMUMh-OOjnLNOtxS4-tcG_rwn3UxKAxz3XbUvWXK236sz9DTqUzLcQAaVHkhvWvagjtZ-jROEqd6rd-d9AozyuY2Ng7JWllnIhR6Dw5uB2CPU_49-anMOKhCtMl74oFwrFphGTEvjRr4a7DxBY_GpfjPlccLwBCIUCohEXxPFA_ILSZd7F7ebDOnQfHRF4hwHsH7y32uqO2CGSHjJRgptZbHS8uSZh2cEYbh9R2EKS97tZkv4H57swMqUrvVpe3oyKBvJIa-Og5Mzj3UGpaRBjMa3_hgTrouivCfs3cAcycb8uZjGEDjElayjfMMuyshaAM39t8lEx4Ro-9DjhJpUrMu3x1pyKfbBnJGWLoysXJiro7KN-fTe_TThJwAbBeJjSmyyxXMiApuS5CuidowNv3UPCIAc9Qe1YQval73Z26jXZc3aB1mWnvKPkUBpMFTa_j5N5JwKE9sgskX3LRUKKhvJHBy-7kcLCngBHjFcW1WzdeX_otNyBqqbbqfUq25E8w5fNjK97FGyEOiBNkRDs_Wgdr5s28iKsKlC5me_wxVayKTLaak_5xalJYeFvCXyv94rxamAD-T7KL_kRn1WP_yz40V_kKwA1vaUlzpe-s7ZKEjLMykXXjF5ils_IAEjwBmrTrzDmHjGgctngWEY2lEbWm5GSaLFtCNoZAwcTpYiZ6V6mYPrRLdysHh3qRk9b4grzr0uLJj3ToA1_BA5fActKqvyjPuHpU8GppJZqBbGpQeJPA0r_qdYJXtlTWl6BJ3rlCfM-suKPloaJFLlcJjKu1jl9So7nQymYdRzBUQ3nOOXqE6cAFhJUFKqxHTWifp9y-RBeVYSSvVxE60VjyHMCWw9tFjUa_RDzy717RK_fSWbfL6lT53bhkK-oM2DHgybr7q05GhTlhynzji_3MMYnFUv34W0wfXniThWD8uUG-nNKb5MqrWetrc265M4c8iGxYrpDM4cIe9PoizJF9Lcc2RKVGfmqMym_mfqiNryHvejRQKukEz6Pmmh6Y9AEMoYtBYA5QAira4zcoc2-iBebB1BPnIfMC72MsG0AdhJ0V3VJNDQKOmC79rpZKQQtHQjHSjnLNbS8zM3Qb5kpMCJkhPv2mKUgNIfelavyC-80yk-ppkSMr2K5OzPCkVnwUSzV3ZxWksxJ_TM58HuXynLWxiGid0-wBv1cCrVNaIDPSdsHmw-BZq-kJHYBbskAFMQ9EP-GtxkJyKdvTw5NxVXb_rJz9FmTbQK2BjCGUd66jkSpCzjMPTAgLz08wYs91ZIMuwCM4bEMmovCsvhD626hJYqKb6j3pqz8ge5SJwkUbsJmk0saAZP4pJJ6FL-FWwzILDp-BdZiDK1Vg2wEI-foAsKPA2Ires5Q8pQ1jfYOxohfHdqMtmxScs80RIiav33LKIN-y2UUW7LEaaTvtfJDeDlv89VvkqXqfeIAITEFuMckrQVt_-0hjvNoTouk7c3lM4o-Nm8VnCdx3s6uNepcqXC3aqLy-C8UNosli-FIzHhKlOjcU2ahK8YNhIJ3jxcIMbYXjYuAfjVjvwzvOx_OG51DrgCTiKy74wwQbS2rlMt5fLhEqLr-fjJhFmHLRJXcSjPEw_TRRAS35FrFx0eAv6gfMiz1qJT_E6wXozavfYPyisaWrj9ydQiHOOr-18D-iA6lB5zAt9nqw3tEoiOoFNCTUyPrOKrm0nj_kSx5JJsInQEtaX39mEQyDYxxd6ahTBpIYuF89Y8JoTosbKjpwIwdcqBONz7IqZIVXj5rPuffuFqaSIdsi5V0Ag0zEnXMBWNbuA1Tpj56azS4cb5vt58fOqKwXMhhFsj9WWlROkkb6s5cq74Qp8xlOOheE86Wvt5iFacRN-SBjAVSrcVuMYLaisbUQqEFtBVqcrD2KJ6oVnKVfxnfFmhc3PpD6DfuOCpzZO0-gFsc7Tyfyjclf4IrlRlPk3jPRjByA6ttD_e5OUE6sBalJIs8XMzkM8RU4eBM0sWwNB21iv8TN9JqGyluduZjIDSknpV9UuQho6C5MFMwArcNafTJEtNjpzI7psHeOe1Drm5BwBwkJ7qo5hCl625nTxoYfjhkUZxan7_VyDVEvsrbo23x0RgJcz2x8KI5SknJdMgj2p1xgHOVUD4Ptmx5sZYrx-TNmXYbHqpaDTs-acjjVcFyUgRai1ZOBBJKYPZXx9JTbgsy_cDV1x2NMyurD7LyFRc9R7DMmlHJvN7fuE0E-5FucY6dcD-SAgcK0ssY_VpTMSSGw8YXTbwrA791YcPT5zGZgt6zJH9fWvrmgwjkIv1yGvrLP9FyF \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 72586ae2ee..5ccd5efd4b 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -435,9 +435,27 @@ class GrantNumberLinks{ deletedAt : timestamp with time zone } +class GrantReplacementTypes{ + * id : integer : + mapsTo : integer : REFERENCES "GrantReplacementTypes".id + * createdAt : timestamp with time zone : now() + * name : text + * updatedAt : timestamp with time zone : now() + deletedAt : timestamp with time zone +} + +class GrantReplacements{ + * id : integer : + grantReplacementTypeId : integer : REFERENCES "GrantReplacementTypes".id + * replacedGrantId : integer : REFERENCES "Grants".id + * replacingGrantId : integer : REFERENCES "Grants".id + * createdAt : timestamp with time zone : now() + * updatedAt : timestamp with time zone : now() + replacementDate : date +} + class Grants{ * id : integer - oldGrantId : integer : REFERENCES "Grants".id regionId : integer : REFERENCES "Regions".id * recipientId : integer : REFERENCES "Recipients".id * createdAt : timestamp with time zone : now() @@ -1614,6 +1632,34 @@ class ZALGrantNumberLinks{ session_sig : text } +class ZALGrantReplacementTypes{ + * id : bigint : + * data_id : bigint + * dml_as : bigint + * dml_by : bigint + * dml_timestamp : timestamp with time zone + * dml_txid : uuid + * dml_type : enum + descriptor_id : integer + new_row_data : jsonb + old_row_data : jsonb + session_sig : text +} + +class ZALGrantReplacements{ + * id : bigint : + * data_id : bigint + * dml_as : bigint + * dml_by : bigint + * dml_timestamp : timestamp with time zone + * dml_txid : uuid + * dml_type : enum + descriptor_id : integer + new_row_data : jsonb + old_row_data : jsonb + session_sig : text +} + class ZALGrants{ * id : bigint : * data_id : bigint @@ -2481,12 +2527,15 @@ Goals "1" --[#black,dashed,thickness=2]--{ "n" Objectives : objectives, goal Goals "1" --[#black,dashed,thickness=2]--{ "n" SimScoreGoalCaches : scoreOne, scoreTwo, goalOne, goalTwo GrantNumberLinks "1" --[#black,dashed,thickness=2]--{ "n" MonitoringClassSummaries : monitoringClassSummaries, grantNumberLink GrantNumberLinks "1" --[#black,dashed,thickness=2]--{ "n" MonitoringReviewGrantees : monitoringReviewGrantees, grantNumberLink +GrantReplacementTypes "1" --[#black,dashed,thickness=2]--{ "n" GrantReplacementTypes : mapsFromReplacementType, mapsToReplacementType +GrantReplacementTypes "1" --[#black,dashed,thickness=2]--{ "n" GrantReplacements : grantReplacements, grantReplacementType Grants "1" --[#black,dashed,thickness=2]--{ "n" ActivityRecipients : grant, activityRecipients Grants "1" --[#black,dashed,thickness=2]--{ "n" Goals : grant, goals -Grants "1" --[#black,dashed,thickness=2]--{ "n" Grants : oldGrants, grant +Grants "1" --[#black,dashed,thickness=2]--{ "n" GrantReplacements : replacedGrant, replacingGrant, replacedGrantReplacements, replacingGrantReplacements Grants "1" --[#black,dashed,thickness=2]--{ "n" GroupGrants : groupGrants, grant Grants "1" --[#black,dashed,thickness=2]--{ "n" ProgramPersonnel : programPersonnel, grant Grants "1" --[#black,dashed,thickness=2]--{ "n" Programs : programs, grant +Grants "1" --[#black,dashed,thickness=2]--{ "n" undefined : grantRelationships, activeGrantRelationships Groups "1" --[#black,dashed,thickness=2]--{ "n" GroupCollaborators : group, groupCollaborators Groups "1" --[#black,dashed,thickness=2]--{ "n" GroupGrants : group, groupGrants ImportFiles "1" --[#black,dashed,thickness=2]--{ "n" ImportDataFiles : importFile, importDataFiles diff --git a/src/goalServices/changeGoalStatus.test.js b/src/goalServices/changeGoalStatus.test.js index 6d88dc4304..ee5c8d593f 100644 --- a/src/goalServices/changeGoalStatus.test.js +++ b/src/goalServices/changeGoalStatus.test.js @@ -55,7 +55,7 @@ describe('changeGoalStatus service', () => { afterAll(async () => { await db.Goal.destroy({ where: { id: goal.id }, force: true }); await db.GrantNumberLink.destroy({ where: { grantId: grant.id }, force: true }); - await db.Grant.destroy({ where: { id: grant.id } }); + await db.Grant.destroy({ where: { id: grant.id }, individualHooks: true }); await db.Recipient.destroy({ where: { id: recipient.id } }); await db.UserRole.destroy({ where: { userId: user.id } }); await db.Role.destroy({ where: { id: role.id } }); diff --git a/src/goalServices/getGoalIdsBySimiliarity.test.js b/src/goalServices/getGoalIdsBySimiliarity.test.js index 4639785b80..95e17f8651 100644 --- a/src/goalServices/getGoalIdsBySimiliarity.test.js +++ b/src/goalServices/getGoalIdsBySimiliarity.test.js @@ -5,6 +5,8 @@ import { ActivityReportGoal, Grant, GrantNumberLink, + GrantReplacements, + GrantRelationshipToActive, Recipient, Goal, GoalTemplate, @@ -80,9 +82,17 @@ describe('getGoalIdsBySimilarity', () => { replacementGrant = await createGrant({ recipientId: recipient.id, status: 'Active', - oldGrantId: inactiveGrantWithReplacement.id, }); + await GrantReplacements.create({ + replacedGrantId: inactiveGrantWithReplacement.id, + replacingGrantId: replacementGrant.id, + replacementDate: new Date(), + }); + + // Refresh the materialized view. + await GrantRelationshipToActive.refresh(); + // goals that will be ineligible for similarity // because they are on reports that have ineligible statuses const goalOnDraftReport = await createGoal({ diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js index 4f4c2bd7c9..18323a382d 100644 --- a/src/goalServices/goals.js +++ b/src/goalServices/goals.js @@ -12,6 +12,7 @@ import { GoalResource, GoalStatusChange, Grant, + GrantRelationshipToActive, Objective, ActivityReportObjective, sequelize, @@ -69,6 +70,46 @@ const logContext = { namespace, }; +/** + * Maps grants to their active replacements. + * + * This function iterates through a list of grants and constructs a dictionary + * where each grant ID is associated with an array of active grant IDs. If the + * grant itself is active, it maps to its own ID. If the grant is not active, + * but has relationships with active grants, it maps to those active grants instead. + * + * @param {Array} grants - An array of grant objects. Each grant object should have + * properties `id`, `status`, and an optional array of `grantRelationships`. Each + * relationship should contain an `activeGrant` object with `id` and `status`. + * @returns {Object} A dictionary where the keys are grant IDs and the values are + * arrays of active grant IDs related to each key grant. + */ +function mapGrantsWithReplacements(grants) { + const grantsWithReplacementsDictionary = {}; + + grants.forEach((grant) => { + if (grant.status === 'Active') { + if (Array.isArray(grantsWithReplacementsDictionary[grant.id])) { + grantsWithReplacementsDictionary[grant.id].push(grant.id); + } else { + grantsWithReplacementsDictionary[grant.id] = [grant.id]; + } + } else { + grant.grantRelationships.forEach((relationship) => { + if (relationship.activeGrant && relationship.activeGrant.status === 'Active') { + if (Array.isArray(grantsWithReplacementsDictionary[grant.id])) { + grantsWithReplacementsDictionary[grant.id].push(relationship.activeGrantId); + } else { + grantsWithReplacementsDictionary[grant.id] = [relationship.activeGrantId]; + } + } + }); + } + }); + + return grantsWithReplacementsDictionary; +} + /** * * @param {number} id @@ -621,20 +662,36 @@ export async function goalsForGrants(grantIds) { /** * get all the matching grants */ - const grants = await Grant.findAll({ - attributes: ['id', 'oldGrantId'], + const grants = await Grant.unscoped().findAll({ + attributes: [ + 'id', + [sequelize.fn( + 'ARRAY_AGG', + sequelize.fn( + 'DISTINCT', + sequelize.col('grantRelationships.grantId'), + ), + ), 'oldGrantIds'], + ], where: { id: grantIds, }, + include: [{ + model: GrantRelationshipToActive, + as: 'grantRelationships', + required: false, + attributes: [], + }], + group: ['"Grant".id'], }); /** * we need one big array that includes the old recipient id as well, * removing all the nulls along the way */ - const ids = grants + const ids = Array.from(new Set(grants .reduce((previous, current) => [...previous, current.id, current.oldGrantId], []) - .filter((g) => g); + .filter((g) => g))); /* * finally, return all matching goals @@ -660,7 +717,7 @@ export async function goalsForGrants(grantIds) { 'ARRAY_AGG', sequelize.fn( 'DISTINCT', - sequelize.col('grant.oldGrantId'), + sequelize.col('grant.grantRelationships.grantId'), ), ), 'oldGrantIds'], [sequelize.fn( @@ -685,7 +742,7 @@ export async function goalsForGrants(grantIds) { 'createdVia', [sequelize.fn('BOOL_OR', sequelize.literal(`"goalTemplate"."creationMethod" = '${CREATION_METHOD.CURATED}'`)), 'isCurated'], ], - group: ['"Goal"."name"', '"Goal"."status"', '"Goal"."endDate"', '"Goal"."onApprovedAR"', '"Goal"."source"', '"Goal"."createdVia"'], + group: ['"Goal"."name"', '"Goal"."status"', '"Goal"."endDate"', '"Goal"."onApprovedAR"', '"Goal"."source"', '"Goal"."createdVia"', '"Goal".id'], where: { name: { [Op.ne]: '', // exclude "blank" goals @@ -700,6 +757,11 @@ export async function goalsForGrants(grantIds) { model: Grant.unscoped(), as: 'grant', attributes: [], + include: [{ + model: GrantRelationshipToActive, + as: 'grantRelationships', + attributes: [], + }], }, { model: GoalTemplate, @@ -1645,23 +1707,31 @@ export async function getGoalIdsBySimilarity(recipientId, regionId, user = null) const uniqueGrantIds = uniq(goalGroups.map((group) => group.map((goal) => goal.grantId)).flat()); const grants = await Grant.findAll({ + attributes: ['id', 'status'], where: { [Op.or]: [ { id: uniqueGrantIds }, - { oldGrantId: uniqueGrantIds }, + { '$grantRelationships.grantId$': uniqueGrantIds }, ], - status: 'Active', }, - attributes: ['id', 'oldGrantId', 'status'], + include: [ + { + model: GrantRelationshipToActive, + as: 'grantRelationships', + attributes: ['activeGrantId', 'grantId'], + required: false, + include: [ + { + model: Grant, + as: 'activeGrant', + attributes: ['id', 'status'], + }, + ], + }, + ], }); - const grantLookup = {}; - grants.forEach((grant) => { - grantLookup[grant.id] = grant.id; - if (grant.oldGrantId) { - grantLookup[grant.oldGrantId] = grant.id; - } - }); + const grantsWithReplacementsDictionary = mapGrantsWithReplacements(grants); const filteredGoalGroups = goalGroups .filter((group) => { @@ -1682,7 +1752,7 @@ export async function getGoalIdsBySimilarity(recipientId, regionId, user = null) const goalGroupsDeduplicated = filteredGoalGroups.map((group) => group .reduce((previous, current) => { - if (!grantLookup[current.grantId]) { + if (!grantsWithReplacementsDictionary[current.grantId]) { return previous; } @@ -1719,7 +1789,7 @@ export async function getGoalIdsBySimilarity(recipientId, regionId, user = null) responsesForComparison: responsesForComparison(current), ids: [current.id], excludedIfNotAdmin, - grantId: grantLookup[current.grantId], + grantId: grantsWithReplacementsDictionary[current.grantId], }, ]; }, [])); @@ -1909,39 +1979,45 @@ export async function mergeGoals( const finalGoalValues = determineFinalGoalValues(selectedGoals, finalGoal); /** - * we will need to create a "new" final goal for each grant involved - * in this sordid business - */ + * we will need to create a "new" final goal for each grant involved + * in this sordid business + */ const uniqueGrantIds = uniq(selectedGoals.map((goal) => goal.grantId)); const grantsWithReplacements = await Grant.findAll({ - attributes: ['id', 'status', 'oldGrantId'], + attributes: ['id', 'status'], where: { [Op.or]: [ { id: uniqueGrantIds }, - { oldGrantId: uniqueGrantIds }, + { '$grantRelationships.grantId$': uniqueGrantIds }, ], - status: 'Active', }, + include: [ + { + model: GrantRelationshipToActive, + as: 'grantRelationships', + attributes: ['activeGrantId', 'grantId'], + required: false, + include: [ + { + model: Grant, + as: 'activeGrant', + attributes: ['id', 'status'], + }, + ], + }, + ], }); if (!grantsWithReplacements.length) { throw new Error('No active grants found to merge goals into'); } - const grantsWithReplacementsDictionary = {}; - - grantsWithReplacements.forEach((grant) => { - if (grant.oldGrantId) { - grantsWithReplacementsDictionary[grant.oldGrantId] = grant.id; - } - - grantsWithReplacementsDictionary[grant.id] = grant.id; - }); + const grantsWithReplacementsDictionary = mapGrantsWithReplacements(grantsWithReplacements); // unique list of grant IDs - const grantIds = uniq(grantsWithReplacements.map((grant) => grant.id)); + const grantIds = uniq(Object.values(grantsWithReplacementsDictionary).flat()); const goalsToBulkCreate = grantIds.map((grantId) => ({ ...finalGoalValues, @@ -2008,7 +2084,8 @@ export async function mergeGoals( // will use the most up-to-date grant ID as well mergeObjectivesFromGoal(g, grantToGoalDictionary[ grantsWithReplacementsDictionary[g.grantId] - ])))); + ]) + ))); const updatesToRelatedModels = []; diff --git a/src/goalServices/mergeGoals.test.js b/src/goalServices/mergeGoals.test.js index 04b7bb1927..70c9e0f653 100644 --- a/src/goalServices/mergeGoals.test.js +++ b/src/goalServices/mergeGoals.test.js @@ -2,6 +2,8 @@ import faker from '@faker-js/faker'; import db, { Recipient, Grant, + GrantReplacements, + GrantRelationshipToActive, Goal, GoalCollaborator, GoalTemplate, @@ -106,9 +108,17 @@ describe('mergeGoals', () => { startDate: new Date(), endDate: new Date(), status: 'Active', - oldGrantId: grantTwo.id, }); + await GrantReplacements.create({ + replacedGrantId: grantTwo.id, + replacingGrantId: grantThree.id, + replacementDate: new Date(), + }); + + // Refresh the materialized view. + await GrantRelationshipToActive.refresh(); + template = await createGoalTemplate(); const promptTitle = faker.datatype.string(255); @@ -479,6 +489,7 @@ describe('mergeGoals', () => { expect(goalsWithData.length).toBe(2); const grantIds = goalsWithData.map((goal) => goal.grantId); expect(grantIds).toContain(grantOne.id); + expect(grantIds).not.toContain(grantTwo.id); expect(grantIds).toContain(grantThree.id); const goalForGrantOne = goalsWithData.find((g) => g.grantId === grantOne.id); diff --git a/src/goalServices/types.ts b/src/goalServices/types.ts index 26eb91fc20..b38f105801 100644 --- a/src/goalServices/types.ts +++ b/src/goalServices/types.ts @@ -115,7 +115,6 @@ interface IGrant { status: string; startDate: string; endDate: string; - oldGrantId: number; recipientId: number; numberWithProgramTypes: string; number: string; diff --git a/src/lib/programmaticTransaction.ts b/src/lib/programmaticTransaction.ts index 3aa390bb2c..66d7c15492 100644 --- a/src/lib/programmaticTransaction.ts +++ b/src/lib/programmaticTransaction.ts @@ -202,7 +202,7 @@ const hasModifiedData = async (snapShot, transactionId) => { } const zalTables = Object.keys(db) - // Filter the keys of the `db` object for tables that start with 'ZAL' + // Filter the keys of the `db` object for tables that start with 'ZAL' .filter((key) => key.startsWith('ZAL')) // Endpoints Descriptor Are allowed to be added to the audit log .filter((key) => key !== 'ZALZADescriptor') diff --git a/src/lib/updateGrantsRecipients.js b/src/lib/updateGrantsRecipients.js index 762bc1b976..8c6c2cad7d 100644 --- a/src/lib/updateGrantsRecipients.js +++ b/src/lib/updateGrantsRecipients.js @@ -1,7 +1,10 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-loop-func */ +/* eslint-disable no-await-in-loop */ import AdmZip from 'adm-zip'; import xml2js from 'xml2js'; import axios from 'axios'; -import { keyBy, mapValues } from 'lodash'; +import { keyBy, mapValues, uniq } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize'; @@ -9,6 +12,10 @@ import { fileHash } from './fileUtils'; import db, { Recipient, Grant, + GrantRelationshipToActive, + GrantReplacements, + GrantReplacementTypes, + GroupGrant, Program, sequelize, ProgramPersonnel, @@ -175,20 +182,23 @@ async function getProgramPersonnel(grantId, programId, program) { export const updateCDIGrantsWithOldGrantData = async (grantsToUpdate) => { try { - const updatePromises = grantsToUpdate.map(async (grant) => { - if (grant.oldGrantId) { - const oldGrant = await Grant.findByPk(grant.oldGrantId); - if (oldGrant) { - return grant.update({ - recipientId: oldGrant.recipientId, - regionId: oldGrant.regionId, - }); - } + const updates = grantsToUpdate.map(async (grant) => { + // eslint-disable-next-line max-len + const replacedGrants = await GrantReplacements.findAll({ where: { replacingGrantId: grant.id } }); + // eslint-disable-next-line max-len + const validOldGrants = (await Promise.all(replacedGrants.map((rg) => Grant.findByPk(rg.replacedGrantId)))).filter(Boolean); + + const [regionId] = uniq(validOldGrants.map((g) => g.regionId)); + const [recipientId] = uniq(validOldGrants.map((g) => g.recipientId)); + + if (!regionId || !recipientId || validOldGrants.length !== replacedGrants.length) { + throw new Error(`Expected one region and recipient for grant ${grant.id}, got ${validOldGrants.length} valid grants`); } - return null; + + return grant.update({ recipientId, regionId }); }); - await Promise.all(updatePromises); + await Promise.all(updates); } catch (error) { logger.error('updateGrantsRecipients: Error updating grants:', error); } @@ -259,7 +269,9 @@ export async function processFiles(hashSumHex) { name: grantAward ? grantAward.grantee_name : r.name, recipientType: r.recipientType, }); - } return r; + } + + return r; }); logger.debug(`updateGrantsRecipients: calling bulkCreate for ${recipientsForDb.length} recipients`); @@ -387,25 +399,92 @@ export async function processFiles(hashSumHex) { && grantIds.includes(parseInt(g.replacement_grant_award_id, 10)), ); - const grantUpdatePromises = grantsToUpdate.map((g) => ( - Grant.unscoped().update( - { oldGrantId: parseInt(g.replaced_grant_award_id, 10) }, - { - where: { id: parseInt(g.replacement_grant_award_id, 10) }, - fields: ['oldGrantId'], - sideEffects: false, - transaction, - individualHooks: true, + const grantReplacementPromises = grantsToUpdate.map(async (g) => { + let grantReplacementType = await GrantReplacementTypes.findOne({ + where: { + name: g.grant_replacement_type, }, - ) - )); + }); + + if (!grantReplacementType) { + // Create the new type + grantReplacementType = await GrantReplacementTypes.create({ + name: g.grant_replacement_type, + }); + } + + const grantReplacement = await GrantReplacements.findOne({ + where: { + replacedGrantId: parseInt(g.replaced_grant_award_id, 10), + replacingGrantId: parseInt(g.replacement_grant_award_id, 10), + grantReplacementTypeId: grantReplacementType.id, + }, + }); + + if (grantReplacement) { + await grantReplacement.update({ + replacementDate: new Date(g.replacement_date), + grantReplacementTypeId: grantReplacementType.id, + }, { + individualHooks: true, + }); + } else { + await GrantReplacements.create({ + replacedGrantId: parseInt(g.replaced_grant_award_id, 10), + replacingGrantId: parseInt(g.replacement_grant_award_id, 10), + grantReplacementTypeId: grantReplacementType.id, + replacementDate: new Date(g.replacement_date), + }); + } + }); + + await Promise.all(grantReplacementPromises); + + // --- + // Update GroupGrants + const HOUR_AGO = new Date(new Date() - 60 * 60 * 1000); + + const replacements = await GrantReplacements.findAll({ + where: { updatedAt: { [Op.gte]: HOUR_AGO } }, + attributes: ['replacedGrantId', 'replacingGrantId'], + }); + + const affectedGroupGrants = await GroupGrant.findAll({ + where: { grantId: uniq(replacements.map((g) => g.replacedGrantId)) }, + attributes: ['id', 'grantId', 'groupId'], + }); + + const createdGrants = new Set(); + + for (const g of affectedGroupGrants) { + if (replacements.some((r) => r.replacedGrantId === g.grantId)) { + await GroupGrant.destroy({ where: { id: g.id } }); + + // Use a Set to avoid duplicates + const uniqueReplacingGrantIds = new Set(); + + for (const replacement of replacements.filter((r) => r.replacedGrantId === g.grantId)) { + uniqueReplacingGrantIds.add(replacement.replacingGrantId); + } + + // Now create each unique replacing grant only if it hasn't been created before + for (const uniqueGrantId of uniqueReplacingGrantIds) { + const key = `${g.groupId}-${uniqueGrantId}`; + if (!createdGrants.has(key)) { + await GroupGrant.create({ groupId: g.groupId, grantId: uniqueGrantId }); + createdGrants.add(key); // Track created groupId + grantId pairs + } + } + } + } + // --- - await Promise.all(grantUpdatePromises); + await GrantRelationshipToActive.refresh(); // Automate CDI linking to preceding recipients const cdiGrantsToLink = await Grant.unscoped().findAll({ where: { regionId: 13, endDate: { [Op.gte]: '2021-03-17' } }, - attributes: ['id', 'endDate', 'oldGrantId'], + attributes: ['id', 'endDate'], }); await updateCDIGrantsWithOldGrantData(cdiGrantsToLink); diff --git a/src/lib/updateGrantsRecipients.test.js b/src/lib/updateGrantsRecipients.test.js index 19ee6efff6..3be3474729 100644 --- a/src/lib/updateGrantsRecipients.test.js +++ b/src/lib/updateGrantsRecipients.test.js @@ -4,7 +4,18 @@ import axios from 'axios'; import fs from 'mz/fs'; import updateGrantsRecipients, { getPersonnelField, processFiles, updateCDIGrantsWithOldGrantData } from './updateGrantsRecipients'; import db, { - sequelize, Recipient, Goal, Grant, Program, ZALGrant, ActivityRecipient, ProgramPersonnel, + sequelize, + Recipient, + Goal, + Grant, + GrantReplacements, + GrantReplacementTypes, + Group, + GroupGrant, + Program, + ZALGrant, + ActivityRecipient, + ProgramPersonnel, } from '../models'; jest.mock('axios'); @@ -85,6 +96,14 @@ describe('Update grants, program personnel, and recipients', () => { await ProgramPersonnel.unscoped().destroy({ where: { grantId: { [Op.gt]: SMALLEST_GRANT_ID } }, }); + await GrantReplacements.destroy({ + where: { + [Op.or]: [ + { replacingGrantId: { [Op.gt]: SMALLEST_GRANT_ID } }, + { replacedGrantId: { [Op.gt]: SMALLEST_GRANT_ID } }, + ], + }, + }); await Grant.unscoped().destroy({ where: { id: { [Op.gt]: SMALLEST_GRANT_ID } }, individualHooks: true, @@ -98,15 +117,25 @@ describe('Update grants, program personnel, and recipients', () => { await ProgramPersonnel.unscoped().destroy({ where: { grantId: { [Op.gt]: SMALLEST_GRANT_ID } }, }); + await GrantReplacements.destroy({ + where: { + [Op.or]: [ + { replacingGrantId: { [Op.gt]: SMALLEST_GRANT_ID } }, + { replacedGrantId: { [Op.gt]: SMALLEST_GRANT_ID } }, + ], + }, + }); await Grant.unscoped().destroy({ where: { id: { [Op.gt]: SMALLEST_GRANT_ID } }, individualHooks: true, }); await Recipient.unscoped().destroy({ where: { id: { [Op.gt]: SMALLEST_GRANT_ID } } }); }); + afterAll(async () => { await db.sequelize.close(); }); + it('should import or update recipients', async () => { const recipientsBefore = await Recipient.findAll( { where: { id: { [Op.gt]: SMALLEST_GRANT_ID } } }, @@ -126,15 +155,22 @@ describe('Update grants, program personnel, and recipients', () => { const recipient7782 = await Recipient.findOne({ where: { id: 7782 } }); expect(recipient7782).toBeDefined(); - const grant1 = await Grant.findOne({ where: { id: 8110 } }); - expect(grant1.oldGrantId).toBe(7842); + const grant1 = await GrantReplacements.findOne({ + where: { replacedGrantId: 7842, replacingGrantId: 8110 }, + }); + expect(grant1).toBeDefined(); + + const grant2 = await GrantReplacements.findOne({ + where: { replacedGrantId: 2591, replacingGrantId: 11835 }, + }); + expect(grant2).toBeDefined(); - const grant2 = await Grant.findOne({ where: { id: 11835 } }); - expect(grant2.oldGrantId).toBe(2591); expect(recipient.recipientType).toBe('Community Action Agency (CAA)'); - const grant3 = await Grant.findOne({ where: { id: 10448 } }); - expect(grant3.oldGrantId).toBe(null); + const grant3 = await GrantReplacements.findOne({ + where: { replacedGrantId: null, replacingGrantId: 10448 }, + }); + expect(grant3).toBeDefined(); const grantForRecipients = await Grant.findAll({ where: { recipientId: 7782 } }); expect(grantForRecipients.length).toEqual(2); @@ -178,7 +214,7 @@ describe('Update grants, program personnel, and recipients', () => { const grants = await Grant.unscoped().findAll({ where: { recipientId: 1335 } }); expect(grants).toBeDefined(); - expect(grants.length).toBe(7); + expect(grants.length).toBe(8); const containsNumber = grants.some((g) => g.number === '02CH01111'); expect(containsNumber).toBeTruthy(); @@ -188,7 +224,7 @@ describe('Update grants, program personnel, and recipients', () => { const totalGrants = await Grant.unscoped().findAll({ where: { id: { [Op.gt]: SMALLEST_GRANT_ID } }, }); - expect(totalGrants.length).toBe(19); + expect(totalGrants.length).toBe(20); }); it('should import or update program personnel', async () => { @@ -942,28 +978,138 @@ describe('Update grants, program personnel, and recipients', () => { it('includes the inactivated date', async () => { await processFiles(); - const grant = await Grant.findOne({ where: { id: 8317 } }); + const grant = await Grant.findOne({ where: { id: 7842 } }); // simulate updating an existing grant with null inactivationDate await grant.update({ inactivationDate: null }, { individualHooks: true }); - const grantWithNullinactivationDate = await Grant.findOne({ where: { id: 8317 } }); + const grantWithNullinactivationDate = await Grant.findOne({ where: { id: 7842 } }); expect(grantWithNullinactivationDate.inactivationDate).toBeNull(); await processFiles(); - const grantWithinactivationDate = await Grant.findOne({ where: { id: 8317 } }); + const grantWithinactivationDate = await Grant.findOne({ where: { id: 7842 } }); expect(grantWithinactivationDate.inactivationDate).toEqual(new Date('2022-07-31')); }); + it('includes the replacement date', async () => { + await processFiles(); + // const grant = await Grant.findOne({ where: { id: 7842 } }); + // simulate updating an existing grant replacement with null replacementDate + await GrantReplacements.update( + { replacementDate: null }, + { where: { replacedGrantId: 7842 }, individualHooks: true }, + ); + const grantReplacementWithNullDate = await GrantReplacements.findOne({ + where: { replacedGrantId: 7842 }, + }); + expect(grantReplacementWithNullDate.replacementDate).toBeNull(); + await processFiles(); + const grantReplacement = await GrantReplacements.findOne({ where: { replacedGrantId: 7842 } }); + expect(grantReplacement.replacementDate).toEqual('2021-10-01'); + }); + it('includes the inactivated reason', async () => { await processFiles(); - const grant = await Grant.findOne({ where: { id: 8317 } }); + const grant = await Grant.findOne({ where: { id: 7842 } }); // simulate updating an existing grant with null inactivationReason await grant.update({ inactivationReason: null }, { individualHooks: true }); - const grantWithNullinactivationReason = await Grant.findOne({ where: { id: 8317 } }); + const grantWithNullinactivationReason = await Grant.findOne({ where: { id: 7842 } }); expect(grantWithNullinactivationReason.inactivationReason).toBeNull(); await processFiles(); - const grantWithinactivationReason = await Grant.findOne({ where: { id: 8317 } }); + const grantWithinactivationReason = await Grant.findOne({ where: { id: 7842 } }); expect(grantWithinactivationReason.inactivationReason).toEqual('Replaced'); }); + it('includes the grant_replacement_type', async () => { + await processFiles(); + // simulate updating an existing grant replacement with null grantReplacementTypeId + await GrantReplacements.update( + { grantReplacementTypeId: null }, + { where: { replacedGrantId: 7842 }, individualHooks: true }, + ); + const grantWithNullInactivationType = await GrantReplacements.findOne({ + where: { replacedGrantId: 7842, grantReplacementTypeId: null }, + }); + expect(grantWithNullInactivationType).not.toBeNull(); + await processFiles(); + const grantReplacementWithInactivationReason = await GrantReplacements.findOne({ + where: { replacedGrantId: 7842, grantReplacementTypeId: { [Op.ne]: null } }, + }); + + const grantReplacementType = await GrantReplacementTypes.findOne({ + where: { id: grantReplacementWithInactivationReason.grantReplacementTypeId }, + }); + + expect(grantReplacementType.name).toEqual('DRS Non-Competitive Continuation'); + }); + + describe('Updating GroupGrants', () => { + let group; + let groupGrant; + let grant; + + afterEach(async () => { + if (grant && grant.id) { + await GrantReplacements.destroy({ where: { replacedGrantId: grant.id } }); + await Grant.destroy({ where: { id: grant.id }, individualHooks: true }); + } + }); + + it('should update the group', async () => { + [grant] = await Grant.findOrCreate({ + where: { id: 7842 }, + defaults: { + recipientId: 10, regionId: 1, number: 'X1', + }, + }); + + [group] = await Group.findOrCreate({ + where: { name: 'my test group 1234' }, + defaults: { isPublic: true }, + }); + + [groupGrant] = await GroupGrant.findOrCreate({ + where: { grantId: grant.id, groupId: group.id }, + defaults: { grantId: grant.id, groupId: group.id }, + }); + + await processFiles(); + const found = await GroupGrant.findOne({ where: { groupId: group.id } }); + + // Expect the grant id referenced by this group to have been updated with + // the id of the grant that has replaced this one. + expect([8110, 9999]).toContain(found.grantId); + + await GroupGrant.destroy({ where: { groupId: group.id } }); + await Group.destroy({ where: { id: group.id } }); + }); + + it('should update all groups when multiple grants replace a single grant', async () => { + [grant] = await Grant.findOrCreate({ + where: { id: 7842 }, + defaults: { + recipientId: 10, regionId: 1, number: 'X1', + }, + }); + + [group] = await Group.findOrCreate({ + where: { name: 'my test group 1234' }, + defaults: { isPublic: true }, + }); + + [groupGrant] = await GroupGrant.findOrCreate({ + where: { grantId: grant.id, groupId: group.id }, + defaults: { grantId: grant.id, groupId: group.id }, + }); + + await processFiles(); + const found = await GroupGrant.findAll({ where: { groupId: group.id } }); + + // expect there to be two entities found: + expect(found.length).toBe(2); + + await GroupGrant.destroy({ where: { grantId: [8110, 9999] } }); + await Group.destroy({ where: { id: group.id } }); + }); + }); + describe('updateCDIGrantsWithOldGrantData', () => { afterAll(async () => { await Grant.destroy({ @@ -973,7 +1119,7 @@ describe('Update grants, program personnel, and recipients', () => { await db.sequelize.close(); }); - it('should update CDI grants based on oldGrantId', async () => { + it('should update CDI grants based on replacedGrantId', async () => { // Create old grants const oldGrant1 = await Grant.create({ id: 3001, recipientId: 10, regionId: 1, number: 'X1', @@ -982,19 +1128,30 @@ describe('Update grants, program personnel, and recipients', () => { id: 3002, recipientId: 11, regionId: 2, number: 'X2', }); - // Create CDI grants linked to old grants - const grant1 = await Grant.create({ - id: 3003, cdi: true, oldGrantId: oldGrant1.id, number: 'X3', recipientId: 628, + // Create CDI grants + const newGrant1 = await Grant.create({ + id: 3003, cdi: true, number: 'X3', recipientId: 628, + }); + const newGrant2 = await Grant.create({ + id: 3004, cdi: true, number: 'X4', recipientId: 628, }); - const grant2 = await Grant.create({ - id: 3004, cdi: true, oldGrantId: oldGrant2.id, number: 'X4', recipientId: 628, + + // Create the replacements (normally done by processFiles), linking them to the old grants + await GrantReplacements.create({ + replacedGrantId: oldGrant1.id, + replacingGrantId: newGrant1.id, + }); + + await GrantReplacements.create({ + replacedGrantId: oldGrant2.id, + replacingGrantId: newGrant2.id, }); - await updateCDIGrantsWithOldGrantData([grant1, grant2]); + await updateCDIGrantsWithOldGrantData([newGrant1, newGrant2]); // Fetch the updated grants from the database - const updatedGrant1 = await Grant.findByPk(grant1.id); - const updatedGrant2 = await Grant.findByPk(grant2.id); + const updatedGrant1 = await Grant.findByPk(newGrant1.id); + const updatedGrant2 = await Grant.findByPk(newGrant2.id); expect(updatedGrant1.recipientId).toEqual(10); expect(updatedGrant1.regionId).toEqual(1); diff --git a/src/migrations/20241016000000-cleanup-old-table-referances.js b/src/migrations/20241016000000-cleanup-old-table-referances.js new file mode 100644 index 0000000000..9eeb5bb4f8 --- /dev/null +++ b/src/migrations/20241016000000-cleanup-old-table-referances.js @@ -0,0 +1,64 @@ +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); + /* + Dropping old referances: + ZALNoTruncateFActivityReportObjectiveRoles + ZALNoTruncateFGrantGoals + ZALNoTruncateFObjectiveRoles + ZALNoTruncateFTopicGoals + ZALNoDeleteFActivityReportObjectiveRoles + ZALNoDeleteFGrantGoals + ZALNoDeleteFObjectiveRoles + ZALNoDeleteFTopicGoals + ZALNoUpdateFActivityReportObjectiveRoles + ZALNoUpdateFGrantGoals + ZALNoUpdateFObjectiveRoles + ZALNoUpdateFTopicGoals + */ + return queryInterface.sequelize.query(` + DO + $$ + DECLARE + drop_stmt text; + BEGIN + FOR drop_stmt IN + WITH all_tables AS ( + SELECT table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('information_schema', 'pg_catalog') + ), + all_functions AS ( + SELECT routine_name + FROM information_schema.routines + WHERE routine_type = 'FUNCTION' AND specific_schema NOT IN ('information_schema', 'pg_catalog') + ) + SELECT 'DROP FUNCTION "' || f.routine_name || '"();' AS drop_statement + FROM all_functions f + LEFT JOIN all_tables t + ON f.routine_name LIKE 'ZALNoTruncateF' || t.table_name + OR f.routine_name LIKE 'ZALNoDeleteF' || t.table_name + OR f.routine_name LIKE 'ZALNoUpdateF' || t.table_name + WHERE t.table_name IS NULL + AND (f.routine_name LIKE 'ZALNoTruncateF%' + OR f.routine_name LIKE 'ZALNoDeleteF%' + OR f.routine_name LIKE 'ZALNoUpdateF%') + AND f.routine_name NOT LIKE '%DDL' + LOOP + EXECUTE drop_stmt; + END LOOP; + END + $$; + `); + }); + }, + + async down() { + // no rollbacks + }, +}; diff --git a/src/migrations/20241017203311-populate-grant-replacements.js b/src/migrations/20241017203311-populate-grant-replacements.js new file mode 100644 index 0000000000..b81d0c2fed --- /dev/null +++ b/src/migrations/20241017203311-populate-grant-replacements.js @@ -0,0 +1,137 @@ +const { prepMigration } = require('../lib/migration'); + +module.exports = { + up: async (queryInterface, Sequelize) => queryInterface.sequelize.transaction( + async (transaction) => { + await prepMigration(queryInterface, transaction, __filename); + // Create GrantReplacementTypes table + await queryInterface.createTable('GrantReplacementTypes', { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: Sequelize.TEXT, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + mapsTo: { + type: Sequelize.INTEGER, + references: { + model: 'GrantReplacementTypes', + key: 'id', + }, + allowNull: true, + }, + }, { transaction }); + + // Create initial GrantReplacementTypes from distinct inactivation reasons + await queryInterface.sequelize.query(/* sql */` + INSERT INTO "GrantReplacementTypes" ("name") + SELECT DISTINCT gr."inactivationReason" + FROM "Grants" gr + WHERE gr."inactivationReason" IS NOT NULL; + `, { transaction }); + + // Create GrantReplacement table + await queryInterface.createTable('GrantReplacements', { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + replacedGrantId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Grants', + key: 'id', + }, + }, + replacingGrantId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Grants', + key: 'id', + }, + }, + grantReplacementTypeId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'GrantReplacementTypes', + key: 'id', + }, + }, + replacementDate: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }, { transaction }); + + await queryInterface.sequelize.query(/* sql */` + INSERT INTO "GrantReplacements" ( + "replacedGrantId", + "replacingGrantId", + "replacementDate", + "grantReplacementTypeId", + "createdAt", + "updatedAt" + ) + SELECT + gr1."oldGrantId" AS "replacedGrantId", + gr1."id" AS "replacingGrantId", + gr2."inactivationDate" AS "replacementDate", + grt.id AS "grantReplacementTypeId", + gr1."createdAt", + gr1."updatedAt" + FROM "Grants" gr1 + JOIN "Grants" gr2 + ON gr1."oldGrantId" = gr2.id + LEFT JOIN "GrantReplacementTypes" grt + ON gr1."inactivationReason"::text = grt.name + WHERE gr1."oldGrantId" IS NOT NULL; + `, { transaction }); + + await queryInterface.removeColumn('Grants', 'oldGrantId', { transaction }); + }, + ), + + down: async (queryInterface, Sequelize) => queryInterface.sequelize.transaction( + async (transaction) => { + await prepMigration(queryInterface, transaction, __filename); + await queryInterface.addColumn('Grants', 'oldGrantId', { + type: Sequelize.INTEGER, + allowNull: true, + }, { transaction }); + + await queryInterface.dropTable('GrantReplacements', { transaction }); + await queryInterface.dropTable('GrantReplacementTypes', { transaction }); + }, + ), +}; diff --git a/src/migrations/20241017203312-create-grant-relationship-to-active.js b/src/migrations/20241017203312-create-grant-relationship-to-active.js new file mode 100644 index 0000000000..baf77731e0 --- /dev/null +++ b/src/migrations/20241017203312-create-grant-relationship-to-active.js @@ -0,0 +1,91 @@ +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 */` + CREATE MATERIALIZED VIEW "GrantRelationshipToActive" AS + WITH RECURSIVE recursive_cte AS ( + -- Base query: Case 1: Select all Active grants from the "Grants" table + SELECT + g."id" AS "grantId", + g."id" AS "activeGrantId", + ARRAY[g."id"] AS "visited_grantIds" -- Initialize the array with the first grantId + FROM "Grants" g + WHERE g."status" = 'Active' + + UNION ALL + + -- Base query: Case 2: Select all inactive grants from the "Grants" table that have replaced other grants, but that have not been replaced + SELECT + g."id" AS "grantId", + NULL::int AS "activeGrantId", + ARRAY[g."id"] AS "visited_grantIds" -- Initialize the array with the first grantId + FROM "Grants" g + JOIN "GrantReplacements" gr1 + ON g.id = gr1."replacingGrantId" + LEFT JOIN "GrantReplacements" gr2 + ON g.id = gr2."replacedGrantId" + WHERE g.status != 'Active' + AND gr2.id IS NULL + + UNION ALL + + -- Base query: Case 3: Select all inactive grants from the "Grants" table that have never replaced other grants or been replaced + SELECT + g."id" AS "grantId", + NULL::int AS "activeGrantId", + ARRAY[g."id"] AS "visited_grantIds" -- Initialize the array with the first grantId + FROM "Grants" g + JOIN "GrantReplacements" gr + ON g.id = gr."replacingGrantId" + OR g.id = gr."replacedGrantId" + WHERE g.status != 'Active' + AND gr.id IS NULL + + UNION ALL + + -- Recursive query: Use an array to track visited grantIds + SELECT + g."id" AS "grantId", + rcte."activeGrantId", + "visited_grantIds" || g."id" -- Append the current grantId to the array + FROM recursive_cte rcte + JOIN "GrantReplacements" gr + ON rcte."grantId" = gr."replacingGrantId" + JOIN "Grants" g + ON g."id" = gr."replacedGrantId" + WHERE g."id" != ALL("visited_grantIds") -- Ensure the current grantId hasn't been visited + ) + SELECT DISTINCT + ROW_NUMBER() OVER (ORDER BY rcte."grantId", rcte."activeGrantId") AS "id", -- Add row number as "id" + rcte."grantId", + rcte."activeGrantId" + FROM recursive_cte rcte + WITH NO DATA; + `, { transaction }); + + await queryInterface.sequelize.query(/* sql */` + CREATE INDEX "idx_GrantRelationshipToActive_grantId_activeGrantId" + ON "GrantRelationshipToActive" ("grantId", "activeGrantId"); + `, { transaction }); + + // Initial refresh without CONCURRENTLY to populate the materialized view + await queryInterface.sequelize.query(/* sql */` + REFRESH MATERIALIZED VIEW "GrantRelationshipToActive"; + `, { transaction }); + }, + ), + + down: async (queryInterface) => queryInterface.sequelize.transaction( + async (transaction) => { + await prepMigration(queryInterface, transaction, __filename); + + await queryInterface.sequelize.query(/* sql */` + DROP MATERIALIZED VIEW IF EXISTS "GrantRelationshipToActive"; + `, { transaction }); + }, + ), +}; diff --git a/src/models/grant.js b/src/models/grant.js index 31a4238cd0..9a131dd9cf 100644 --- a/src/models/grant.js +++ b/src/models/grant.js @@ -5,27 +5,16 @@ const { afterCreate, afterUpdate, beforeDestroy, + afterDestroy, } = require('./hooks/grant'); const { GRANT_INACTIVATION_REASONS } = require('../constants'); const inactivationReasons = Object.values(GRANT_INACTIVATION_REASONS); -/** - * Grants table. Stores grants. - * - * @param {} sequelize - * @param {*} DataTypes - */ export default (sequelize, DataTypes) => { class Grant extends Model { static associate(models) { - /** - * Associations: - * grantNumberLink: GrantNumberLink.grantId - id - * grant: id - GrantNumberLink.grantId - */ - Grant.belongsTo(models.Region, { foreignKey: 'regionId', as: 'region' }); Grant.belongsTo(models.Recipient, { foreignKey: 'recipientId', as: 'recipient' }); Grant.hasMany(models.Goal, { foreignKey: 'grantId', as: 'goals' }); @@ -45,8 +34,6 @@ export default (sequelize, DataTypes) => { otherKey: 'activityReportId', as: 'activityReports', }); - Grant.hasMany(models.Grant, { foreignKey: 'oldGrantId', as: 'oldGrants' }); - Grant.belongsTo(models.Grant, { foreignKey: 'oldGrantId', as: 'grant' }); Grant.addScope('defaultScope', { include: [ @@ -66,11 +53,6 @@ export default (sequelize, DataTypes) => { number: { type: DataTypes.STRING, allowNull: false, - /* - We're not setting unique true here to allow - bulkCreate/updateOnDuplicate to properly match rows on just the id. - unique: true, - */ }, annualFundingMonth: DataTypes.STRING, cdi: { @@ -92,7 +74,6 @@ export default (sequelize, DataTypes) => { type: DataTypes.INTEGER, allowNull: false, }, - oldGrantId: DataTypes.INTEGER, deleted: { type: DataTypes.BOOLEAN, defaultValue: false, @@ -141,19 +122,13 @@ export default (sequelize, DataTypes) => { }, }, }, { - // defaultScope: { - // where: { - // deleted: false - // } - // }, - // }, - // { sequelize, modelName: 'Grant', hooks: { afterCreate: async (instance, options) => afterCreate(sequelize, instance, options), afterUpdate: async (instance, options) => afterUpdate(sequelize, instance, options), beforeDestroy: async (instance, options) => beforeDestroy(sequelize, instance, options), + afterDestroy: async (instance, options) => afterDestroy(sequelize, instance, options), }, }); return Grant; diff --git a/src/models/grantRelationshipToActive.js b/src/models/grantRelationshipToActive.js new file mode 100644 index 0000000000..72343d8566 --- /dev/null +++ b/src/models/grantRelationshipToActive.js @@ -0,0 +1,67 @@ +const { Model } = require('sequelize'); + +const suppressSuccessMessage = process.env.SUPPRESS_SUCCESS_MESSAGE === 'true'; + +export default (sequelize, DataTypes) => { + class GrantRelationshipToActive extends Model { + static associate(models) { + GrantRelationshipToActive.belongsTo(models.Grant, { foreignKey: 'grantId', as: 'grant' }); + GrantRelationshipToActive.belongsTo(models.Grant, { foreignKey: 'activeGrantId', as: 'activeGrant' }); + + models.Grant.hasMany(GrantRelationshipToActive, { foreignKey: 'grantId', as: 'grantRelationships' }); + models.Grant.hasMany(GrantRelationshipToActive, { foreignKey: 'activeGrantId', as: 'activeGrantRelationships' }); + } + + // Static method to refresh the materialized view + static async refresh() { + try { + await sequelize.query('REFRESH MATERIALIZED VIEW "GrantRelationshipToActive";'); + if (!suppressSuccessMessage) { + // eslint-disable-next-line no-console + console.log('Materialized view refreshed successfully'); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error refreshing materialized view:', error); + throw error; + } + } + } + + GrantRelationshipToActive.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + grantId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + activeGrantId: { + type: DataTypes.INTEGER, + allowNull: true, + }, + }, { + sequelize, + timestamps: false, // Disable timestamps since this is a materialized view + freezeTableName: true, // Ensures Sequelize uses the exact table name provided + modelName: 'GrantRelationshipToActive', + }); + + // Override to prevent modifications + GrantRelationshipToActive.beforeCreate(() => { + throw new Error('Insertion not allowed on materialized view'); + }); + + GrantRelationshipToActive.beforeUpdate(() => { + throw new Error('Update not allowed on materialized view'); + }); + + GrantRelationshipToActive.beforeDestroy(() => { + throw new Error('Deletion not allowed on materialized view'); + }); + + return GrantRelationshipToActive; +}; diff --git a/src/models/grantReplacement.js b/src/models/grantReplacement.js new file mode 100644 index 0000000000..8aa2079f3e --- /dev/null +++ b/src/models/grantReplacement.js @@ -0,0 +1,46 @@ +const { Model } = require('sequelize'); + +export default (sequelize, DataTypes) => { + class GrantReplacement extends Model { + static associate(models) { + GrantReplacement.belongsTo(models.GrantReplacementTypes, { foreignKey: 'grantReplacementTypeId', as: 'grantReplacementType' }); + GrantReplacement.belongsTo(models.Grant, { foreignKey: 'replacedGrantId', as: 'replacedGrant' }); + GrantReplacement.belongsTo(models.Grant, { foreignKey: 'replacingGrantId', as: 'replacingGrant' }); + + models.Grant.hasMany(GrantReplacement, { foreignKey: 'replacedGrantId', as: 'replacedGrantReplacements' }); + models.Grant.hasMany(GrantReplacement, { foreignKey: 'replacingGrantId', as: 'replacingGrantReplacements' }); + } + } + + GrantReplacement.init({ + replacedGrantId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + replacingGrantId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + grantReplacementTypeId: { + type: DataTypes.INTEGER, + allowNull: true, + }, + replacementDate: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, { + sequelize, + modelName: 'GrantReplacements', + }); + + return GrantReplacement; +}; diff --git a/src/models/grantReplacementType.js b/src/models/grantReplacementType.js new file mode 100644 index 0000000000..7c4b4cd27c --- /dev/null +++ b/src/models/grantReplacementType.js @@ -0,0 +1,39 @@ +const { Model } = require('sequelize'); + +export default (sequelize, DataTypes) => { + class GrantReplacementTypes extends Model { + static associate(models) { + GrantReplacementTypes.hasMany(models.GrantReplacements, { foreignKey: 'grantReplacementTypeId', as: 'grantReplacements' }); + GrantReplacementTypes.hasMany(models.GrantReplacementTypes, { + foreignKey: 'mapsTo', + as: 'mapsFromReplacementType', + }); + GrantReplacementTypes.belongsTo(models.GrantReplacementTypes, { + foreignKey: 'mapsTo', + as: 'mapsToReplacementType', + }); + } + } + + GrantReplacementTypes.init({ + name: { + type: DataTypes.TEXT, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + deletedAt: DataTypes.DATE, + mapsTo: DataTypes.INTEGER, + }, { + sequelize, + modelName: 'GrantReplacementTypes', + }); + + return GrantReplacementTypes; +}; diff --git a/src/models/hooks/goalStatusChange.test.js b/src/models/hooks/goalStatusChange.test.js index 67d7511782..02131d7c1b 100644 --- a/src/models/hooks/goalStatusChange.test.js +++ b/src/models/hooks/goalStatusChange.test.js @@ -53,7 +53,7 @@ describe('GoalStatusChange hooks', () => { await User.destroy({ where: { id: user.id } }); await Goal.destroy({ where: { id: goal.id }, force: true }); await GrantNumberLink.destroy({ where: { grantId: grant.id }, force: true }); - await Grant.destroy({ where: { id: grant.id } }); + await Grant.destroy({ where: { id: grant.id }, individualHooks: true }); await Recipient.destroy({ where: { id: recipient.id } }); await sequelize.close(); }); diff --git a/src/models/hooks/grant.js b/src/models/hooks/grant.js index 9e419ed396..b860868673 100644 --- a/src/models/hooks/grant.js +++ b/src/models/hooks/grant.js @@ -6,12 +6,20 @@ import { const afterCreate = async (sequelize, instance, options) => { await Promise.all([ syncGrantNumberLink(sequelize, instance, options, 'number'), + sequelize.models.GrantRelationshipToActive.refresh(), ]); }; +const checkStatusChangeAndRefresh = async (sequelize, instance) => { + if (instance.changed('status')) { + await sequelize.models.GrantRelationshipToActive.refresh(); + } +}; + const afterUpdate = async (sequelize, instance, options) => { await Promise.all([ syncGrantNumberLink(sequelize, instance, options, 'number'), + checkStatusChangeAndRefresh(sequelize, instance), ]); }; @@ -21,8 +29,13 @@ const beforeDestroy = async (sequelize, instance, options) => { ]); }; +const afterDestroy = async (sequelize, instance, options) => { + await sequelize.models.GrantRelationshipToActive.refresh(); +}; + export { afterCreate, afterUpdate, beforeDestroy, + afterDestroy, }; diff --git a/src/models/index.js b/src/models/index.js index 237dfaf310..af2a7a9247 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -87,7 +87,9 @@ fs if (modelDef && modelDef.default) { const model = modelDef.default(sequelize, Sequelize); db[model.name] = model; - if (model.name !== 'RequestErrors') { + // GrantRelationshipToActive is exluded here because it is a materialized view, + // so we don't want a ZAL created for it. + if (model.name !== 'RequestErrors' && model.name !== 'GrantRelationshipToActive') { const auditModel = audit.generateAuditModel(sequelize, model); db[auditModel.name] = auditModel; } diff --git a/src/scopes/activityReport/index.test.js b/src/scopes/activityReport/index.test.js index f7b5687190..383ea6d382 100644 --- a/src/scopes/activityReport/index.test.js +++ b/src/scopes/activityReport/index.test.js @@ -3519,6 +3519,7 @@ describe('filtersToScopes', () => { await Grant.destroy({ where: { id: grant.id }, + individualHooks: true, }); await Recipient.destroy({ diff --git a/src/scopes/goals/index.test.js b/src/scopes/goals/index.test.js index bade8276a8..4e41915d4a 100644 --- a/src/scopes/goals/index.test.js +++ b/src/scopes/goals/index.test.js @@ -1366,6 +1366,7 @@ describe('goal filtersToScopes', () => { where: { id: greatGrant.id, }, + individualHooks: true, }); await Recipient.destroy({ diff --git a/src/scopes/grants/activeWithin.js b/src/scopes/grants/activeWithin.js index d186d9f77b..41668beb05 100644 --- a/src/scopes/grants/activeWithin.js +++ b/src/scopes/grants/activeWithin.js @@ -11,7 +11,10 @@ export function activeBefore(dates) { ], []); return { - [Op.or]: scopes, + where: { + [Op.or]: scopes, + }, + include: [], }; } @@ -22,18 +25,21 @@ export function activeAfter(dates) { endDate: { [Op.gte]: new Date(date), }, - [Op.or]: [{ - inactivationDate: { - [Op.gte]: new Date(date), + [Op.or]: [ + { + inactivationDate: { [Op.gte]: new Date(date) }, + }, + { + inactivationDate: null, }, - }, { - inactivationDate: null, - }], + ], }, ], []); return { - [Op.or]: scopes, + where: { + [Op.or]: scopes, + }, }; } @@ -57,18 +63,17 @@ export function activeWithinDates(dates) { endDate: { [Op.gte]: new Date(sd), }, - [Op.or]: [{ - inactivationDate: { - [Op.gte]: new Date(sd), - }, - }, { - inactivationDate: null, - }], + [Op.or]: [ + { inactivationDate: { [Op.gte]: new Date(sd) } }, + { inactivationDate: null }, + ], }, ]; }, []); return { - [Op.or]: scopes, + where: { + [Op.or]: scopes, + }, }; } diff --git a/src/scopes/grants/goalName.test.js b/src/scopes/grants/goalName.test.js index 9aacce15fe..d59c446d29 100644 --- a/src/scopes/grants/goalName.test.js +++ b/src/scopes/grants/goalName.test.js @@ -72,7 +72,8 @@ describe('goalName', () => { model: Grant.unscoped(), as: 'grants', required: true, - where: { [Op.and]: [scope.grant, { id: goalNameFilterPossibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: goalNameFilterPossibleIds }] }, + include: scope.grant.include, }, ], }); @@ -81,6 +82,7 @@ describe('goalName', () => { expect(found.grants.length).toBe(1); expect(found.grants.map((f) => f.id)).toContain(grantForGoalIncluded.id); }); + it('filters out', async () => { const filters = { 'goalName.nctn': '_pig' }; const scope = await filtersToScopes(filters); @@ -91,7 +93,8 @@ describe('goalName', () => { model: Grant.unscoped(), as: 'grants', required: true, - where: { [Op.and]: [scope.grant, { id: goalNameFilterPossibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: goalNameFilterPossibleIds }] }, + include: scope.grant.include, }, ], }); diff --git a/src/scopes/grants/goalName.ts b/src/scopes/grants/goalName.ts index 69b614a127..9e807f79b1 100644 --- a/src/scopes/grants/goalName.ts +++ b/src/scopes/grants/goalName.ts @@ -22,17 +22,21 @@ export function withGoalName(searchText: string[]) { const search = [`${searchText.map((st) => st.toLowerCase())}`]; return { - [Op.or]: [ - filterAssociation(goalNameIncludeExclude(true), search, false, 'LIKE'), - ], + where: { + [Op.or]: [ + filterAssociation(goalNameIncludeExclude(true), search, false, 'LIKE'), + ], + }, }; } export function withoutGoalName(searchText: string[]) { const search = [`${searchText.map((st) => st.toLowerCase())}`]; return { - [Op.and]: [ - filterAssociation(goalNameIncludeExclude(false), search, false, 'NOT LIKE'), - ], + where: { + [Op.and]: [ + filterAssociation(goalNameIncludeExclude(false), search, false, 'NOT LIKE'), + ], + }, }; } diff --git a/src/scopes/grants/grantStatus.js b/src/scopes/grants/grantStatus.js index 77736976a7..7a19bd454e 100644 --- a/src/scopes/grants/grantStatus.js +++ b/src/scopes/grants/grantStatus.js @@ -19,20 +19,24 @@ const grantStatusSql = (grantStatus, notWithin) => { export function withGrantStatus(cdiStatus) { return { - id: { - [Op.in]: sequelize.literal(`( - ${grantStatusSql(cdiStatus[0], false)} - )`), + where: { + id: { + [Op.in]: sequelize.literal(`( + ${grantStatusSql(cdiStatus[0], false)} + )`), + }, }, }; } export function withoutGrantStatus(cdiStatus) { return { - id: { - [Op.in]: sequelize.literal(`( - ${grantStatusSql(cdiStatus[0], true)} - )`), + where: { + id: { + [Op.in]: sequelize.literal(`( + ${grantStatusSql(cdiStatus[0], true)} + )`), + }, }, }; } diff --git a/src/scopes/grants/group.ts b/src/scopes/grants/group.ts index a26e921fd6..916c562f33 100644 --- a/src/scopes/grants/group.ts +++ b/src/scopes/grants/group.ts @@ -5,7 +5,7 @@ import { idClause } from '../utils'; const constructLiteral = (query: string[], userId: number): string => { const where = idClause(query); return sequelize.literal(`( - SELECT DISTINCT "grantId" + SELECT DISTINCT "grantId" FROM "GroupGrants" gg JOIN "Groups" g ON gg."groupId" = g."id" @@ -25,8 +25,10 @@ const constructLiteral = (query: string[], userId: number): string => { */ export function withGroup(query: string[], userId: number): WhereOptions { return { - id: { - [Op.in]: constructLiteral(query, userId), + where: { + id: { + [Op.in]: constructLiteral(query, userId), + }, }, }; } @@ -39,8 +41,10 @@ export function withGroup(query: string[], userId: number): WhereOptions { */ export function withoutGroup(query: string[], userId: number): WhereOptions { return { - id: { - [Op.notIn]: constructLiteral(query, userId), + where: { + id: { + [Op.notIn]: constructLiteral(query, userId), + }, }, }; } diff --git a/src/scopes/grants/index.js b/src/scopes/grants/index.js index a6d1b48ceb..b302a418b1 100644 --- a/src/scopes/grants/index.js +++ b/src/scopes/grants/index.js @@ -1,4 +1,5 @@ /* eslint-disable import/prefer-default-export */ +import sequelize, { Op } from 'sequelize'; import { map, pickBy } from 'lodash'; import { activeBefore, activeAfter, activeWithinDates } from './activeWithin'; import { withRegion, withoutRegion } from './region'; @@ -75,7 +76,7 @@ export function grantsFiltersToScopes(filters, options, userId) { return condition in topicToQuery[topic]; }); - return map(validFilters, (query, topicAndCondition) => { + const scopes = map(validFilters, (query, topicAndCondition) => { const [topic, condition] = topicAndCondition.split('.'); if ((topic === 'startDate' || topic === 'endDate') && isSubset) { @@ -84,4 +85,12 @@ export function grantsFiltersToScopes(filters, options, userId) { return topicToQuery[topic][condition]([query].flat(), options, userId); }); + + const whereClauses = scopes.map((scope) => scope.where); + const includeClauses = scopes.map((scope) => scope.include).filter(Boolean); + + return { + where: whereClauses.length > 0 ? { [Op.and]: whereClauses } : {}, + include: includeClauses.length > 0 ? includeClauses : [], + }; } diff --git a/src/scopes/grants/index.test.js b/src/scopes/grants/index.test.js index e4955f22aa..acff54bc0a 100644 --- a/src/scopes/grants/index.test.js +++ b/src/scopes/grants/index.test.js @@ -440,7 +440,7 @@ describe('grant filtersToScopes', () => { const filters = { 'startDate.bef': '2022/07/31' }; const scope = await filtersToScopes(filters, { grant: { subset: true } }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(3); expect(found.map((f) => f.id)) @@ -451,7 +451,7 @@ describe('grant filtersToScopes', () => { const filters = { 'startDate.aft': '2022/07/31' }; const scope = await filtersToScopes(filters, { grant: { subset: true } }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(3); expect(found.map((f) => f.id)) @@ -462,7 +462,7 @@ describe('grant filtersToScopes', () => { const filters = { 'startDate.aft': '2022/07/12' }; const scope = await filtersToScopes(filters, { grant: { subset: true } }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(5); expect(found.map((f) => f.id)) @@ -475,8 +475,9 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters, { grant: { subset: true } }); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: possibleIds }], + [Op.and]: [scope.grant.where, { id: possibleIds }], }, + include: scope.grant.include, }); expect(found.length).toBe(2); expect(found.map((f) => f.id)) @@ -488,8 +489,9 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters, { grant: { subset: true } }); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: possibleIds }], + [Op.and]: [scope.grant.where, { id: possibleIds }], }, + include: scope.grant.include, }); expect(found.length).toBe(2); expect(found.map((f) => f.id)) @@ -503,7 +505,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grants.map((g) => g.id) }], + [Op.and]: [scope.grant.where, { id: grants.map((g) => g.id) }], }, }); expect(found.length).toBe(2); @@ -517,7 +519,7 @@ describe('grant filtersToScopes', () => { const filters = { 'region.in': [3] }; const scope = await filtersToScopes(filters, 'grant'); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(2); expect(found.map((f) => f.id)) @@ -533,7 +535,7 @@ describe('grant filtersToScopes', () => { { model: Grant, as: 'grants', - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }, ], }); @@ -548,7 +550,7 @@ describe('grant filtersToScopes', () => { { model: Grant, as: 'grants', - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }, ], }); @@ -565,7 +567,7 @@ describe('grant filtersToScopes', () => { const filters = { 'programSpecialist.ctn': 'Darcy' }; const scope = await filtersToScopes(filters); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(2); expect(found.map((f) => f.id)).toContain(recipients[2].id, recipients[5].id); @@ -574,7 +576,7 @@ describe('grant filtersToScopes', () => { const filters = { 'programSpecialist.nctn': 'Darcy' }; const scope = await filtersToScopes(filters); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(4); const recips = found.map((f) => f.id); @@ -593,7 +595,7 @@ describe('grant filtersToScopes', () => { { model: Grant, as: 'grants', - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }, ], }); @@ -608,7 +610,7 @@ describe('grant filtersToScopes', () => { { model: Grant, as: 'grants', - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }, ], }); @@ -624,7 +626,7 @@ describe('grant filtersToScopes', () => { const filters = { 'grantNumber.ctn': specialGrantNumber }; const scope = await filtersToScopes(filters); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(1); expect(found.map((f) => f.id)).toContain(recipients[0].id); @@ -633,7 +635,7 @@ describe('grant filtersToScopes', () => { const filters = { 'grantNumber.nctn': specialGrantNumber }; const scope = await filtersToScopes(filters); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(5); const recips = found.map((f) => f.id); @@ -650,7 +652,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ attributes: ['id', 'stateCode'], - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(1); expect(found.map((f) => f.id)).toContain(recipients[0].id); @@ -663,7 +665,7 @@ describe('grant filtersToScopes', () => { const filters = { 'group.in': [String(group.id)] }; const scope = await filtersToScopes(filters, { userId: mockUser.id }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(2); @@ -676,7 +678,7 @@ describe('grant filtersToScopes', () => { const filters = { 'group.in': [String(publicGroup.id)] }; const scope = await filtersToScopes(filters, { userId: mockUser.id }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(2); @@ -689,7 +691,7 @@ describe('grant filtersToScopes', () => { const filters = { 'group.nin': [String(group.id)] }; const scope = await filtersToScopes(filters, { userId: mockUser.id }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(4); @@ -704,7 +706,7 @@ describe('grant filtersToScopes', () => { const filters = { 'group.nin': [String(publicGroup.id)] }; const scope = await filtersToScopes(filters, { userId: mockUser.id }); const found = await Grant.findAll({ - where: { [Op.and]: [scope.grant, { id: possibleIds }] }, + where: { [Op.and]: [scope.grant.where, { id: possibleIds }] }, }); expect(found.length).toBe(4); @@ -790,7 +792,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grantIds }], + [Op.and]: [scope.grant.where, { id: grantIds }], }, }); expect(found.length).toBe(1); @@ -802,7 +804,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grantIds }], + [Op.and]: [scope.grant.where, { id: grantIds }], }, }); expect(found.length).toBe(1); @@ -814,7 +816,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grantIds }], + [Op.and]: [scope.grant.where, { id: grantIds }], }, }); expect(found.length).toBe(1); @@ -826,7 +828,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grantIds }], + [Op.and]: [scope.grant.where, { id: grantIds }], }, }); expect(found.length).toBe(1); @@ -838,7 +840,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grantIds }], + [Op.and]: [scope.grant.where, { id: grantIds }], }, }); expect(found.length).toBe(1); @@ -850,7 +852,7 @@ describe('grant filtersToScopes', () => { const scope = await filtersToScopes(filters); const found = await Grant.findAll({ where: { - [Op.and]: [scope.grant, { id: grantIds }], + [Op.and]: [scope.grant.where, { id: grantIds }], }, }); expect(found.length).toBe(2); diff --git a/src/scopes/grants/programType.js b/src/scopes/grants/programType.js index f63d2ddd7f..ac6caf7454 100644 --- a/src/scopes/grants/programType.js +++ b/src/scopes/grants/programType.js @@ -13,16 +13,20 @@ function subQuery(baseQuery, searchTerms, operator, comparator) { export function withProgramTypes(types) { return { - [Op.or]: [ - filterAssociation(programTypeFilter, types, false, subQuery, '='), - ], + where: { + [Op.or]: [ + filterAssociation(programTypeFilter, types, false, subQuery, '='), + ], + }, }; } export function withoutProgramTypes(types) { return { - [Op.and]: [ - filterAssociation(programTypeFilter, types, true, subQuery, '='), - ], + where: { + [Op.and]: [ + filterAssociation(programTypeFilter, types, true, subQuery, '='), + ], + }, }; } diff --git a/src/scopes/grants/recipient.js b/src/scopes/grants/recipient.js index ee449f5748..732c3f01e9 100644 --- a/src/scopes/grants/recipient.js +++ b/src/scopes/grants/recipient.js @@ -11,16 +11,20 @@ WHERE "Recipients"."name"`; export function withRecipientName(name) { return { - [Op.or]: [ - filterAssociation(recipientNameFilter, name, false), - ], + where: { + [Op.or]: [ + filterAssociation(recipientNameFilter, name, false), + ], + }, }; } export function withoutRecipientName(name) { return { - [Op.and]: [ - filterAssociation(recipientNameFilter, name, true), - ], + where: { + [Op.and]: [ + filterAssociation(recipientNameFilter, name, true), + ], + }, }; } diff --git a/src/scopes/grants/recipientsWithoutTTA.js b/src/scopes/grants/recipientsWithoutTTA.js index 179f940413..81d723e40c 100644 --- a/src/scopes/grants/recipientsWithoutTTA.js +++ b/src/scopes/grants/recipientsWithoutTTA.js @@ -33,8 +33,10 @@ export function noActivityWithin(dates) { const [startActivityDate, endActivityDate] = dates[0].split('-'); return { - id: { - [Op.in]: grantsMissingActivitySql(startActivityDate, endActivityDate), + where: { + id: { + [Op.in]: grantsMissingActivitySql(startActivityDate, endActivityDate), + }, }, }; } diff --git a/src/scopes/grants/region.js b/src/scopes/grants/region.js index 1d7fdfda36..bc3c98a5eb 100644 --- a/src/scopes/grants/region.js +++ b/src/scopes/grants/region.js @@ -2,16 +2,20 @@ import { Op } from 'sequelize'; export function withRegion(regions) { return { - regionId: { - [Op.in]: regions, + where: { + regionId: { + [Op.in]: regions, + }, }, }; } export function withoutRegion(regions) { return { - regionId: { - [Op.in]: regions, + where: { + regionId: { + [Op.in]: regions, + }, }, }; } diff --git a/src/scopes/grants/stateCode.js b/src/scopes/grants/stateCode.js index 070c207ec2..831c1f1d23 100644 --- a/src/scopes/grants/stateCode.js +++ b/src/scopes/grants/stateCode.js @@ -3,8 +3,10 @@ import { Op } from 'sequelize'; export function withStateCode(stateCodes) { return { - stateCode: { - [Op.in]: stateCodes, + where: { + stateCode: { + [Op.in]: stateCodes, + }, }, }; } diff --git a/src/scopes/grants/utils.ts b/src/scopes/grants/utils.ts index 911db84d4c..c2f9ddbeba 100644 --- a/src/scopes/grants/utils.ts +++ b/src/scopes/grants/utils.ts @@ -23,7 +23,9 @@ export function expandArrayContains(key: string, array: string[], exclude: boole }); return { - [Op.or]: scopes, + where: { + [Op.or]: scopes, + }, }; } diff --git a/src/scopes/index.js b/src/scopes/index.js index ad1a52fd6d..7d95bd10d2 100644 --- a/src/scopes/index.js +++ b/src/scopes/index.js @@ -19,9 +19,9 @@ const models = { * * an object roughly like this * { - * activityReport: SEQUELIZE OP, - * grant: SEQUELIZE OP, - * recipient: SEQUELIZE OP, + * activityReport: { where: SEQUELIZE OP, include: SEQUELIZE INCLUDE }, + * grant: { where: SEQUELIZE OP, include: SEQUELIZE INCLUDE }, + * recipient: { where: SEQUELIZE OP, include: SEQUELIZE INCLUDE }, * } * * options is right now only { @@ -54,3 +54,28 @@ export default async function filtersToScopes(filters, options) { return scopes; }, {}); } + +/** + * Merges the provided includes with the required includes, ensuring no duplicates. + * It is considered duplicate if it has the same value for `as`. + * + * @param {Array} includes - The initial array of Sequelize includes. + * @param {Array} requiredIncludes - The array of required Sequelize includes + * that must be present. + * @returns {Array} - The merged array of includes. + */ +export const mergeIncludes = (includes, requiredIncludes) => { + if (!includes || !includes.length || includes.filter(Boolean).length < 1) { + return requiredIncludes; + } + + const outIncludes = [...includes]; + + requiredIncludes.forEach((requiredInclude) => { + if (!outIncludes.some((include) => include.as && include.as === requiredInclude.as)) { + outIncludes.push(requiredInclude); + } + }); + + return outIncludes; +}; diff --git a/src/services/activityReports.js b/src/services/activityReports.js index 532f614b70..6cf96ae449 100644 --- a/src/services/activityReports.js +++ b/src/services/activityReports.js @@ -17,6 +17,7 @@ import { ActivityRecipient, File, Grant, + GrantReplacements, Recipient, OtherEntity, Goal, @@ -638,9 +639,38 @@ export async function activityReports( [sequelize.col('grant.recipient.name'), sortDir], [sequelize.col('otherEntity.name'), sortDir], ], - include: [{ - model: Grant, as: 'grant', required: false, - }], + include: [ + { + model: Grant, + as: 'grant', + required: false, + attributes: [ + 'id', + 'number', + 'cdi', + 'status', + 'granteeName', + 'recipientId', + 'name', + 'inactivationDate', + 'inactivationReason', + ], + include: [ + { + model: GrantReplacements, + as: 'replacedGrantReplacements', + required: false, + attributes: ['replacedGrantId', 'replacingGrantId', 'replacementDate'], + }, + { + model: GrantReplacements, + as: 'replacingGrantReplacements', + required: false, + attributes: ['replacedGrantId', 'replacingGrantId', 'replacementDate'], + }, + ], + }, + ], }); const arots = await ActivityReportObjectiveTopic.findAll({ @@ -1088,6 +1118,12 @@ export async function possibleRecipients(regionId, activityReportId = null) { attributes: [], required: false, }, + { + model: GrantReplacements, + as: 'replacedGrantReplacements', + attributes: [], + required: false, + }, ], }, ], diff --git a/src/services/objectives.test.js b/src/services/objectives.test.js index ce18ff7bc8..fabfa3e8cc 100644 --- a/src/services/objectives.test.js +++ b/src/services/objectives.test.js @@ -535,6 +535,7 @@ describe('Objectives DB service', () => { id: grant.id, }, force: true, + individualHooks: true, }); await Recipient.destroy({ diff --git a/src/services/recipient.js b/src/services/recipient.js index 7e8e328988..25978ed55b 100644 --- a/src/services/recipient.js +++ b/src/services/recipient.js @@ -3,6 +3,7 @@ import { REPORT_STATUSES, DECIMAL_BASE } from '@ttahub/common'; import { uniq, uniqBy } from 'lodash'; import { Grant, + GrantReplacements, Recipient, CollaboratorType, Program, @@ -32,7 +33,7 @@ import { GOAL_STATUS, CREATION_METHOD, } from '../constants'; -import filtersToScopes from '../scopes'; +import filtersToScopes, { mergeIncludes } from '../scopes'; import orderGoalsBy from '../lib/orderGoalsBy'; import goalStatusByGoalName from '../widgets/goalStatusByGoalName'; import { @@ -123,6 +124,14 @@ export async function recipientsByUserId(userId) { export async function allRecipients() { return Recipient.findAll({ + where: { + [Op.or]: [ + { '$grants.replacedGrantReplacements.replacementDate$': null }, + { '$grants.replacedGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + { '$grants.replacingGrantReplacements.replacementDate$': null }, + { '$grants.replacingGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + ], + }, include: [ { attributes: ['id', 'number', 'regionId'], @@ -131,26 +140,38 @@ export async function allRecipients() { where: { [Op.and]: [ { deleted: { [Op.ne]: true } }, - { - endDate: { - [Op.gt]: '2020-08-31', - }, - }, - { - [Op.or]: [{ inactivationDate: null }, { inactivationDate: { [Op.gt]: '2020-08-31' } }], - }, + { endDate: { [Op.gt]: '2020-08-31' } }, ], }, + include: [ + { + model: GrantReplacements, + as: 'replacedGrantReplacements', + attributes: [], + }, + { + model: GrantReplacements, + as: 'replacingGrantReplacements', + attributes: [], + }, + ], }, ], }); } export async function recipientById(recipientId, grantScopes) { + const grantsWhereCondition = grantScopes?.where ? grantScopes.where : {}; return Recipient.findOne({ attributes: ['id', 'name', 'recipientType', 'uei'], where: { id: recipientId, + [Op.or]: [ + { '$grants.replacedGrantReplacements.replacementDate$': null }, + { '$grants.replacedGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + { '$grants.replacingGrantReplacements.replacementDate$': null }, + { '$grants.replacingGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + ], }, include: [ { @@ -171,25 +192,12 @@ export async function recipientById(recipientId, grantScopes) { as: 'grants', where: [{ [Op.and]: [ - { [Op.and]: grantScopes }, + { [Op.and]: grantsWhereCondition }, { deleted: { [Op.ne]: true } }, { [Op.or]: [ - { - status: 'Active', - }, - { - [Op.and]: [ - { - endDate: { - [Op.gt]: '2020-08-31', - }, - }, - { - [Op.or]: [{ inactivationDate: null }, { inactivationDate: { [Op.gt]: '2020-08-31' } }], - }, - ], - }, + { status: 'Active' }, + { [Op.and]: [{ endDate: { [Op.gt]: '2020-08-31' } }] }, ], }, ], @@ -200,6 +208,16 @@ export async function recipientById(recipientId, grantScopes) { model: Program, as: 'programs', }, + { + model: GrantReplacements, + as: 'replacedGrantReplacements', + attributes: [], + }, + { + model: GrantReplacements, + as: 'replacingGrantReplacements', + attributes: [], + }, ], }, ], @@ -246,40 +264,57 @@ export async function recipientsByName(query, scopes, sortBy, direction, offset, ], }, ], + [Op.and]: [ + { '$grants.deleted$': { [Op.ne]: true } }, + { + [Op.and]: { '$grants.regionId$': userRegions }, + }, + { + [Op.or]: [ + { + '$grants.status$': 'Active', + }, + { + [Op.and]: [ + { + '$grants.endDate$': { + [Op.gt]: '2020-08-31', + }, + }, + { + [Op.or]: [ + { '$grants.replacedGrantReplacements.replacementDate$': null }, + { '$grants.replacedGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + { '$grants.replacingGrantReplacements.replacementDate$': null }, + { '$grants.replacingGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + ], + }, + ], + }, + ], + }, + ], }, include: [{ attributes: [], model: Grant.unscoped(), as: 'grants', required: true, - where: [{ - [Op.and]: [ - { deleted: { [Op.ne]: true } }, + where: scopes.where, + include: [ + ...mergeIncludes(scopes.include, [ { - [Op.and]: { regionId: userRegions }, + model: GrantReplacements, + as: 'replacedGrantReplacements', + attributes: [], }, - { [Op.and]: scopes }, { - [Op.or]: [ - { - status: 'Active', - }, - { - [Op.and]: [ - { - endDate: { - [Op.gt]: '2020-08-31', - }, - }, - { - [Op.or]: [{ inactivationDate: null }, { inactivationDate: { [Op.gt]: '2020-08-31' } }], - }, - ], - }, - ], + model: GrantReplacements, + as: 'replacingGrantReplacements', + attributes: [], }, - ], - }], + ]), + ], }], subQuery: false, raw: true, diff --git a/src/widgets/helpers.js b/src/widgets/helpers.js index ffe0b52da8..8c7cfec2e2 100644 --- a/src/widgets/helpers.js +++ b/src/widgets/helpers.js @@ -3,11 +3,13 @@ import { REPORT_STATUSES, TRAINING_REPORT_STATUSES, REASONS } from '@ttahub/comm import { ActivityReport, Grant, + GrantReplacements, Recipient, SessionReportPilot, Topic, sequelize, } from '../models'; +import { mergeIncludes } from '../scopes'; export const getAllTopicsForWidget = async () => Topic.findAll({ attributes: ['id', 'name', 'deletedAt'], @@ -61,25 +63,32 @@ export function baseTRScopes(scopes) { export async function getAllRecipientsFiltered(scopes) { return Recipient.findAll({ attributes: [ - [sequelize.fn('DISTINCT', sequelize.col('"Recipient"."id"')), 'id'], + [sequelize.fn('DISTINCT', sequelize.col('"Recipient"."id"')), 'id'], // This is required for scopes. + [sequelize.col('grants.regionId'), 'regionId'], ], raw: true, + where: { + '$grants.endDate$': { [Op.gt]: '2020-08-31' }, + '$grants.deleted$': { [Op.ne]: true }, + [Op.or]: [ + { '$grants.replacedGrantReplacements.replacementDate$': null }, + { '$grants.replacedGrantReplacements.replacementDate$': { [Op.gt]: '2020-08-31' } }, + ], + }, include: [ { - attributes: ['regionId'], // This is required for scopes. - model: Grant, + model: Grant.unscoped(), as: 'grants', required: true, - where: { - [Op.and]: [ - scopes.grant, - { endDate: { [Op.gt]: '2020-08-31' } }, - { deleted: { [Op.ne]: true } }, - { - [Op.or]: [{ inactivationDate: null }, { inactivationDate: { [Op.gt]: '2020-08-31' } }], - }, - ], - }, + attributes: [], + where: scopes.grant.where, + include: mergeIncludes(scopes.grant.include, [ + { + model: GrantReplacements, + as: 'replacedGrantReplacements', + attributes: [], + }, + ]), }, ], }); diff --git a/src/widgets/trHoursOfTrainingByNationalCenter.test.js b/src/widgets/trHoursOfTrainingByNationalCenter.test.js index a3c41e1cac..ec18b22c00 100644 --- a/src/widgets/trHoursOfTrainingByNationalCenter.test.js +++ b/src/widgets/trHoursOfTrainingByNationalCenter.test.js @@ -250,6 +250,7 @@ describe('TR hours of training by national center', () => { where: { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], }, + individualHooks: true, }); // delete recipients diff --git a/src/widgets/trOverview.test.js b/src/widgets/trOverview.test.js index f9614c37d5..94c754ec98 100644 --- a/src/widgets/trOverview.test.js +++ b/src/widgets/trOverview.test.js @@ -210,6 +210,7 @@ describe('TR overview widget', () => { where: { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], }, + individualHooks: true, }); // delete recipients @@ -232,9 +233,11 @@ describe('TR overview widget', () => { it('filters and calculates training report', async () => { // Confine this to the grants and reports that we created const scopes = { - grant: [ - { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id] }, - ], + grant: { + where: [ + { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id] }, + ], + }, trainingReport: [ { id: [trainingReport1.id, trainingReport2.id, trainingReport3.id] }, ], diff --git a/src/widgets/trReasonlist.test.js b/src/widgets/trReasonlist.test.js index 976438633a..885e74ad89 100644 --- a/src/widgets/trReasonlist.test.js +++ b/src/widgets/trReasonlist.test.js @@ -223,6 +223,7 @@ describe('TR reason list', () => { where: { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], }, + individualHooks: true, }); // delete recipients diff --git a/src/widgets/trSessionsByTopics.test.js b/src/widgets/trSessionsByTopics.test.js index 725b12bcef..dbad9f0cd9 100644 --- a/src/widgets/trSessionsByTopics.test.js +++ b/src/widgets/trSessionsByTopics.test.js @@ -248,6 +248,7 @@ describe('TR sessions by topic', () => { where: { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], }, + individualHooks: true, }); // delete recipients diff --git a/temp/grant_award.xml b/temp/grant_award.xml index 0ac0f46d2b..55c265c4b1 100644 --- a/temp/grant_award.xml +++ b/temp/grant_award.xml @@ -6,11 +6,13 @@ 02CH01105 CO 2014-03-01 - 2025-02-28 + 2024-02-28 February CH Agency 1, Inc. Inactive + 2022-07-31 + Replaced 8110 @@ -23,7 +25,27 @@ March CH Agency 1, Inc. - Inactive + Inactive + + + + + + 9999 + 1335 + 2 + 02CH09999 + MS + 2014-07-01 + 2020-10-30 + March + CH + Agency 1, Inc. + Inactive @@ -287,4 +309,4 @@ - \ No newline at end of file + diff --git a/temp/grant_award_replacement.xml b/temp/grant_award_replacement.xml index 59b72a16f7..050eb05610 100644 --- a/temp/grant_award_replacement.xml +++ b/temp/grant_award_replacement.xml @@ -6,6 +6,16 @@ 10-01-2021 DRS Non-Competitive Continuation + + + 7842 + 9999 + 10-01-2021 + DRS Non-Competitive Continuation + 2591 11835 @@ -18,4 +28,4 @@ 10-03-2021 DRS Non-Competitive Continuation - \ No newline at end of file +