diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index a255266227..8dcfea4f0d 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 +xLrRS-MsaNxdh-0g3vDCdSdw4czL5CSLMIDDfdOkAadZLEnAgI0IXmSU4ZW1GCcAr_zz2m0l00YI0CcZcR7v91qIw6v-Q3GQtRZyZN50ULdaKHm_YS4TGMcyf-GsXmMBxn7VnG1dEKpZX99x5cyXv54Si_S8xm45HPmZ3Fd37iQ4yXqnNYKvO2p-EOhZEFwMq1JIKv8J6l_koo__v-JFdsdFhtSeUOyXO_4NKHp_8aKv5RKXY-AApad2qHtY3vSmGNi4CMS_H_7J6ANnSOmmXncayN7yTGOnf833z9iepYZ0_ALPuFBi_EpoxCtfsLLyz4AyPqT_H6aK4xw3z0npn1y6QhnjIY5OLNmQWqQoFQ53_4wCWatzX48Xy0cFZsEE2iWuAFOAU_4p_W_1C3wECRd__Hz4-NAV-fV_9Ofi_0Jpa_sUaZj8nq6S_tqbWtIWrZi6wUDW4QSmXsEbenWJBkfm-1EFOiO1Brby74DS5dredf8y1xU40av-MuZsu76ESlWOE6nH3WTgIJ52SF3z_hxlVa50VevWdbv2jYUOJG11aF06GXHkFZ0Z8B-6nJu774eXBYWfzeFsprtZC935jCAawbe__6i8nKl8I4cJHmMGLZm7QZTY7GOYHLmsHMro54pYF_dpPW2g6wFwpr6CGQ5mbyyZoMbBnSEAf3-rh__lsREXPkfloV4M91Hb28DyfEMCjffhER80NiksSP8J1gzAjeSuFILOT8LciD7VNKB02Au7dXZYz3cGuv9W4iM8oI-3-SDPYZWKdt9BI0w1gB85EPDbBZa7Uz0G4GhHZcoovPftpK1t1sYo0_JtNtprrHyqdQdhzNJQjxS_GYdGAIdf5CFN4F1kpAP6srAIzm21IhWFEJOsKayR99U0wMCRDXi9frlz9M3v1Er_5xdE8A2RVvwBFnnSK7A7KfZsZ0DZL1s3GRVAI5DVaprAFamxSy5weBuIxODq0F_1qWU8xgVF7JR99Aw7dTG1-bR440nvFfyypZPVUAg4_Zkai1rRUuT9Lku5KDSY4bAJalsjuQbrkv6XTBeL36_GJz3czPkwizDN_tlQl7x7O1h584qHHmI3_0tX8foKA5qGqtpigj3OIxTjz3U98vPml7LpmFWxXU6t3txJ_P3008KP8dYmuKM1H-h5JsCHZKAo9xRYV15f6bx5_Qe-PlO_gvcRNWTKd6lKAGTvKeffS7fbn7jgRMajXm21q4kIj_qyXJcwW_JXDUGxadP4IqiAkFgm52Qe0Fdllzny_WUNkhPJuww_NmYbaKL-P0JV2lN0MGao6mjHEOSPI9JAcB8uTLxZYilY5jAtsmj0EKhGNiH7smQ9uZt8e408LcA89qP9Mv8z9Ha5XMWprsjQ6z_kYt-Na76f19Qb5V5g0nfMtXQ82Tsn6a--u1oQI790CyWlo5uqk51m1tlf8ZXVSE3GF_7c3b5UWlnvfGmkBMtilHul5AeKRkbr2jvMtmXJDcQ034u6f7Y7zOLqiQIYZA9HW3sx9WEpF8jWBVyTo55wFeZOTrMHgLQlccAREljKz8Czo027R_5hI3DeJDSvBU61WjaQo4LHOfH8bNz5Cj_PPsBL6vnScqK_elbcHpIFBwVrW3CuHDR9g4E5vaMv573GsltmR_SBo8nsgbcX4oFqz3JUYy6USVOMlzld13Z3LuVj46Tt47Db5ozGJlWReFo7KuWvfBATF9G4MbG6cjvcs4293Su354mH7oKRPChsnctSGZqz8R2BL6_R-1agVKJKl1OnE7j_-EcxoykpDzStrozVdrrTdxo-SFK_SezDnDtkT00q3hPTXszF4ll36iIypRcdoH4c-mhz1vfVynu-E9g_3aa86NeqnCoY1fim4OuFpR4cbXCmE73r-0iXMO9FBSkT8_p-Mv2yR_JBG3iuYna2rOVBL19yKay8B_MeZdnZFPcTrGpDOuP0UCYuZHiaVLmsmGeTiRrYFdCpcsEvJ6VsKZoyGWNA0KNyuGKbvTulXHck_nQRaQfMwGE5uk1FILwcC7svVKFuILeWx6ZaNmR06WTg6vhedUQ5BukBCkv_1-mbbZD8q9DlxKImBB3a87Yw0pZpi0SXuydIsUpfOuyTByFm_ieTCITvslEc2Gef4dtUedmXmnFTQ8o1kslWyEpHTLNFeIdSrD9KT0ZJozfB4RN9afb8y5ve0CegxAZgRSNd6mcA7S8j6xAlWs5rCB3IzUgf3FSvSMrpUZKC1qQNs0nzib6JsoZSLQrPXGx-K_7u998uJc361dPzD1-ft8pgdNAkfXblaaEaHvfoYE0cMr1FYNQ0xVHENqdwnWoSw8zVanHjqMADAFDDLJvWfALxP-v-xj9qPLT1gWj08UOXRaJ8Kl2FoLEk9FaE0JtTv36zwwuNEwUak2e0vMm78R_cu287M9_o32yNezoTPv-xZ4mY_qRcdLuX_7xPLVt-JPcrOWIFjBw1n_z2Z9NmhqyAai8ybZahdCLRGcCERdFuH4Va4kvpaC02OXx_Re_m-x-8dHR75ARodp_UaZpzYn1JF8f_RcWd92yB7BCTAVDKB6MFGPwJ-nYNUVva65cB18LRI25EO2tf4mMI9jb8NEM5exXg62zfFGGIOvyadxxmrugbcIESBSqzr4_t9HhcpDEswNHROdPTktt7-kyuhYE9Y9bQTIXeTMDvcEO8osyebUkqHhXZSxWPk95eg4tllEG2mBW4-Nc9KuIprmJpNOTaaYBNY5oXwthLZgNzOu0qZT0T_vRlhlOmGI17Z9yL0ELZXPo4gjKKOwGwnPp4z8KK0_wUMErnIb9dVST8h-aVAVSV96W_5ayp8cY_eh2z35UwUxrZlXdaHOLXUg2PjL-4qP-zbPBsy_KGkoXlSvGOQq9tWCBq7qWQ6tEVu4kPnNiEEF3DqAcyt_dmaecNJQ5Wq8rKN7mSFtdv_EpDzSlpbsVFdnmV7ydfjYNrZQfpv8-INF61jwHDNXgpMmeJGjDJKgf5OrKHbknW0RgAhNsiHtpPVwc9GdeKvV3b6kGUYpKD9rnCrlK5TF5n_5aLuKiWO_q5_1jfQWFn_VbW6rdeBzdlKhX56EbnHPbLXrXCWdfMWcpv_WfoZd1crB6wiD2Wj9mGAnLgyngmzqt5FU1TA5u3b4DgBX-w5RblxbwkfoqhjzVlJLsVq8mjK8pyqaks5o8-ZNyajuwrdq_5UaWH39tjP578ofmtIq1PcGXjrYoYZXZc40ESG1_OE4LP1ccpthaffVTAYsRJEm676Q4F6kGJrbV949iU8_SV5wuK3DioTo91CEA48fp9Wmskog80547leTe8JRZ6QYs4SlJ78wtEaNBYxn2yVvcw2RJ2NSfoDPMwrb7G02C3GgT1fT_vvk4yjrW2nYWXnLkQ0Op-Oo-__chp9sz_2nP4hO2wKZiyEcICWGrXnccP3DUWqWBK2uN7FN29qFEWoL_ReET8JeMy_SAjAZ-kAHNXMbMQUaUDoa8uJkM5AcYehQXtr8w20mDqHngLKQofrVMFmbO-IKfLegmUHgpW_CUWuuJ1yMbfGYY5KX-VGv2-GXZEZsHM2kp1lOaDwpkEPshotn4JlxrymVcg7Aw2Ihw70KryH5kyDMHgM7uSAkodR2daa1O80oEPRVH73AZOOdFNQfeZhxwWykcDkw1VtDMrvLCGFj_bvPGSecDCdBX_AfgyY0OkhNIsaNmqbRlY0AU0fe_dbB2AevVIrk9fYbfL_iISuKVKpL-TMJoeFLmDuQosULIxiKv3rcc8OyQwe9MpPyo7jt5gUb5zZdSSQlQa_XEBPgborwY07toa__OXM3VgSgFoFuRXrbfZmOvZwS9ZzPjVZw-ZVy_Ihau7gzNJ2MuPlxtgimtYegqgTe5Komlg9lkrpPZi1RPMH4fJLrBoXEWhQTK9p9o3P5UOC0Mc1bG36ZvYWCPrZr9nwA0n3rxMqdKcuuUtTI51XqN31KYky3K_TKul-VWc0eYR4Lg_NfILA1MPt4dwg-yqQnfxsZaE7ziP3E7Beop1vXxtqVETXrnRrNuA_8bVSRWFsJ-5gpf3JLSL-RJsJNN0wEwP8jorPsgaejmlrIbZDy5Zd47ehLFwkVpgv_MKfHe9AVR2l3X7FkqSuyTVkZhjUCrVpnBM2Jj9FvPjUjVM7btZ-GVOgDV5r6zRdj-6EdqRtfIJQ9VOmJlZnfRGNd3eVNxQ_lbffzPEtYQuHPJnqyeW-fHzYI_4xWAvUdMyz1l2hyGamq-EwjkyzDzJPb7KMsHHs5jYKTXNuDwzatsR9JLRqdPaNPCNcebvd5muxSpx2x5cGvx9wHU-23FYB2630-oLUFyHtvPpeJReN43A2COmd-kis_ICuxwXUQnHw1TsDefK2zW5myuPQEfOt7chY-5sg-pzQFW3EdS_ASFZKcRPlztxgk0M_UGur7lhUbntJ_U-fwejN3ll3h4DVRgprWQmh3E21tQUESzUriD8ZZV_X826RXuRkEJjGvcseGs2TF1iwX8YwvdOqV7a-5Zy1eauvFWEq0U4Cns0pDDsug6352_LCSfdb1gJcBvD2yjERWltUTXa3vi3iUs3VwIqtvBqeLcENv-lvdpet9TOKZI0Xtz4mN-xJm4GdfAQetbJIteJwdmezKsDjXUG0_C0olg9Nm2lG0LqggNRon07AddVpH5oTlJ7SEk6xrdRljmHQ_VOcgFi38lZxRgNUUuSpMndeVr-vHSTm7TC8KiesZjxzSAUvHMKRgLsEgplpigGlO6imsN8tUmKFh0UTnbMUqqGpjgyqcyT4-_pcwSSshpaUSAJ3mbkFnuLLmcXK1wK2vBTebPEX8K-j_nO0l8lxFEFAn_enlRMc92iS_j5SFiY3azowBSLzH3f69zJ89x61MHCNShHvERKCShrJjuwCzZiusq_x4OKhPFn7cSpJ3qPq3gD-GvG1AGxQEvTztO30fD2XnmK-yU-T_LfaGlxDHy-yoTOL9kZtzv1Ib4Ab-uZPSjYf4gzI_jG9x7UWRo4tSo1pjf7OsMbhYe6R647xHRes_wpqSDRyhlcr4qr7_X0rewJzz3bot7NLCAt-PhJSmFm60TWHmfuUU3Bh7PCtfL4UqoOcBwz8fcQ1YuVgN77kNAGjKdZ-3W-kRmy-USF_wh75GdUZInr9Cu549zmKl8TeJA2x3WveFv8ryLHpg3GZc3undbmav72rYGH-Mg0x-I15OXnbB1w3VcDy2bk2d4vpWsfp9Ymv-8yfvi2qFUoW1WZvP7JKVpl4kI8Fzm0naegrf3Sf83jRWcWQPqM1BcYghtW522CS7QZbbenxLsTEDV5bLbo-Q3MoVg2eBansxaN3I6ct59trcd2-ekf1urzV_AyclrvWD9E5Gdjn1w-Fpb7EOVKBzjU5jWv_PKhhGQl5_d-vDM9VQkSHtHCkEqtMWdvhCXl0EiykdteF6g171bQHDcV99koLAeX7-ah7LeCBMSRm_iRIkvlp1a6oLF7qsewTyDGvcY9L-PYZIh_ILUO1c7gMJIoGjM5oehQh11r8HgwY6N5ousN8yA_GkM6QeLe429j9Q1rWTwArW1QgrttnserLc5usH795Qm1i5QS1d5Q-vuNJ2iy5Zm3TtUl0Da0MJS2h00fW0Qlubv1Se6q0jG1Q9sLE61v_loL1oHfm8UbreSbf2W0Jcyuo3gnPr5OWjXyMkytgoqmDoY5SWjBTvcaUk_CjwcJqc-SIPaHV8AkFLzLoWNP2f05q3g0EK0PkBDy1QW6Q8rkZo6qJbC7m1c-gtl5gGUd5ICNHkxRkLU0Qe26hz5USR9n6ZhCO4NBHciTvpn0Mi5nWBE2OeFIUwFoRneVpzkNQm0FUW6pio5yANHQoxkYrQGXzVHs3wddfHn113KD2ghtGRfX_-t8gwrgG5fHwQ3IVtVkYb23qAeylT2ig5Yez5x4kyBJmhD2qmlD2ei5IyXbkByvP48klVtDmJDw4-wAaubJpnskAqnEc1mNXAv83I3bliroXMmSibN5Hl5QjSe79SUr52jIVKYjjYe8gzL8ZGAr8jIBaFksmnKb4wM-gnHNd5muhSha0T8wq0L2jVQxCPnMW7e1whdbReN93TCAaGNHkeic4A8L7XkU_ZllLe1qW5PWB636RgnRKJ9GMjVIZY6q8cCZuxvvQmL7WEEywMk5HaBJUytNYe-6pwet5YxheRJzo6IheN71iauQtC0uRhDQaQcHMK6nGB7lnrfHaQYCtRYrmkI6QUHIhXKsDsmhH3P40xTxhGWfXBgNWwt826HMxyU5YkCoi1MO6fZ-kyHMZ2oCLcmSsFJVOBPYL6EqWcA1qhrMQyL5uEA-NMl5Hc5ZtnVscyObUXAVC4OI96qlYw98pjFZey-FucVFVlZTRGwIzvjz3X3Uy1rAtcF8s5-_-DUpPxA8jWLOoDNysiXBeYEr-yoNc7q6JNmSP-BHHlrftqJHm0rQ5ZTnLHzCBNO_n_4HFeexnxB7n-AIEFFP9WQzp2B71Ja1_CTkRegFBTwYoCRnpAosk5HDgDXReAP6r7VHn6I1x2jsEZ8r1JPzBwgQ1vIjxX6Xaibw1vIhloN7PNkCDbKppmd2gOgMPDC4glDe7IbqvdMXHJWQboMwsDRl5kDcMioX3-sKNam6Tn_PnBHzknWlPGF7lbEMMeodrY_66BT55-FUtzDjvdtzEeHtiz0kFexP7YO8v8ZnvgberAh7QCzCCQBE7I0JHt9LzxYwIzT-iv7FpoafEdLcIT0vkbjuBzQpZU5T20xpM7PEV98Ua_LmOQRkbOFnSNNtWFN2oTowf2EecHste4YdpNo-wJnHvgHs6HRqFHBvtwYh4SwvPT4gh69LAxRu5NKk6WXWuN6lWvY3Y58RzMUuy2nX73aTOKpqfwoImZb72HzujdAYeWyty-Xsl3-TWUNYXNBgqtp5mgtjeJnwShPjAyYVrTHkFd5dfbCmSpsl9vIEWya3ZRJh_FVALbklDgw7BEexrthPYTzU4gsrfvtRBS1zX9-BKoXvfuGAvFhLfcAv3AFkKkqEksom-29Iw4vNF_FMuPh3i-R1JEBH_Eb-JNaJ4ofYMY3lc-PBnmumXZ8CKmUXO67wlAXDnvUQHhkZzegugVIXHErMLyAQhRoWbNAQfUbmTyof65IQDk-rWC5izAiIcYzX-jD9TANj0XJYAXQLx52UJaF3cB0rxcpLluAfTHTWYB5DF7cBuKRzTtrF57NvI7RZUn9rug3-JFlEIHDUPC-hRdHr4VxcyGO6V_DLbtzzGZFG0jCnXZ2DEOblOPp2BS8mZMy9tyKfegAT26PFHYice81vd006dqJEavQRBqs_-jENd_rvy5DBNCjn77-Ta9pGuwhQDpPjzlL_omBVk0FYmM6fAYhkY4O5qsyCInLeLnqdc8ggS2nI3-h_gbMda6eEwQrdipCfsZigbBDZF9fBcxKqfykfArIcD2XNEhn3JRW4ld2yeKhLqexUrQ_gGrdqTr6uzzmRRzXqmmvJwPjDlz4r8DApJVvahLDyhJmLCjzMFCBfcxGt6WyMMOTxXjXJg-Pnp2dACaXX-kt3flqHUPpJDycCs-SZ6eQySxxMflkogVau8ZQdxBa4jOhKxeSSTDodqCqHgD7aRKlG0L2bsylp7sopyDQQBKzp44IF3gcN6nsk0qZXv3x6TI4LRJI23VMOFeLOI8YX3kBNCnOIfAybdAprCyKMQOAi9UZJ1hFv1_Z3XOjhv3mHQ-cfGKjwqpjXM52IGKlbbcN0KCsLPhI9R5dC2ejBuXA1RPnIHGD7WSsGm6ahp5Sz_NL9ANfmSF8r3cLQQhGQDDTDmKMbS0-MzLCBTkkOdCKpGDXuwfSAcYwJdmpuW3pRxF4RYskTYhFAHst-EpndheSFSVLiq_pNXX4T8Uu5O-N4VZWQC9ybJs-ihID7jEJw8eDR6zyUNryq4EdM5czC4dKyexvn9zVdycj3fzhto_Yf_adwEIPp0rUa8VRW35zDPMQshywaLQse4mUQ5KAgBS559vIaB8VzF6f2HKj4hZQT45Qa0JqzhIpmQvRgTFFAR1gNdZI3niYPPfZ6gtRmTUhAcvz4pnt6hnxg2vkYwfo9oKPA2Ires5Q8JQ1jfXPRohfHdqItnRSYM8CRIiav3DLKINUy2UUW7JEaaTwtJoVGhFsHKlpTn3jIG4KaQiVnfTPQq-h_uHNMy_awbmVtCNUi1vulWG_ZUVpwiSrcezct1C3aqKt_J-7byXfRFbslKUrhcBSdWXAroCXw4qoxFLcb8KgRHy7KclsuLM-iTci8Yf4wb6EogM2YkcafNClJ5bnQLI_DrLTQBSwpTyMLKmPdwQXgl-LM-dFnpzYw_c2kXgfLB3HTqxVZ1ggSlTDPuYSVRQGQskyZDM8iiIz0aAzNX3NFPzAt9nqw3tEoCO-FN8TUyfrO4vm3nj_jKx7JJcInQENaX39mEQyrYwxd6ahTBpIYuF89Y8JoTos5KjowIw6cqBONvBIqZJVXj5rPuefuDqaSIdse5V02gFTEnXMBddbuA1Tpj56azS4cb5vxb8bOaKwXMdhFcgV11EsnTTEEi9DeEOncItQsnNGT8D1mkBDUHFMkyPdRI-ugCjqj3fDOD5NgqEDt3Vqwr50_6Td-Ye_YNpIUX7C7ptgCh3qnPdv6pnvIVz8ExvJvRBVI8rhUtEpT6Aopw7uHDlgQ-mEnyC9jMPQcbyH2jgRDGsuEGMi1j1qkcK7Oo0ylIhedvFqFn7Uah9TJcyyx3nULnmYswd5Jj2mZBwLsxhwRgW-9pzQ-09se0fRIVrcSV6ygP5asHk3gUaT9cUqwFkR6VDtnryptRcV98NWFku6IuxiO1uLmxJASQgroCNke5XrwqnZSpSQQERRivLVz6vv4IUSqtBqHQcf_OVvxdAQowDdiCzU8cE7iXToKJDy-CAs3sKkffnkRAhuUtCIsEAfXEodogVHmVmPyBlnr4DFCRquLLCi0Dj5-_swiueXLH52wBfbA791ydfT5zGZgr6zJHEfWvrGgwjiYr_Q9M4v9SHPlAIdYWgGmPqycxyzmxxaWGkO7cEVaVdDBSbja4jlLj0qNN_pfpv-VdtzJIVbCIFbCIjdikFZGmjRJnSUI-D1uHd-3E2sBFFf_ \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 72586ae2ee..be2bb17a5b 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -435,9 +435,30 @@ class GrantNumberLinks{ deletedAt : timestamp with time zone } +!issue='model missing for table' +class GrantReplacement #pink;line:red;line.bold;text:red { +!issue='column should not allow null' * id : integer : +!issue='column reference missing' grantReplacementTypeId : integer : REFERENCES "GrantReplacementTypes".id +!issue='column should not allow null' +!issue='column reference missing' * replacedGrantId : integer : REFERENCES "Grants".id +!issue='column should not allow null' +!issue='column reference missing' * replacingGrantId : integer : REFERENCES "Grants".id +!issue='column should not allow null' * createdAt : timestamp with time zone : now() +!issue='column should not allow null' * updatedAt : timestamp with time zone : now() + replacementDate : date +} + +class GrantReplacementTypes{ + * id : integer : +!issue='column reference missing' mapsTo : integer : REFERENCES "GrantReplacementTypes".id +!issue='column should not allow null' * createdAt : timestamp with time zone : now() +!issue='column should not allow null' * name : text +!issue='column should not allow null' * updatedAt : timestamp with time zone : now() + deletedAt : timestamp with time zone +} + 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() @@ -450,8 +471,6 @@ class Grants{ granteeName : varchar(255) grantSpecialistEmail : varchar(255) grantSpecialistName : varchar(255) - inactivationDate : timestamp with time zone - inactivationReason : enum programSpecialistEmail : varchar(255) programSpecialistName : varchar(255) startDate : timestamp with time zone @@ -1614,6 +1633,35 @@ class ZALGrantNumberLinks{ session_sig : text } +!issue='model missing for table' +class ZALGrantReplacement #pink;line:red;line.bold;text:red { +!issue='column should not allow null' * id : bigint : +!issue='column should not allow null' * data_id : bigint +!issue='column should not allow null' * dml_as : bigint +!issue='column should not allow null' * dml_by : bigint +!issue='column should not allow null' * dml_timestamp : timestamp with time zone +!issue='column should not allow null' * dml_txid : uuid +!issue='column should not allow null' * dml_type : enum + descriptor_id : integer + new_row_data : jsonb + old_row_data : jsonb + 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 ZALGrants{ * id : bigint : * data_id : bigint @@ -2481,12 +2529,16 @@ 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 +!issue='associations need to be defined both directions' +!issue='associations need to be camel case' +GrantReplacementTypes "1" --[#d54309,dashed,thickness=2]--{ "n" undefined : GrantReplacements 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" 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, replacedGrantReplacements, replacingGrantReplacements 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 @@ -2600,4 +2652,11 @@ Roles "n" }--[#black,dotted,thickness=2]--{ "n" Topics : topics, roles Roles "n" }--[#black,dotted,thickness=2]--{ "n" Users : users, roles Scopes "n" }--[#black,dotted,thickness=2]--{ "n" Users : users, scopes +!issue='association missing from models'!issue='associations need to be defined both directions' +GrantReplacementTypes o--[#yellow,bold,thickness=2]--o GrantReplacement : missing-from-model +!issue='associations need to be defined both directions' +GrantReplacementTypes o--[#yellow,bold,thickness=2]--o GrantReplacementTypes : missing-from-model +!issue='associations need to be defined both directions' +Grants o--[#yellow,bold,thickness=2]--o GrantReplacement : missing-from-model + @enduml 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/migrations/20240830172706-populate-grant-replacements.js b/src/migrations/20240830172706-populate-grant-replacements.js new file mode 100644 index 0000000000..427e865b83 --- /dev/null +++ b/src/migrations/20240830172706-populate-grant-replacements.js @@ -0,0 +1,141 @@ +const { prepMigration } = require('../lib/migration'); + +const { GRANT_INACTIVATION_REASONS } = require('../constants'); + +const inactivationReasons = Object.values(GRANT_INACTIVATION_REASONS); + +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 GrantReplacement table + await queryInterface.createTable('GrantReplacement', { + 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 "GrantReplacement" ( + "replacedGrantId", + "replacingGrantId", + "replacementDate", + "createdAt", + "updatedAt" + ) + SELECT + gr1."oldGrantId" AS "replacedGrantId", + gr1."id" AS "replacingGrantId", + gr2."inactivationDate" AS "replacementDate", + gr1."createdAt", + gr1."updatedAt" + FROM "Grants" gr1 + JOIN "Grants" gr2 + ON gr1."oldGrantId" = gr2.id + WHERE gr1."oldGrantId" IS NOT NULL; + `, { transaction }); + + await queryInterface.removeColumn('Grants', 'oldGrantId', { transaction }); + await queryInterface.removeColumn('Grants', 'inactivationDate', { transaction }); + await queryInterface.removeColumn('Grants', 'inactivationReason', { 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.addColumn('Grants', 'inactivationDate', { + type: Sequelize.DATE, + allowNull: true, + }, { transaction }); + + await queryInterface.addColumn('Grants', 'inactivationReason', { + type: Sequelize.ENUM(...inactivationReasons), + allowNull: true, + }, { transaction }); + + await queryInterface.dropTable('GrantReplacement', { transaction }); + await queryInterface.dropTable('GrantReplacementTypes', { transaction }); + }, + ), +}; diff --git a/src/migrations/20240830173101-create-grant-relationship-to-active.js b/src/migrations/20240830173101-create-grant-relationship-to-active.js new file mode 100644 index 0000000000..835b6b73db --- /dev/null +++ b/src/migrations/20240830173101-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 "GrantReplacement" gr1 + ON g.id = gr1."replacingGrantId" + LEFT JOIN "GrantReplacement" 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 "GrantReplacement" 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 "GrantReplacement" 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..7035b88f0e 100644 --- a/src/models/grant.js +++ b/src/models/grant.js @@ -5,27 +5,12 @@ 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' }); @@ -66,11 +51,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: { @@ -86,13 +66,10 @@ export default (sequelize, DataTypes) => { stateCode: DataTypes.STRING, startDate: DataTypes.DATE, endDate: DataTypes.DATE, - inactivationDate: DataTypes.DATE, - inactivationReason: DataTypes.ENUM(inactivationReasons), recipientId: { type: DataTypes.INTEGER, allowNull: false, }, - oldGrantId: DataTypes.INTEGER, deleted: { type: DataTypes.BOOLEAN, defaultValue: false, @@ -141,19 +118,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..2e91ed0544 --- /dev/null +++ b/src/models/grantRelationshipToActive.js @@ -0,0 +1,61 @@ +const { Model } = require('sequelize'); + +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";'); + console.log('Materialized view refreshed successfully'); + } catch (error) { + 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..4fee886eb4 --- /dev/null +++ b/src/models/grantReplacement.js @@ -0,0 +1,28 @@ +const { Model } = require('sequelize'); + +export default (sequelize, DataTypes) => { + class GrantReplacement extends Model { + static associate(models) { + GrantReplacement.belongsTo(models.GrantReplacementTypes, { foreignKey: 'grantReplacementTypeId' }); + GrantReplacement.belongsTo(models.Grant, { foreignKey: 'replacedGrantId' }); + GrantReplacement.belongsTo(models.Grant, { foreignKey: 'replacingGrantId' }); + + models.Grant.hasMany(GrantReplacement, { foreignKey: 'replacedGrantId', as: 'replacedGrantReplacements' }); + models.Grant.hasMany(GrantReplacement, { foreignKey: 'replacingGrantId', as: 'replacingGrantReplacements' }); + } + } + + GrantReplacement.init({ + replacedGrantId: DataTypes.INTEGER, + replacingGrantId: DataTypes.INTEGER, + grantReplacementTypeId: DataTypes.INTEGER, + replacementDate: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'GrantReplacement', + }); + + return GrantReplacement; +}; diff --git a/src/models/grantReplacementType.js b/src/models/grantReplacementType.js new file mode 100644 index 0000000000..739a6626d8 --- /dev/null +++ b/src/models/grantReplacementType.js @@ -0,0 +1,22 @@ +const { Model } = require('sequelize'); + +export default (sequelize, DataTypes) => { + class GrantReplacementTypes extends Model { + static associate(models) { + GrantReplacementTypes.hasMany(models.GrantReplacement, { foreignKey: 'grantReplacementTypeId' }); + } + } + + GrantReplacementTypes.init({ + name: DataTypes.TEXT, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + 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/scopes/activityReport/index.test.js b/src/scopes/activityReport/index.test.js index 416e6ce953..3d6878b98c 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/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/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..bf4dd8a57e 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 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