diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index af1eacfd15..7bd734cde4 100644 --- a/docs/logical_data_model.encoded +++ b/docs/logical_data_model.encoded @@ -1 +1 @@ -xLrjR-IuaVxUlq9mFcmow0cIpUM0CtA7U3oUtS7DYs5xDaY2mA0bkfiPIUoGb6UTlVpt0qcH8YbA8YMrPsR3JtQJL1NvyArOh2h-aJ90M5ELcopx9WCF61NPWU2x4bOq-uJOFWFrheH5bXFyYMRt4B9Dbj6Fg3u00ggiH3LaZmUOOSBssChAIq1fzjCcoxBi1IO59EUun2JxnUz-zvzd__KR8_rcZ_AFDQGq-tQJPVyILJddNqEwoLewPpb33uWzNi4S7H2i6VrfaptBK96TPgXcS0T9TfhzOGThI023nVziiXq1DNjj5xYwU7LnTV7k_E8wE_cEvzEJNwDYas6sX-IYPeWzZdpnNfT2iFtmMGPqpGwOZF4ximhgxtC2UONFM7QQCLH1oa1raDZpdza_SGspqwp6dtxvArw-EHJXvV-rsRZuSUPdXmF13v1CWxYyVGs5PEIh3nIIfy4YAs09fqfliXep_Ws3Fx9DHXbW3SrECrWtrH2QvxWimHqcWE5_BqG7y7Y5IlWKEDoZ4evy9QeHWqDe-uVQ_Hq6vIi4o-8AqWEkwGmGE8bW87XXtS0T1kKDh0ubO51KufBWwZ2w_lc_E0va6ManQQVMN_ysXk8LfBWX-PC2I5gU8r_hQXq78abST8LQSYHC_3_nytO4gblhyvysMGqgYRj8tmXEzuGdjYJ3gVtX_vu_7-kcuPwSxa5Cq0xLe9peEQklbguSkmXUw_PnNc8AhnjwW7LnZci-5VHcO-PTGK1nhJQU3DR5Io3s9Svaao4gMWRBnui2CSYRSeTn2G7_v1wKL9IvOWmSW2R21qItiucUqtbweti09Dy3yijlV__xNnbZHdqtC6dVt_qTYWgT8mOOmdN8pCtRiYCzeSbrFK-mReCx47GjJ8ec9-FBs8tn7jK5gdU2igBHfjrm0RXBxScTEl5TKzs99ggY5QuHmmJLkrUIrBNeXdTcHpsp-uqlLBojBs697Y1vR8T5b_c0u2U708KFa23sQ5U9Cb0NW1HkLUE4gXQ3QpMSmaO9RiYSxT7RmQDJNd_HpMV1UspCoSfzJWbArtgUABK6J5-QSALhE5ysM0EMhr1Aw2FN4nLC8O6L_0y3bmG_e08M0zB255MFoD3_PAAhR_01vP-ddnbdFVAzhDfdcG3X8bK84FscBnMky2oknptw0_AUQvgi-lnsjZIcMtWkA_qz9B2Jm0Q5ATeSE52jk219YVccUHhJJgSrLEu2FX6leV3reMWtB4-1WQ4qVmcD7mrVuO8vl4vUdB_vttET_O72OfE1ea1SjWJbEyounZAN0ubhachV1tgnzI-iuUlfn38wtRibeEo3nF35OvX6AKRtHE1kNBg_4WLcBm5SrgLIgk_e_oP-SNYMSLMeI-ZRZJ7ss-blpsVWwgJRcUsKiiU7djT0wc2dHczNx0PLiAsupI47PTvmBKvnRQahrKZibZGEC2PNkEmMeA8zHxo5R8-B7ksY8UhaLC0SLQ3yvU-sl_sLRofqcft-SRNY0r8K-Jl5zHqVUlMk0JktCVpZ4kv0BeUZNHVlV3Aurk9uzVx-4X26SdJWwdtpQEFBVrrVGaBDhlUN-m4Hre2ojCvG1x2377BOjekdMApTfXq8tX5GyN7YooQ-SIOp_j50oiIuTFOKVdzZOKsRkmfHFfoTqI0o68Dsrp10Tf1TuWCVGNmsUjWBOSYRmO1dpFES1ucNEgNwPeRUdC9r1krXynmmkCFaSVHyOfP4y-MCbkLjqo3pw5_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvR_EssTryxl9KDPcfYp3ojKZIvhWA4I7j5BU5EJJ2xaiKvmA2Wby7wizt3If0dSD-lL6D60BbRG4P91MgxQget1eGllOym5wEr83SggNBhdLNY6j2x7xZ6vxAQBysTMUy08EK3LIipUJAmrkELoEalc5aslBzThGPeytGPVgj8E2JlxzvXjFHaeVRitBzx7UCKWdTseDR9vkxIJTznVLNUx7ORebYLGpI0HeVZ-fakcAzWufWTewB_iwujYWPD2Fym4JSk2x1PEsxmPMn2RfTDHls4Z30j_aZmvRRB9jAxvfVpX0AkqvPXeDvYnXuTVWvXvCN9ocj7nkfqDPAcKvGLmFgs01S7s2EG8aeEpO8L7FwWjSSFqOC7kYJB2gSqql802eZRvyYBXKretDS7JWaBwFbE2zAHr4Xn2i6OZQrOso0WqUgu0uQEQ4NJSxquLkVoM78uASvGGTBH3MFwxYvAs5s830NMfB-gNmFgDfUJHbu3wZOQimqH8MGfSGxCwkzdRG5Bs5FPn2NjHEfpkbSvaoScmTEkInsL4iRBQx_Y5TzcXI2rQ2MQPWU_dZqQarKxxWt2sQrX39Y4eqA7EWDdbZ_38Ag_W-WHCMhKSH-Q8lCFQ5x1-0phcivW1rWy7tz5f8TjDJZZ3JmI0Q41iXLrTGe5nFo3Tw5tkNcpdUZsuvTz0kwlNMh5c6ukmu4H34StMTKfPxsjhzNwG1CZh6n5O21JYSRtyLXLXoJQySYUml7EkR0LVcKqVVrXqE9WTSowhZp_gLIB5q9tSsnBQ8Aczt8fqx5dkZgrL12jieih-vgLENMVWZhOa0_9WG4bJVK4q4z48b5HPRfWKL5RtDitA_yVliVEaDS4_Woa2UZbVrDuKobsUg_71C2SsQBVM529svOZeq9Dwh3R3DXNNZJHQUvEQ8xhJdTTR6js1IfjlwphopLO-7Y0TPC2cMNgCJi7P2NSkUmPf28rJDWU427oiXiSndgwLPRd4Ses5Ly4lD_ksxl_99LxEqghx2Fr63VX1_CbJXktFFnyq9mmyy_N5rTlBkvlNdzzkBwvkpjfMzNn9zg6t7_6JO26W_R_XNf0UUzXlKkgZiGWyNBGDc0Yv3Nw9_GRk17-EWP4ShWYDTXOD3GMITdetNEVTuHrd97aZYcZC0FsB5kGxbpnffKHtcEOp66xr-RurQ9weylGFvHpo0BxMZ7d1YeFf_p3QxFb878bRcRYnmdPR6ZJgnhJWo4nxGJyuDadWTii69F2a4sPakTTQRSSxmEEJnHgXpFzw3U1VHQpAnVsurcmI73iNIguQThln4cBek1d0CazvXK0HEajTBHzfb-GObd25rUNw5fpIZVCX0ukOvzLOT9mRTrMmGuY2eQ6wLjmngk4s-iezuzRxvb6xj7TSS67vHraZBt4gf5RRCaxA-yk7Rcv0g68wEBNOOXXj37lKxr0XExbC_5uwim0VO2CxLCcslpnCTwZUwLytNDT9SSmxXMbqEuTgwadl5heRqvwTkQEgvcPl9RNA1xzCG69k92ihrqcXWFFcDAOUVKKnw4cLxCdoDIGte92AnPZ1ziJ8YBfn6N3WGQACwMfzgg_e10vALetr7s5S7LTGK8eti8sGq3bwMeaN6tEUOdIvZVTMIf_wpeccFhXjSV7_D5D7qVunWNTkcEreylNtPP6xmPsjtbklAVDQBIF_aDgZ-k6rqdVEdnkqaVmnX8rEyA7agGmwhrtLXybUKbkjB5rb7LKKwz0xlVQUdKQ1W2XiZquhke8IM_GlNkkozgIkeccjkG3NGVtNXnxcbkjsir4vTT0O2wRa3vgX4gtBUuPlk-3rE1VF92EunzMTrEFxmax23tjpdiKIxXwx5r2zZwV378eL-ZBV8wIykBIXhuZHRo4aAri3CI6_TaWyp2ZbEgl11zgoDAo8thPT-dm2tothdIyvUoWboqVanJpUyLym9W6dUDYEo3Hlv-qSkOShWvV1PTEsSykYxXvl1prBRpTWqiT1x5GjJg7OVfaLGeWiUADsqpDdf-QhbixagKMtxdUq1WyISarbVRf-9rfRmfGky9vONagt53ijUwKBnHhNaXc9rctxn-UV7kvlMhrnSllpazFH5w5QVMhGt0IJXAmWwkliVThQgbnY0mnuJxpqkpqxO8pVUm0ls1RJGsm5Cnt01F8Zs9Shnr5_vKSnEeCyQrCgOlhFAJFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN0Mx1J8uheVrk2kX0FgYtjsk8QCeNfdVUDCOJw2YcQ6KfmFcxU0VZGCeratN0-OialPPQZUSKjZi-eQgRS0bP3OPeLt_8AU-MsS3I72DNgUQrUXjnx81GDnOd9jkfzbVyTt7YzlOMxFbUABnKu2QLCoxPjDcvmZPooO9c8rWpNtLWs1ZH6qst7bGEloe4kPazQXgITlvtCPws1csAK8LQjvLnA-RLnB6EwRqek45lj41Dxhfgf0eBb5_-pksyeD7SF9hq-CRqwwy6Kwo9eBgIEJAYhggvEe_KSeyCwZIdy8mZWy5Y2Wb61SlISxKAD_ke4Mf5bVeDwpmj9owre6cUcHiVOzL5D6cdpy2t-SkGcT-iielSDRD_vTiDZeXkCR72ajOJLKz7SdUiX2E4tbErdG6hRykvDkanpPlz_wm_mfQkhxapZpB5_iGUUIvaiOupokmEJWQIrI-H4OWkAD32Emz2f8-Ru73bqdDER2aVmzeYpCK6sDeHm_16Nz7aT6uXEknVTMdQmOcvz_28Twu_dirwtvs0y4uR6lM77mcPsxejZS7sl7qj6Fjoj8A3d6n9XvhlzQHl8g8t3jGUSBl_iaG-wwlSr-Eg745vTta9AgdZ5Gsu-_Cc7u2SVcpsvPFEfwc1R2HNtpfEj6EeQaOuZdnBhYAkUUc8-_nidZHTkSO3tKuaFzLwRDakLuYD3FljE_U8mU6whSPFsVOk4sUcF5YUECXQUQDpyVMSTipzgYJW-h9Eb4zqn8bwom8AxQgznhgBK-Y8tCzRLqhIVeawcHCeQhtxN_Z36GxoiRfiYh0TAt88eOSVteJvunQ9s3rrtfPZ-ICt-jYr4whAekG8pVVJkF9osO9L_UGS0DV1msYrGiurdZrnirTrxyYIyjTaIyzPHJiQKMrmFjFARfLTSK-AE88ndZHrUttunrNLlaw_AVwu7Hzvt3pESxD_6Xy9lhbN6xu2bUCWaUKzXwaOt_l9bZgf5xOdbsOmypTDy645lNgJkqcRFCJxWz3rAFNhJHIH4zbC3ttWFilSVCZPFlO1T8bHkXTe_dc0xWsaJ7uCI543D1WscyI_VqPiBpdalw-PWBpt3F_b9yNsySntShVMNlrn2_rZwcqrXsdfPdYzR7y5WGfhFhd1VArd2lN_3UU-niEdRm4JEMtApNp6RxsjuZGfcAE1rIzdLzoJr2XCNnTEbtc5tparzOFTgs1SsZm3hpei5LveM3QymBXrPuq05voEFOM9Ew1S4SoSSBdRN8NbTNzf8RqeXmss4wfRZDWEPuTAE-vlbVtZxReIETgkIpLJKibvwWN7hMwohGZY0LsqBlKyYjLrWle9vybMoPoJUylvVh_QHE2dezrkJ2unlCOdxxCWu4tgCVFOyztlkIrVJq-wgrVU5p4FsoNCzThMBjHqQ_TXRoRXbF-JC49DR1RoIozETQoiRyLideQu4DH4uQzxhvvlIK_mOvVmBk0P17LEzC07Q1QsKz5eNpIZuoTq68TVWPyXnPLyDG-PyVn0Ue7pImSzedyLxWub7xHAZGTTv4h-zeGz087g5gdOaLThPofi0LGFMSsrHIMc-UjI7W909wRbIG0JlMgKe9qxbMg8BUGUoNGNWijE0NdMFUIFWItceEVLCYb3kYdTUqlGSlM9-21VkcBBrxdJlGk_PBgUa8agNzoedRwbNbpcieX4uZ476ehsoCjoVjSHaJHdk5ZpoE-mpZmf8jYnJtrM5p72_tBnA6oClCVhLQFpFkkQhlbF_T9zSD0QWEk_fRgscV_mhGKxd9zhVFO0VMNBqE5TnG4TT_JZ3_DUFCpmuUto6B63mhOPYR_346GaXGdcTP1IcLRtzhmm0PQ5v56L1c2Jh6GDUmMrvKMG85n8sP6vxMYLV_00W2uXXanzK85cZ3WnBm5zi06mf0y0JYtx2Kzq65D5vPWl_yQn9dqGPsUFy02K45N7iEDHsBYKzKX8dZxEFYKRO6zc_YJDOX8rfyDF_9vGBUWou-mztmwnBnFVc33WqjJxwVQJLa-2FqdLgOxz0SsdrjsEieFAO1sECANXtXMjKyokqffBms0qiOLrIymJkfy5XVO-dNzvPWrrcqPAVPsTNLsP__-ZUrxmZmRaMf4nwqX3VXh65xJCHDYYUiYMganvx1hoJTung-yyRPi-nhZ9fmOnePjuKRwztMfYd89gkqiTzpUJK2okS5eFix34YnoVxHW1Annrj0QKEuITjjbwbCSanUmlv69AW1fTvhNAeMM_hk8w9I81lT9vyYUkPVpbwsIee0K3gD1YOz5TbVpbwXaa4IPdhILBLNIoDiE4SVNFhBsPlk4h-lFHzI-NA1zTOe8TAgyXRGNjBTOo-f0t9YN-1bVnHi-4jUEicqD-emT6BVrl3JBS6VRctP6fU59xKzKTrIjFFqn1lRpA6zn2S9fb6O1Yoe4bHzg1pbOeAe3z7Omqq9BpzUdckh19KVgWJHA4ITMMp_cOA4rnXUxiF82QqHjD8-68vLMjpZy7BLimci4R0IJgCufRaN15E2nu4dIKz3m0XTGQjUQSSGrzUd6JQAL594JOF4dmpWtb89es6CqWbu0Y3f9vKW6e6eCr9DC8t1PBke9u1SW18uNd3aFOfZSPC0SW5otcvC04c0gggfMxk0DtYNr6m18ANRy52a4a0zW9BItmFBovTl0Y0f8XfME0tBSLM1khXbODSpXU651OYwQODjbc4bRb4XmyxqNH5bljTsJxuOT-iry9h2ntveEYcBmd1A-3Zuk1uAe_7b4cG-o1903a19PFI28YBUdyjt_502o0M8wnMJ6U04GCcgRGgXTDqw0auRq64Fwbq2o0UGSYqNy-oIOFwySS9f6AUnA8m9BHUjj3O5W9J0yO2JG751uP4lajPZ39sa-WW0oNY9AU7ZmUSA7agGmweDF9T0cW0f0UG3AA7IGmKtXwJjc48e3Pv1Q18g3YglAsN1cVJFW_0aQ0QXIR0uiBm5g8GasMnuaU5lc_4mA0aU3pmwbCmvJxfkgWga6wyndDhBmIJk1C7qFQ8iNQ668_NeVSqtGJODMHfYatYbKHM2akTbreJK459fhOh2AYrIaq0bX0OH4Z8dX6d2ZQ8ahz1Qh19E4pYbmHG0Kjhe8g5AysuAfmJG841A04_pBGYc1943KVABYOGemUF20tyK0HsI4UW4G0aO5nWpKdy2nmR7Y9wQh6ccKKfMrbC89MHSPFoFm2TmE3ZUH-SJD0AQmHVd4pu6FcdSEBYaO3h3oEpIGkE39Pse_C1udavFo2Xa4YWEAEvZx0aLWShKy-u99ZzCoQat3gva_1c4qyHxy0asFspz9-CJKdwa4aGkH9xduHCo79aaNFjGyJptde1Hm0o_5fymSZ1As7Ym6NwZFc6aC4gWEA3qtrvFk71S_4-m9smaDkwZyl_Mk6o-oSuenJcIPXYRpKQeWRAJtvvapv__zHTt9SW_RWybGFMMxL7-iOQK_lUtVtl-N1GntEzvlVIlRNQFIhYHX_zGOcxaekmqs_6ahVpJvMp4QXXArdfP9JlDGCFsc6op4b3suL3m_dxnkDcClDpkS2e0tSF2OVLJbftKYfdnKabsckLksZO3WymsdeUP8_XFlHZEszyhilMasP-jNYjzkH5S8Vt5PkZ1pjaLwZ_jtAkLBh8Toj3V7hD-ybM5ERMJNnwnRceHYAsP-Ew8zHeUQTDrUuen9m_RCoedFtHgDgz2wcQwLmkJkIgq2aVhxKOVMvMt6ZTxHHzhQA_qQdNmpv5jHhhSrNZ9DNggdQBKQZoDUJ75k2kw6lVXcUuo7vhNCRorNMzscb7zy9zEvPtQpXWZpQx6fjjlLjswylR_jxY0PS26DQSQGo-rPATPCP2rrHMRnT6EZQg4GozrI-AB5TvyJ_1gASuTJp9x7Ox6SkWmqulX6dpKQx0zDaMRiGeNjVpOB8YnwAp1fNsKT81FcxiA9-Djpnwtt5zk7Rxj4cDpf3nrqtlrR-P1HNvxcbNWG6-mcxCLwQy8hdhVBAf1xsKCFgMTjArrUE8Sbh7inQSKouYJk39Jlob_Tjg-eULaU9axZyr5TRFdtKCndEpqRIz5IYDhyhRapEaA61AdElUiU83AnN-JvwLupXsBRjf8LcfwyLyRDuprKRRUxU1Ul8I_Dmotse_Tz5uLDhs7fY9O1xphnRLclDHM_XallAWodZjsBWkQgRIjXLUh5dzkk1UgDVEqi8nkx_1kCd1EwB6SXbOUoSA_siJo631Ywtf1_6oLaSk9UsrqE3DVkkRgF90L0w4ni-yjEe-Tj4itGo-yhAisF0YYdMYbD8ChS9_Wv-EqL2pT6RHi0KuAftDv3QBXifJDMuTHtmsknsutMdoLMfZ7xs7ctT5x2K_5Wzh4PzESCFDqbtKhqrE5vLgRdT22OyBlb40vf1CNMWP0AThLiF0GYgxCa4o-xKdcysZtPhaaRi_nMABkm9ELjGTQB4thIJ5Dbvgfi-YAbGqGMBVt7FaUbSMylhAKRVeiqGx7JUyoBPpmKA1RBj8EPFMlw5RXRyUdilZRMGFytMS-OeNStlUTKEzaAUdUILwz6bhEDjEslQIEz-8BWVOArdCF44rVBsxjhQuSzj5Kl7h0hQCEd4btw5YpzPzkPd9nEfJ8dyLtafFygOBORUrikz_jecRUd9hffocKLaJ-QjiUJz5rbPLIdPMqb3RjQCwlvv3NGhVjkq3mbofMqJjj--edEfE-tNLRimStffr6E5ddxGd7mWFPkIsFUFesxQt1WswSaFGncuZpvMd62Dhj-DnzyRZjVcm2ealFshlSgDxMxjMB2hljP-eldwQAEZQqhw9E4IK9YPtM7pjvIONqIBxl27e0QiVs-ln7-oo-jnRNHuSl6nX3CdDhmdM4MdYvJt5TQOXsw_7OZ1aQ42jLOco2XCsCbIeor1DUrcOat5MwODSH3Ec3nVmZ3W6LZ-l66yTrxVDGHtexU4fQiIQZAvM_BYCBrTBQJ7QHMnVxWzLhugi2tNfhjHa9OBkW1jwrmiuQcAzhrc801ozVAvJeMhiLDOXkH_QkLUlXtowDVTAirSQ9xxZwPLHjfORTIislKrWTwYFox0m9tZbSbUh4AR_fBAdYhQzCtL_TfctrVvqEqACYNr7NZjt4IONxeleCQlvwzTYiQQbgSUkKqzEMgYDgQiCCS5PLfuqhjduxYwNyEaMkqNwXpJMRPCQOMwg8mvhOerSTDvUPtRycasveRNggd4LQ8BKINFQqGNBbSDCtMjkpXl9fszD2RGoWLx9lqTQVeuOji_XgITpWiPDYTnjAQ_PSbrGv3NSopw7jUjueokPrwnowxjsSeckkkRFeTi2s1EUYwjqb55NStmxYEHWH6WtIXovr1aqbBZJyxpRRumfA8fjkcrtBwOUq_u-x-Ypdw_K36MBkSVnONeCGnilu3MExMupcq0s_pQD_kp-7bq-ew_ZqXRevF4pQtLsUug4kyUJlZVJWM-RzHt09jtqVm6ZQNTGxJPJef6XgEfX5EzrRlqHESIC3firUYoZ_t5wAV9PoArfvQQjem-XhTPwZ1Y6YXAw0LNJzYZXtKzuQo8rhRTQ31d3UVl2MNhrEAWBKE22grAQTGkkvxJ7-pvIJNZHNQxQtB8pf7arFqv8ht8sag1R0bgePf2HoBN7ZluZU44bRiRwu-LuNxdckWvoyCalKwGlfkwT9auTzYbp6Pz0VNJ6BPp3aKYIRaFjoJG-Z9Uni7vdEAMxOGoQUc-kEqE7d7hFYgOSfz2IG8MgyMFu-wpl9hMzzDuUsQzJMqRPoucdxp1jQudthXbZgOxrGhu2wzGv9HzbCZQOvg9QIn0SqKh5SRBbBaZR5hbXweviha__sTzJbNCGiRbUWRKQRkmenPh1gryepgbQiU9uwrXzt_I5GUbfHdHfDq9MVjvqKFOkMP__io2xUF7qHjZI71oTZey8gBVGcV6Z9z3tQPh7GdoWPvWTDNuanAcKYc4jzaltBu8aPy_1KGfWwRL6x6GCHKS99UuwuDCGvtzHQ1tFpEOVJO8_yUImTJplQSojQXOtfLNGa5a5TjIvBM_AdCR4JbY6tXA3Zb-6gZnSGjYnFLYLjtNs8VG4MFo1mDdYFrLs9_0cnFMwK6uQTtalirNaxo6r9QwmR8FCo1y5LOGkQ4bPSYM3I7WFiekTIclj2LymVJmOvssC_cAIIIhijWbiST0STzyx3uqy-Tvxb8Kxv4TW8q-FMY7XryeCtr-koiIuoRlO4Dxjzxt2rjRDx1nKxto_HUoe-m6wav7JyWT5A-7pqv-u29JysBw7dlmDLbFjEv_CdhRigAF_UO8gZxjjK7px4WduzxE7BIteLdSEEiNEd-e3FwD9bsxusdKiyp1k-pskb1iGJSsuxRM-a-GjcUsSSJIWGFqLTUeZCRgpulc_pDPTUFMpIjRHprRMVRR0pjwA6IOk-7m_l8danrbvn2Q5v9w_QSRbClYVcbU3SjaaYwlPkBXNPDlyrJJJDLcb4NLNEPlD7M1TDLMx-7m00 \ No newline at end of file +xLrjR-IuaVxUlq9mFcmow0cIpUM0CtA7U3oUtS7DYs5xDaY2mA0bkfiPIUoGb6UTlVpt0qc9ufiaKb9xPZRyP3T9LP4Ftuh5gk8VAGE4DLMvoR9lCl20KP4T2BuhOKMpJuZjCr3lIbXaES6VQ7OF8Tja6Vs8wWCWeCfAL4FsU80P9coFZQoy19HcFssoBCjUO590SevBJB9V_Evt_tdsNx_Hqc_sAFzOGqgpRpTPzYzHbNF-DQIRhALpbZFqWDZZ5SpH0S6QqPyssRCC5TbfXce6Tv1afzaVTh26131O_Cycsn5GizTcWQkBLnTN5-_EBwwpa-zuFZtvDIes6MPxI2vgXjZZo1VlLIa4sm-VPK3Lx8396Bypew3wFYUGL_YCPQSPGnMa35G7YJt_cVqJrp0xpMhywPU_ukMRGn5U_b-RZOqVPtvsE13y0CaqYClRrr18HhxwG25v6Yoo0fPmglGcgp7Ztp3u8zjab07MqEmqWtLJ3Q5vZiiIt6426FxtGdG0ZrUeX4-1mpqguSXNeXenE8IsVwZTts5GlaAmBAuWFU2QpW21amW6WXTM3TnXKDx0wb021aKbBWck3gFhd_-Buq2MaHQJTchzzMzZA5v1YXkIFoc0fEKvyhMgrd4WaiH5LtHBZeI9_oT-tev0TMjy_jCcgq4bSHVf6y7fdl34bcGujJ_yt_FdOzqqt9FJFOW9sg4Qb1CzP_JLqfLZ5y5htJxEIqpXTKCFi2xESSsdWjfizBmh22YkkuqdWtNn4eXzYJFPP8YAbi7oyQ903FAct65SGi0_kGSb5MMk60C7e8Zm0T7lRABdL9xQw1u0oVU0_FBRt__-LzgOwRuRcBJlx_uEHONE4GCCuJfavkPjsP6UKEJwdYTOjS4U2AeMEaMTa_PbxCPuZ-e2rJj1kQFHfjrm0RXFTUHEbVYkgUv44xkY5QuHmmJLkrUIrBNeXdTwHpqp-uqlLBojBs697Y1vR8T5d_c0u2UB08KFa23sQ5Q9Cb0NW1HsLUE4gXQ3wsuuXOqIt90vswEtWqUdlFgYdy-2zzYOavNxd1AKglKyKMeDcBuquKhLSBvii0OiNw6Ku2BdY0gwaC1A_eU1Iu8VK06B0MbXYgf7P70_cUZg6tn0-UVfPyPPZtmlg_PP9a2uI1K2XDlqfS8LdiNrk4T_GDvpJPDHdz_EbiPK2yzbfR-71FOIsA0mHPi31weL5oGfCTyKJstQgRI6obt09-8r5Bu-5Es6vIamC9JcBw7ne-4hN327rycBypV_UwwoFr1OR39G58ZBbg1yXndNMEPo875DKktxG2ss_eKrV3sTM4QdUxS4b7sG6Bvu3DCenUYU2DoDI_TNei3i9O3BkbHAzJtztyGl3iyohWgr2NsxqSP-E_tD-GnidNGxiwrIvlXGynf87UpKwEsBtQ0AjXKtEUIWp1kknIck3NLbDnBx9Oq3ZCaLBli5g6ZF4MyXs-F2eLqqf3iv5J175IX_-RljRt_bcmgzPiSzhbPyG8eYVwUulkD3hzurO6StXdzSmXt8vN0qw-ATJnhNMXoltjzV0i9GZWvSlQyVRNpvh-jBIAZUzVvoFo326b2MnXdg07QGGmxRTl4KWxNTj0F1Em8gFavysSGtpcJ6Fnf8cHYNrfx2xsyCh6bpLo7AXxCr6aG6Oz3i6WR83h9BVE0ZYCz63tiX37ap6F2CUPxp8F7gJgc-cQ4sft2TGNlOF0SCxZ1v73sVw2NHV3cZPNdRD4W_-bVtQD6nMQmTXj0WKsxPdk1cjrVzqq_ksxnjTZ-V-xokcapNHMZvIYHfSso5Y91sYjj27nfXzoKTvmA2Wby7gizt3If0dKEttgX6387oje0CaWhLPbMwt1eGllOyG2T7Qa1kkwNBh7LVY6j2p7xZ6rwNqNwjwyvu0GOf6wXOcycLXxSShqP8VSF8jENTThGPeyrGPTnM4F39tzyym-feoSFkMRb-kXtJgOHkpS6jalNTf9--ulghlLXijqInAWRf0BMFHtKoVJ7de2AOdUF2tHdN5YM3PeGVc8WRroMOh7qtk38sGRUhUiDU0WRuDdzas7AJPLEfNVFB6K9XrodBSD1l4GEl3Z_dSFBY9ANrOoCLsbefqmdgtO0wDW3N1vWZK2BAZWq2LPo-u3N7Zn631_eaYmhdz18oWCh8swU8IqN5w1nN1mx9osWvpelIKLG8yOg1c4tj61lWu94gk8D6JkX5apDr-5PdSXWo-6bEqC4GKVNZEcxkIfWT2Co5biGzbUy1xRPNNGRU0ogs6ZqDKI5aTUATdjNUJfg2bx2dquZBsebKwtGkSoREpGEdN1OxggMD5jT_nAi-TGh1Qb33D4mDVpnwjIQgTzoR1RrQGXanYKO53lG6Jwn_6i6k_W-WHCMhKSH-Q8lCFQ5x1-0phcivW1rWy7tz5f8TDDJZZ3JmI0Q46iXLrTGe5nFo3Tw5tkNcrdUZsuvPz0kwlRMh5c6ukmu4f6CukizqbNhQstwdqW6O76DZAm03lKuslel3h3Wcruv5zXQMTSo1g_8fe-_h3uSI0wzbr77d_SjEiNGbT3V7jeWgJ7OZdreLUwAhLqq9sIgplBghKPLR-o6iYm7zc10GkhoXdWZgWKWiAh9C3omgUfjZxNxbzzl_K1dYci2NWJmPhUol2sTTcgd-op4XC6Ucs1ScTEA6vLYRSACwp3OJreKtNNIQdIEwqvZJVHURYasHQke_NMMf6WiN3x1cKYYxH2Lcxu2yb3k7DOD4h9u1mmDQL4PccyrGJRVQu3n2mQpYavZ_ctLxvvUiOczNUOLzf0Rx9lnWtedZsZqVFoy4D_pymzNLnRkRsvlNRo-kRyxUNlfsHVofDX7tds4ZeF6mTQz83pXt6kPSLFSW1ekNWR815o6lqJ-XsS2FyD4p8Zg38rw7WK53PvsSjTPPztb7MCijIUAOCW8_OCUw3UNM6MjI7UKvZiKOldzjZbibgZ--0Fb7DOCCjACTSsAW-dpEDxW-wmn4hlJTNEHOAOiTTM9TSs8WFAQTc1ya_JnWWXLxKWYoCLlgh3FbdkDroE69KkLulWVrBg3NOcRzscuq2muPbgLJ3JzT-uqmSLaCv1mWkSUa2fWWhPMEii_q2qeyG-ZoN8McCQDyoq3Wv3hsLHqc1T_MJH7W8AgeRPIc3MkuJhmnZdhtlFc6RUmUrJqRVEYiapfpBw5QL3PBmlxAYsjlJjx1S6HqiSqmn13wkDUfNc1Yb_AvU7mL9i1UOAQMQNrjdlXOhT5EEL_NJ7V9COnhLJc4Q-jAwXc_5jhhaxwjgULgfYU_nGMwHqziO28k9DjBfudnu3Ec57PEZl8WpFHY_XoH6j9BG63DOlnWOq9SF8suT21GGt6sFDLMzGS48Yz5NKVPLmHNrnKWZEuXP3SCN9QYHjhTvfYVB65-rv6b_hEYQuwj6rv_VCmNqTHzZ6LSsATRMZ--V9bbJV1cQ7UNwyf_reXA_-GtgFwuRdITyAN7xoPz36CWKRyhU2X13glMTsFnLPQNw4eNMqTLHrdr3kn-gwLJes4862BJY--YXfJy2jMxxxweAQcQg6z39j0kkl7YtDFSRjVTJ5ns1G3gkW7bgqQeSD_YckrT7wO2-UI5TXZxilgTVdX9s47kRtFOerp2vx2r2vZwV378eL-ZBV9wIykBIXhuZHRo4aAri3CI6_TaW_J2bbEgl11zgo9Ao8thfUjJO1TvRrtf-KiPGIvQFwQfvZl5l03enbqZ8lkWaRzVzDAc76uEdqNNpXcFhakuEVmSjUsyNGCBdKUn4BLwns5w91MAuB7YJTkCZPwVMYwRknBbXj_v7X1O_0c9zTNqgNXTTTuKeVU4yaBorRWXsMjLADweLhyGp4wpRrw_l7Xty_hLwukNtvoUdWYzYbNhrWRW91mbOGTNt-F-LjLGOn2OOyBzvwNPQLk4vdjO0NTWQuqDy5GCDy2JI4-Y76_TXJ_Lt0JgZ76jZEuBs_b97mZE8QN31S8xQMu7iSEhqJwoq5zptXNmNZWBzWfaSTqFQt1NmW7rnJqxN4F6KBqplh6cC1z1HJD3ASw7pLj0Fsg6qQmVhWTCsQNiCbJlkAMnNdM5r5k0IaXCiy8xVa7F_3REcX1X6ptFjQjGMm_aWe4uCTastS-ol-CxZvUtCBVdIl75OYU1j2cPyiscJSwHqnPC7R6QmPfxguR0nWZQxRXoe7LvKANqIMlHL9EtzxcCTJ3Jx5A4gbM_AmdVDgub3FTDwKL2YNqY0kzrKrKWqDp2FRlxjd9Dnt3ownFjcvC-V9aAicP3Qica7PNkgvEe_KSeyCwZIdy8mZWy5Y2Wb61SlISxKAD_se4Mf5bVeDwpmj9owre6cN786FiUgYaZJJx-XJ_Et8HE_MMKtk6j7VykM6oqm_4DZfIMS3TKr7SdQiX2E4tbErdG6BRykvD-anpPlz_wm_nrrBhlJ-BCiton1wrBcYzXZFEu0fE3fxL8vKTW28etCep0qgkWplSzSEXQeJ4NZkJl4MLXXcfh3ENu8IZhzpWo4fzmBRutwcJ4sFpyGpZK6yzdlsxCHpSGXCU-OiN1ftVkYcApEzQEfQCVRrUGK7ACYJ7pNF-rZEHLHk3QWyuNVlT9XjnrUvlzT4A9Boxl8AMkUjf0RJ_-oeJX9noRFxjbyQZhOLe85_NDawuRwHYIZY6U4-kAgvnzOZx-6YUF5srpWlLGYm_rNvasIvRZ8K8_-qx_uZ5wR7Jb9kl_50ktrXihJXnhB3pLlFdvo3fwUzOMTHoiagGJtJCXNRB2WhXghtAleTRw83OorzVIjPwWJxf6IXkkVjV-CyP0lgzCc2Al1eXhGHGnujpeJvunQ9o3rrtfPZ-ICt-jYr4whAekG8pVVLkF9osO9L_UGS0EV1msYxfOnhF6hpTgxhpu4rzQx8XuwochOaijhWVQUKpJgxufy4SHHpB6ZwvklndhkhR8r-K_vu3X-ivXvlETc_XG-CqvPUmkU8gNJ8B753PUPEC_R-QOQkIUM9xzsCCCtRS1nFYi3GwuNMiVZWr2hTWN45oQsm6Xxh4CASXmaK9Fy4AphhzxQ9jcMUOKY1vAu7lt0ZhlKdCZxJlO1vJbnhZz8qAw8xmoLV80fJt7z_JMqVDUYVhvM4dFy4h_Kbn-Ron_VoDzPQVV47TU5zDfB7i7qxD5sq9u20pTntVEmsnhH1LOyCnzx6mwTimLFOJUh4VSPhiOtoD2MHTeEwJiw_gIEy88G-lfqkymk-O7th1wj6qBcaU1TUP5WwlC2mVNc1SEhV2Wt_MGexMs9lKBWZwIZnSnR96Pighi9RSV4b76mrHfSPi1pV7eHdtDyx-yVRP7H_gIoTUwQbWkFJZQz8n7PQ0PE2ksXTuNe5jdE2UEe7oCRPcbExo_b-lzf2eEUXtrvCBZ6ynYVlio3WJUenyzRrpU-vAxwUbtqMlxmkSN-sLvtDlArDgFZ7haBUIvDfxoPmX8h5_ZI43jpxMKZRdhaj574Xk8h3JkRFJDwId_3NB-yjq388-etka0x0BNodei-EYLV5llWX3Zy3FaCBFkkRRolxg83r2zus3cj2y1li35et85wIBsUHEzBhCDGIDuX6gp9bROsJgf0bS0rNCkaKbfFFNMHhwJ2AYvKK46p12lNKgOghb25VGEOhyCmaF01Jza7VCqn9VmH7NeKngXt17jdSNDFNZ1-XGis3TbwTthx87UjrzAJKUGBCt7NUnMhoftNaCXT6hnbqDTX6ESNAd3PKmOTGeVUHox3EEYiIqBaFVLONCSv_ul4eR8ErotMgrx7NVOLLVf_-wJwuO0L8VTUAxLjCz_y-cft6JxszU_0slUFOeLt53ndC6SDmpM3Ay_EsP-ZonYyAo4OT41ZZ4GGc8HFSigIAbyybpPGyZu-brmXSuQGuLMw1jsoujs1b01uNBcHkMrmlG_08A0E0QPuKI21ThmM0pyeJh0Xk8kF87uDopbVUWYEQyimN_-DKbS7Sju5kyV826eW6lFeQPdCV595rZZEFkuU8kkWUvDdCuwP4IBZ-Q_D5t0Uz27fRWRVbGZlYT_K16nvSdtCws637-4lbCBynsQGxklZVOTXSSq07jSmQiZNAlgHxazXPJNPc09uugADtWd5HxhQopz-juIx7f3dkmqExkwEllpN__MxJi2l5jHweJ7Qk5DE6lO7XDna-A9vj8rqcE-u5UIxd4Dttd32nfcHLfDk0sH35V3RHHlTKCMkiagPUFuN5wDWF9v0UZ-3WDIRB9_DA14xF4MKDeGRibtssTsbaWwBs5s91Aw0TKzKxbKBRRqkKj4fK0skay-HT3GlvozR9KK0Q3q6WrC71NPNyvUeP914cPwqbIrLqiZJ5T7tvdwozcR_XBVrPuFgNovm7ghbH1fLNaBg9Dqz-WazK1Ex0kyx8-23N_0J2NfjaGzXbR8tnf-kYNuqwqfM-EoyAIs9wfRYdQUddXZcZcqb_WamJGAqu05LOBAXSfIBYQ8N8Z6tgjAUFdnybnR9QZyK2M8HY7gpMBTrnaINcDusUz19xH6q4hxO3XMQN6FmyjMpoQmHi1AE8tYbEPU44m97WkU93qD0IDq1QrwfHr3jljUGif1AYcYfa7YBoLmRwc4qJ16wGGy0P3qaqeGZK3K6waccCPWCZtp4q0sG0aSBpZo7iMok4a0sG1PJrOcW2H0LTNKhLt06xnBQhO0aD9jU2XI2I07eAJqzy3oykMR04YAY8OLLiDoN5MWBcuPs3ccCBom054rRR3DCgp4BIfab1dN5L7cUsttv4Dvjru3hobypFUrax8e3AU4ZuMFxQ4m6bOlGUQ3B05a0PH4HYye8j3_kNo-42M0R82uNZ6P0Ku0bApQfX1ArwqZuBG17VQWtYM0V01rhkLYdbqIp5zrJZWDupGMXJ6XPQELrgO0a18u7d0IA3OeFF95qdgCePDK7m4WQGzG9Nmy-5p1GmboMBM1vn984o0bW7m09LGSL71pIDgEAOJIu1c49g5YeVAwwYmua_vv49u4pI0q2HOBbhS0bP0acmqlCln9Dnu69S7ZWKU5aZddITTDLO5qupM6KtivcCIT89Y-HvIbAzHmf1wzZnWdoAOXIqDi4gyqga9GyZn8cv0QGYgjLL5OfKLg4gY4q212GkQ4C4suKPJa5LgBLS9f0gSqc090IXjT95HftatXb00Q1AWJmDDy2u8fGMH1bFnY8Y5AyDZmm1z5G8Ta13e1K0B61aQBr2yUsqZ1hYPGIk1Ou6WnPfFDqJHAoHPz2g64Z8qCsqDomMC0f8j91vnBI2eaIYHeR6ZaQ-UNa51Srx1C-7ZnIUF6mYM571f6PfSM6XyiwTpf1yRpm7n2faCcWMA1OZzPdr0Hg3YFCoTJyo2Oakjp75p9H3O8f-lcmFz7zUwCCP3laszY46mt1A0wNLu489KWiY5IRyqdP4naad3jGuNVv2G1Um2OzZtvzm-G45GaZ4OmIjXuiFb1ldQFS2rjnn96BaO950iKzuNwX0l7nVzGVS96OlCAbGmcN2Gkcs-oCqfnZgGPncQp4MgWxEIt9zdpvt_znLq9ye_RGmbGlMLxb7-i8QN__UtVdZyNHRGW1RnUwjSskqSbtCZ3VoWnExvJTfhjUD9M_kbpDc8r32Lhl3-JdMQWONjCjba9AFjmA7X_ltZSR4RUjxSu5O3kOM6n-YdBJkgEcVQUItQQvNRQDWE3p3QUvwiZ-4-zgyxRxnJPUjBCprQlbRhS2AwG-c8pzApfx0gv3s0xjL95jaCPwjjZra--FJ6dTlBB8zQjMmHYQoQ-EsAzXaVQTDsUOip9m_QCoiaFhPhDgr1tC-tKGcVU5Lg5e_72fOzjbxSQDtD57skeKtgrElZdo3OZVUvglEGQFTLEKMerdjgyc6BS5rsD-_1CjvaFpUiOtihwEJlDg7eUKQNoJcrdZ9PcLcFJxpUhRbrv-_zRNC0oOCEQKusXzsooqwmOoDggYitYQ4QGxOH3BtL3ueiLtdmF5IifpXvFCdiTZjfoQ35JY-4QVBetSnqsHPkn2fTk-J5P46tHMODB-tJh09ysTnLEsWke3hVSNsuTlkqIOtAaF7NZl8quyw0YlptDAd2WDzZEsOhqLmHNpZUBQk2y7yAdj9MsDGvFdCCobauKJx6CM8ahC_MRycUdxKjZLeQNkVKaNrGtUrw75CF9JDywAL8QMPEt8MTELq2JEBjppue7g5pyDtbUYkVSi-YdZMJLJI_-QsC7-pxArhiTt8kNyFS6A-_sOplTgSB6UOuhWdN0wsTSQyPBRUMVySABAkEvO-Uoe9vAsrfujMhnvuPxeLhJJmlBw3g7Tvs1TKAFvJIqyaWM_jKcbiU04LlV2-9dgunSJjnheyEP-T8LL-iPiHW8jPbzRz1vxALTkXbw1sTTjU514Er5gqupk0Bt2Nu-Jal5qfr6onJWf70wbzSW6ZPBsRnr6BbkS0wxtMZnLMgX7x-7cVT7xoKyEnxK93wRveIPfxsi6vcUAYpNsko45XmJVgS4oY6TkD0o04hHheQ5Xr1qPOPaygqZcS-ZxSnoIToSux54xO4dAsiEj5YQniDhYYxLKsVH5IiR8B1kxpdoFIgBUNrbADlKMQ8TZXldCYsSy50uO2xI3cIvLtIhy3UZU1dypQm1_lPZAB52hlDzBzHRMPrqRoMlNWqbP-jfMoUfvxtuGg2zWlNS0qJJLujRUwihXtqq5QyUS6kf0sVI7JhMRFsdcvbSh0ubycVndUGaVtQX_hPcjjdlPb4zRqxDr0CUdLP4_chR7a_HTfMLKfsHj9GsxMdEh-UGrqA_xTj0yFSyLj4xRLtr4vr9t-owhTw36zrE8voizKA7PU61pDoMHpnza_PMuq4tLk3-c4t4-l8qOmJjTdpkldZSzZys0J6uP-rTTknpO_ToOkTktKprvTDKKKNGzbNKAj99GcAdzKUONb8XDPBlUu8UWDgnlJx_qJwBxwt5zL51tWR6aCpSwd0TeHPUxbDSLngY_NeyDcE61iHAbHYR864pOwMA3FM4bxKUBtYLBjZr14FwO37_Y4D0vUEwyKQxNViyr9FU3fwILgp9w4hbhyl8GhMqDfDTPDV5_63rclYgGBVUMcr6mlmkg85thJ2p1kRhchKOmC7BrmYukjPkHJMYsv6zQzLw-BTBRP_KQxMnec2kVXbL6wbXjrBpwnHMX_g8F7j3WdSMrqvrOfNVz9PKyTPNkkwlRjEswhzU1sW46upXumtbo9bf6TkiKLaRZp9-HQq-jXCdLVkBwZEeOtJgiLdJ9jRWrgl1fsrYHnJYci7XhAfEct5i_HOTI_drAcEj_K9lh3T9ZsQtLEM75R57h-9lBZEpVtKc_T3QE4gZHVgcRisvTFHESesoqpTAhxI6cclhwBis6j0hMT8eEzDH4vfPV9NCh_7OIG6_3TerUwvBgZknUfgJUGvjsyUdewnVxCwXQzy2TdJrDNSMtHxObk1SLBsxH4SLkzj1F0Sj5gH6-j2dpg3P2IPytwJRR8yhA8bi-WrzBVSYqlu_xbIqdA_NusI8kSV5P7aDGXWlupUCxMuncaEs12OFruvW3Y-V-DZpSSN7EJnqslsjOSUZkowKlpVILt2Pdo719TowteRHjFluUeeeKKdHr5JOZ7QwjnwGdE96XcQRlHPH_hcv5FbiybQq9rFsrvfvsUeyHqrFH0bL0QFe-XMnRi6yDP0Rrzgi1qtjlFtWBRrwdRfOK-E0TAKsxHHQpq6GyN-ct06ZkHgplMMXJVTeUegMNE5k9CLk0Mofwk5G8jSIEzACw0sHr1x3hNoli_Wy9qLElZE0LEaBwTkdIPs7UOfSncVGyxrZ5izWo7b9DY7tvUg-J4lOcTuulVlTi2zEF3VRyQ77ppvcnLCFKnrA84CKXh7yVNP-afjhvpU7jcq-rzCsSk9f-mmfMkAk7KKizR5Ug5T0VL2PsBBmPY_YuYSUJtDG-IM93-YzOhcOqfqahGjtiVH6jt0c_tNtxULSn5hHhdFRZ3Pt5M9COzMko6UqMnVZUQhOXToLYK3fEKPLQZH1MqhVzhBwB5gUNRedktYBzaKCKnmUh8nD5oPgeNVaGql6zT2RnK9zqabAFoZwdR3KfKm4fwbFidzfF35Z7fwTgDUTwQZPZ848B-6aFGTf6X9iGDUD1tFpERlJu0W3yrXQdeFKSPDQXOtk9NKa5a5TjIvBytn1Os91B4Dk2K7BpyHMKYyWR5ZIh4hQk64H-m8iVa3WRF2shBzu-HDYUznqRYLckfFer7axo6r9Qunx9ltA2C5LOGkQ4aQSZ63I7eFomd6HpNsXEEQF9usS6O4Wc6Gd-_PR1BUWw8zJzisAnpz-xZpBGu8CH60ZJAbRe-ZLoG_UZw_BnhWkkTaJtEpspX5kQsOxno4pto_HUoe-m6wav7GyXT5A-68EpDq5IduuNqBFVzFQKNQTwETFMdjLwFoLWocEkgDJTVWG2_hriCSoB-bLfmux-iwTwWC-i4kNRVDoTYxnCEz-bzTA3OWdPjsfcjvLynVCzcOwcb0WVXrrwV4okx7YUzU6RIwzUjX4RMddTfUBfbdGt8fgXZFg5ZwyY-JrMtkH9eJcdRpGn-Ko-Pxe5uzpsoQ9T7U4Swd8jlalWw9fjaeZQgjoDfi_mhfegdBp_m00 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 7d9a8023c7..852d47841c 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1057,20 +1057,28 @@ class NextSteps{ completeDate : date } +class NotificationUserStates{ + * id : integer : + * notificationId : integer : REFERENCES "Notifications".id + * userId : integer : REFERENCES "Users".id + * createdAt : timestamp with time zone + * updatedAt : timestamp with time zone + archivedAt : date + viewedAt : date +} + class Notifications{ * id : integer : userId : integer : REFERENCES "Users".id * createdAt : timestamp with time zone * type : enum * updatedAt : timestamp with time zone - archivedAt : date displayId : varchar(255) entityId : integer label : text link : text text : text triggeredAt : date - viewedAt : date } class ObjectiveCollaborators{ @@ -2541,6 +2549,20 @@ class ZALNextSteps{ session_sig : text } +class ZALNotificationUserStates{ + * 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 ZALNotifications{ * id : bigint : * data_id : bigint @@ -3069,6 +3091,7 @@ NationalCenters "1" --[#black,dashed,thickness=2]--{ "n" EventReportPilotNation NationalCenters "1" --[#black,dashed,thickness=2]--{ "n" NationalCenterUsers : nationalCenter, nationalCenterUsers NationalCenters "1" --[#black,dashed,thickness=2]--{ "n" NationalCenters : mapsToNationalCenter, mapsFromNationalCenters NextSteps "1" --[#black,dashed,thickness=2]--{ "n" NextStepResources : nextStep, nextStepResources +Notifications "1" --[#black,dashed,thickness=2]--{ "n" NotificationUserStates : notification, userStates ObjectiveTemplates "1" --[#black,dashed,thickness=2]--{ "n" GoalTemplateObjectiveTemplates : objectiveTemplate, goalTemplateObjectiveTemplates ObjectiveTemplates "1" --[#black,dashed,thickness=2]--{ "n" Objectives : objectives, objectiveTemplate Objectives "1" --[#black,dashed,thickness=2]--{ "n" ActivityReportObjectives : objective, originalObjective, activityReportObjectives, reassignedActivityReportObjectives @@ -3118,6 +3141,7 @@ Users "1" --[#black,dashed,thickness=2]--{ "n" GoalCollaborators : user, goalCo Users "1" --[#black,dashed,thickness=2]--{ "n" GoalStatusChanges : user, goalStatusChanges Users "1" --[#black,dashed,thickness=2]--{ "n" GroupCollaborators : user, groupCollaborators Users "1" --[#black,dashed,thickness=2]--{ "n" NationalCenterUsers : user, nationalCenterUsers +Users "1" --[#black,dashed,thickness=2]--{ "n" NotificationUserStates : user, notificationUserStates Users "1" --[#black,dashed,thickness=2]--{ "n" Notifications : user, notifications Users "1" --[#black,dashed,thickness=2]--{ "n" ObjectiveCollaborators : user, objectiveCollaborators Users "1" --[#black,dashed,thickness=2]--{ "n" Permissions : user, permissions diff --git a/src/constants.js b/src/constants.js index 1c326bbd0b..dff3f87a19 100644 --- a/src/constants.js +++ b/src/constants.js @@ -205,6 +205,29 @@ const NOTIFICATION_TYPES = { SYSTEM_UNPLANNED_OUTAGE: 'systemUnplannedOutage', }; +const NOTIFICATION_CONFIGURATION = { + [NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION]: { + textFn: ({ userName, recipientName }) => + `${userName} has requested changes to your Activity Report for ${recipientName}.`, + actionable: true, + linkFn: ({ id }) => `/activity-reports/${id}`, + linkText: () => 'View AR', + displayId: ({ displayId }) => displayId, + }, + [NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE]: { + textFn: ({ date }) => `Planned outage: the TTA Hub will be closed for maintenance from ${date}`, + actionable: true, + linkFn: () => null, + linkText: () => null, + displayId: () => null, + }, +}; + +const ADMIN_BROADCASTABLE_NOTIFICATION_TYPES = [ + NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + NOTIFICATION_TYPES.SYSTEM_UNPLANNED_OUTAGE, +]; + const EMAIL_ACTIONS = { COLLABORATOR_ADDED: 'collaboratorAssigned', NEEDS_ACTION: 'changesRequested', @@ -358,6 +381,8 @@ module.exports = { RESOURCE_ACTIONS, USER_SETTINGS, NOTIFICATION_TYPES, + NOTIFICATION_CONFIGURATION, + ADMIN_BROADCASTABLE_NOTIFICATION_TYPES, EMAIL_ACTIONS, S3_ACTIONS, EMAIL_DIGEST_FREQ, diff --git a/src/middleware/checkIdParamMiddleware.js b/src/middleware/checkIdParamMiddleware.js index 41a2c49ff0..6bf21df229 100644 --- a/src/middleware/checkIdParamMiddleware.js +++ b/src/middleware/checkIdParamMiddleware.js @@ -283,3 +283,7 @@ export function checkGrantIdQueryParam(req, res, next) { req.query.parsedGrantId = parsed; return next(); } + +export function checkNotificationIdParam(req, res, next) { + return checkIdParam(req, res, next, 'notificationId'); +} diff --git a/src/middleware/checkIdParamMiddleware.test.js b/src/middleware/checkIdParamMiddleware.test.js index f7d2d03099..87174f92ab 100644 --- a/src/middleware/checkIdParamMiddleware.test.js +++ b/src/middleware/checkIdParamMiddleware.test.js @@ -13,6 +13,7 @@ import { checkGroupIdParam, checkIdIdParam, checkIdParam, + checkNotificationIdParam, checkObjectiveIdParam, checkObjectiveTemplateIdParam, checkRecipientIdParam, @@ -405,6 +406,56 @@ describe('checkIdParamMiddleware', () => { }); }); + describe('checkNotificationIdParam', () => { + it('calls next if notification id is string or integer', () => { + const mockRequest = { + path: '/api/endpoint', + params: { + notificationId: '2', + }, + }; + + checkNotificationIdParam(mockRequest, mockResponse, mockNext); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('throw 400 if param is not string or integer', () => { + const mockRequest = { + path: '/api/endpoint', + params: { + notificationId: '2D', + }, + }; + + checkNotificationIdParam(mockRequest, mockResponse, mockNext); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(auditLogger.error).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('throw 400 if param is missing', () => { + const mockRequest = { + path: '/api/endpoint', + params: {}, + }; + + checkNotificationIdParam(mockRequest, mockResponse, mockNext); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(auditLogger.error).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('throw 400 if notificationId param is undefined', () => { + const mockRequest = { path: '/api/endpoint', params: {} }; + + checkNotificationIdParam(mockRequest, mockResponse, mockNext); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(auditLogger.error).toHaveBeenCalledWith(`${errorMessage}: notificationId undefined`); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + describe('checkGroupIdParam', () => { it('calls next if objective id is string or integer', () => { const mockRequest = { diff --git a/src/migrations/20260608135105-create-notifications-table.js b/src/migrations/20260608135105-create-notifications-table.js index 861e6a2fdd..8f7f0b7662 100644 --- a/src/migrations/20260608135105-create-notifications-table.js +++ b/src/migrations/20260608135105-create-notifications-table.js @@ -86,17 +86,47 @@ module.exports = { type: Sequelize.TEXT, allowNull: true, }, - archivedAt: { + triggeredAt: { type: Sequelize.DATEONLY, allowNull: true, }, - viewedAt: { - type: Sequelize.DATEONLY, - allowNull: true, + createdAt: { + type: Sequelize.DATE, + allowNull: false, }, - triggeredAt: { - type: Sequelize.DATEONLY, - allowNull: true, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }, + { transaction } + ); + + await queryInterface.createTable( + 'NotificationUserStates', + { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + notificationId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Notifications', + key: 'id', + }, + onDelete: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'id', + }, + onDelete: 'CASCADE', }, createdAt: { type: Sequelize.DATE, @@ -106,10 +136,23 @@ module.exports = { type: Sequelize.DATE, allowNull: false, }, + archivedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + viewedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, }, { transaction } ); + await queryInterface.addIndex('NotificationUserStates', ['notificationId', 'userId'], { + unique: true, + transaction, + }); + await updateUsersFlagsEnum( queryInterface, transaction, @@ -124,6 +167,7 @@ module.exports = { const sessionSig = __filename; await prepMigration(queryInterface, transaction, sessionSig); + await removeTables(queryInterface, transaction, ['NotificationUserStates']); await removeTables(queryInterface, transaction, ['Notifications']); // no simple way to remove enum from feature flag; write a new migration for that diff --git a/src/models/notification.js b/src/models/notification.js index ef89b33169..8762439b2d 100644 --- a/src/models/notification.js +++ b/src/models/notification.js @@ -5,6 +5,10 @@ export default (sequelize, DataTypes) => { class Notification extends Model { static associate(models) { Notification.belongsTo(models.User, { foreignKey: 'userId', as: 'user' }); + Notification.hasMany(models.NotificationUserState, { + foreignKey: 'notificationId', + as: 'userStates', + }); } } @@ -44,30 +48,16 @@ export default (sequelize, DataTypes) => { type: DataTypes.STRING, allowNull: true, }, - archivedAt: { - type: DataTypes.DATEONLY, - allowNull: true, - }, triggeredAt: { type: DataTypes.DATEONLY, allowNull: true, }, - viewedAt: { - type: DataTypes.DATEONLY, - allowNull: true, - }, isGlobal: { type: DataTypes.VIRTUAL, get() { return this.userId === null; }, }, - isInformational: { - type: DataTypes.VIRTUAL, - get() { - return this.triggeredAt === null; - }, - }, }, { sequelize, diff --git a/src/models/notificationUserState.js b/src/models/notificationUserState.js new file mode 100644 index 0000000000..c23c042ed2 --- /dev/null +++ b/src/models/notificationUserState.js @@ -0,0 +1,57 @@ +const { Model } = require('sequelize'); + +export default (sequelize, DataTypes) => { + class NotificationUserState extends Model { + static associate(models) { + NotificationUserState.belongsTo(models.Notification, { + foreignKey: 'notificationId', + as: 'notification', + }); + NotificationUserState.belongsTo(models.User, { + foreignKey: 'userId', + as: 'user', + }); + } + } + + NotificationUserState.init( + { + notificationId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'Notifications', + }, + key: 'id', + }, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'Users', + }, + key: 'id', + }, + }, + viewedAt: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + archivedAt: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'NotificationUserState', + timestamps: true, + paranoid: false, + } + ); + + return NotificationUserState; +}; diff --git a/src/models/tests/notification.test.js b/src/models/tests/notification.test.js index b7556e140b..d0c55ada53 100644 --- a/src/models/tests/notification.test.js +++ b/src/models/tests/notification.test.js @@ -1,6 +1,6 @@ import faker from '@faker-js/faker'; import { NOTIFICATION_TYPES } from '../../constants'; -import db, { Notification, User } from '..'; +import db, { Notification, NotificationUserState, User } from '..'; describe('Notification model', () => { let user; @@ -45,14 +45,13 @@ describe('Notification model', () => { await notification.destroy(); }); - it('defaults archivedAt and viewedAt to null', async () => { + it('defaults triggeredAt to null', async () => { const notification = await Notification.create({ userId: user.id, type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, }); - expect(notification.archivedAt).toBeNull(); - expect(notification.viewedAt).toBeNull(); + expect(notification.triggeredAt).toBeNull(); await notification.destroy(); }); @@ -119,18 +118,40 @@ describe('Notification model', () => { await notification.destroy(); }); - it('persists archivedAt and viewedAt when set', async () => { + it('persists triggeredAt when set', async () => { const notification = await Notification.create({ userId: user.id, type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, - viewedAt: '2026-01-15', - archivedAt: '2026-01-16', + triggeredAt: '2026-01-15', }); const found = await Notification.findOne({ where: { id: notification.id } }); - expect(found.viewedAt).toEqual('2026-01-15'); - expect(found.archivedAt).toEqual('2026-01-16'); + expect(found.triggeredAt).toEqual('2026-01-15'); + + await notification.destroy(); + }); + + it('userStates association returns related notification user states', async () => { + const notification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + }); + + const userState = await NotificationUserState.create({ + notificationId: notification.id, + userId: user.id, + viewedAt: '2026-01-15', + }); + + const withUserStates = await Notification.findOne({ + where: { id: notification.id }, + include: [{ model: NotificationUserState, as: 'userStates' }], + }); + + expect(withUserStates.userStates).toHaveLength(1); + expect(withUserStates.userStates[0].id).toEqual(userState.id); + await userState.destroy(); await notification.destroy(); }); }); diff --git a/src/models/user.js b/src/models/user.js index 53ba6cda9e..8a6aa45c17 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -47,6 +47,11 @@ export default (sequelize, DataTypes) => { User.hasMany(models.UserValidationStatus, { foreignKey: 'userId', as: 'validationStatus' }); User.hasMany(models.SiteAlert, { foreignKey: 'userId', as: 'siteAlerts' }); User.hasMany(models.Notification, { foreignKey: 'userId', as: 'notifications' }); + User.hasMany(models.NotificationUserState, { + foreignKey: 'userId', + as: 'notificationUserStates', + }); + User.hasMany(models.CommunicationLog, { foreignKey: 'userId', as: 'communicationLogs' }); // User can belong to a national center through a national center user. diff --git a/src/notificationConfiguration.test.js b/src/notificationConfiguration.test.js new file mode 100644 index 0000000000..fd5be2fe18 --- /dev/null +++ b/src/notificationConfiguration.test.js @@ -0,0 +1,55 @@ +import { NOTIFICATION_CONFIGURATION, NOTIFICATION_TYPES } from './constants'; + +describe('NOTIFICATION_CONFIGURATION', () => { + describe(NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, () => { + const config = NOTIFICATION_CONFIGURATION[NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION]; + + it('textFn interpolates userName and recipientName', () => { + expect(config.textFn({ userName: 'Alice', recipientName: 'Head Start Program' })).toBe( + 'Alice has requested changes to your Activity Report for Head Start Program.' + ); + }); + + it('actionable is true', () => { + expect(config.actionable).toBe(true); + }); + + it('linkFn returns the activity report path with the given id', () => { + expect(config.linkFn({ id: 42 })).toBe('/activity-reports/42'); + }); + + it('linkText returns "View AR"', () => { + expect(config.linkText()).toBe('View AR'); + }); + + it('displayId returns the displayId param', () => { + expect(config.displayId({ displayId: 'AR-123' })).toBe('AR-123'); + }); + }); + + describe(NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, () => { + const config = NOTIFICATION_CONFIGURATION[NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE]; + + it('textFn interpolates date', () => { + expect(config.textFn({ date: '6/15/2026 8:00 PM – 10:00 PM ET' })).toBe( + 'Planned outage: the TTA Hub will be closed for maintenance from 6/15/2026 8:00 PM – 10:00 PM ET' + ); + }); + + it('actionable is true', () => { + expect(config.actionable).toBe(true); + }); + + it('linkFn returns null', () => { + expect(config.linkFn()).toBeNull(); + }); + + it('linkText returns null', () => { + expect(config.linkText()).toBeNull(); + }); + + it('displayId returns null', () => { + expect(config.displayId()).toBeNull(); + }); + }); +}); diff --git a/src/policies/notifications.test.ts b/src/policies/notifications.test.ts new file mode 100644 index 0000000000..97391e97d4 --- /dev/null +++ b/src/policies/notifications.test.ts @@ -0,0 +1,81 @@ +import SCOPES from '../middleware/scopeConstants'; +import Notifications from './notifications'; + +describe('NotificationsPolicy', () => { + const adminUser = { + id: 1, + permissions: [{ regionId: 1, scopeId: SCOPES.ADMIN }], + }; + + const regularUser = { + id: 2, + permissions: [{ regionId: 1, scopeId: SCOPES.READ_WRITE_REPORTS }], + }; + + const ownedNotification = { userId: 2 }; + const otherNotification = { userId: 99 }; + const globalNotification = { userId: null }; + + describe('isAdmin', () => { + it('returns true when user has the ADMIN scope', () => { + const policy = new Notifications(adminUser, ownedNotification); + expect(policy.isAdmin()).toBe(true); + }); + + it('returns false when user does not have the ADMIN scope', () => { + const policy = new Notifications(regularUser, ownedNotification); + expect(policy.isAdmin()).toBe(false); + }); + }); + + describe('isOwnedNotification', () => { + it('returns true when notification userId matches user id', () => { + const policy = new Notifications(regularUser, ownedNotification); + expect(policy.isOwnedNotification()).toBe(true); + }); + + it('returns false when notification userId does not match user id', () => { + const policy = new Notifications(regularUser, otherNotification); + expect(policy.isOwnedNotification()).toBe(false); + }); + + it('returns false for global notifications with null userId', () => { + const policy = new Notifications(regularUser, globalNotification); + expect(policy.isOwnedNotification()).toBe(false); + }); + }); + + describe('isGlobalNotification', () => { + it('returns true for notifications with null userId', () => { + const policy = new Notifications(regularUser, globalNotification); + expect(policy.isGlobalNotification()).toBe(true); + }); + + it('returns false for notifications with a non-null userId', () => { + const policy = new Notifications(regularUser, ownedNotification); + expect(policy.isGlobalNotification()).toBe(false); + }); + }); + + describe('canUpdateNotification', () => { + it('returns true when user is admin even if not the owner', () => { + const policy = new Notifications(adminUser, otherNotification); + expect(policy.canUpdateNotification()).toBe(true); + }); + + it('returns true when user is the notification owner', () => { + const policy = new Notifications(regularUser, ownedNotification); + expect(policy.canUpdateNotification()).toBe(true); + }); + + it('returns true for global notifications even when user is not admin or owner', () => { + const policy = new Notifications(regularUser, globalNotification); + expect(policy.canUpdateNotification()).toBe(true); + }); + + it('returns false when user is neither admin nor owner', () => { + const policy = new Notifications(regularUser, otherNotification); + expect(policy.canUpdateNotification()).toBe(false); + }); + }); +}); diff --git a/src/policies/notifications.ts b/src/policies/notifications.ts new file mode 100644 index 0000000000..2e6652877d --- /dev/null +++ b/src/policies/notifications.ts @@ -0,0 +1,44 @@ +import { isUndefined } from 'lodash'; +import SCOPES from '../middleware/scopeConstants'; + +interface Permission { + regionId: number; + scopeId: number; +} + +interface UserType { + id: number; + permissions: Permission[]; +} + +interface NotificationType { + userId: number | null; +} + +export default class Notifications { + readonly user: UserType; + readonly notification: NotificationType; + + constructor(user: UserType, notification: NotificationType) { + this.user = user; + this.notification = notification; + } + + isAdmin() { + return !isUndefined( + this.user.permissions.find((permission) => permission.scopeId === SCOPES.ADMIN) + ); + } + + isOwnedNotification() { + return this.notification.userId === this.user.id; + } + + isGlobalNotification() { + return this.notification.userId === null; + } + + canUpdateNotification() { + return this.isAdmin() || this.isOwnedNotification() || this.isGlobalNotification(); + } +} diff --git a/src/routes/apiDirectory.js b/src/routes/apiDirectory.js index cb139c68bb..60cca3aa87 100644 --- a/src/routes/apiDirectory.js +++ b/src/routes/apiDirectory.js @@ -24,6 +24,7 @@ import goalTemplatesRouter from './goalTemplates'; import groupsRouter from './groups'; import monitoringRouter from './monitoring'; import nationalCenterRouter from './nationalCenter'; +import notificationsRouter from './notifications'; import objectiveRouter from './objectives'; import recipientRouter from './recipient'; import recipientSpotlightRouter from './recipientSpotlight'; @@ -94,6 +95,7 @@ router.use('/session-reports', sessionReportsRouter); router.use('/national-center', nationalCenterRouter); router.use('/communication-logs', communicationLogRouter); router.use('/monitoring', monitoringRouter); +router.use('/notifications', notificationsRouter); router.use('/courses', coursesRouter); router.use('/citations', citationsRouter); router.use('/ssdi', ssdiRouter); diff --git a/src/routes/notifications/handlers.test.ts b/src/routes/notifications/handlers.test.ts new file mode 100644 index 0000000000..192b7c3eb9 --- /dev/null +++ b/src/routes/notifications/handlers.test.ts @@ -0,0 +1,299 @@ +import type { Request, Response } from 'express'; +import StatusCodes from 'http-status-codes'; +import { NOTIFICATION_TYPES } from '../../constants'; +import handleErrors from '../../lib/apiErrorHandler'; +import SCOPES from '../../middleware/scopeConstants'; +import db from '../../models'; +import * as currentUserService from '../../services/currentUser'; +import * as notificationsService from '../../services/notifications'; +import * as usersService from '../../services/users'; +import { + createGlobalNotificationHandler, + getNotificationsHandler, + updateNotificationHandler, +} from './handlers'; + +jest.mock('../../services/notifications'); +jest.mock('../../services/currentUser'); +jest.mock('../../services/users'); +jest.mock('../../lib/apiErrorHandler'); +jest.mock('../../models', () => ({ + Notification: { findByPk: jest.fn() }, +})); + +const { Notification } = db; + +const logContext = { namespace: 'HANDLERS:NOTIFICATIONS' }; + +describe('notification handlers', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockJson: jest.Mock; + let mockStatus: jest.Mock; + + beforeEach(() => { + mockJson = jest.fn(); + mockStatus = jest.fn().mockReturnValue({ json: mockJson }); + mockRequest = { query: {}, params: {}, body: {} }; + mockResponse = { status: mockStatus, json: mockJson }; + jest.clearAllMocks(); + }); + + describe('getNotificationsHandler', () => { + it('returns notifications for the current user with default options', async () => { + (currentUserService.currentUserId as jest.Mock).mockResolvedValue(42); + const mockNotifications = [{ id: 1 }, { id: 2 }]; + (notificationsService.getNotifications as jest.Mock).mockResolvedValue(mockNotifications); + + mockRequest.query = {}; + + await getNotificationsHandler(mockRequest as Request, mockResponse as Response); + + expect(notificationsService.getNotifications).toHaveBeenCalledWith(42, [], { + limit: undefined, + sortBy: undefined, + sortDirection: undefined, + offset: undefined, + }); + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.OK); + expect(mockJson).toHaveBeenCalledWith(mockNotifications); + }); + + it('passes pagination and sort options from query params', async () => { + (currentUserService.currentUserId as jest.Mock).mockResolvedValue(42); + (notificationsService.getNotifications as jest.Mock).mockResolvedValue([]); + + mockRequest.query = { + limit: '5', + offset: '10', + sortBy: 'createdAt', + sortDirection: 'ASC', + }; + + await getNotificationsHandler(mockRequest as Request, mockResponse as Response); + + expect(notificationsService.getNotifications).toHaveBeenCalledWith(42, [], { + limit: 5, + sortBy: 'createdAt', + sortDirection: 'ASC', + offset: 10, + }); + }); + + it('calls handleErrors when an error is thrown', async () => { + const error = new Error('service error'); + (currentUserService.currentUserId as jest.Mock).mockRejectedValue(error); + + await getNotificationsHandler(mockRequest as Request, mockResponse as Response); + + expect(handleErrors).toHaveBeenCalledWith(mockRequest, mockResponse, error, logContext); + }); + }); + + describe('updateNotificationHandler', () => { + const mockUser = { id: 42, permissions: [] }; + const mockNotification = { id: 1, userId: 42 }; + + beforeEach(() => { + (currentUserService.currentUserId as jest.Mock).mockResolvedValue(42); + (usersService.userById as jest.Mock).mockResolvedValue(mockUser); + }); + + it('returns 404 when notification is not found', async () => { + (Notification.findByPk as jest.Mock).mockResolvedValue(null); + mockRequest.params = { notificationId: '999' }; + mockRequest.body = {}; + + await updateNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.NOT_FOUND); + expect(mockJson).toHaveBeenCalledWith({ message: 'Notification not found' }); + }); + + it('returns 403 when user does not have permission', async () => { + const otherNotification = { id: 1, userId: 99 }; + (Notification.findByPk as jest.Mock).mockResolvedValue(otherNotification); + mockRequest.params = { notificationId: '1' }; + mockRequest.body = {}; + + await updateNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.FORBIDDEN); + expect(mockJson).toHaveBeenCalledWith({ + message: 'User does not have permission to update this notification', + }); + }); + + it('returns 403 when admin tries to update a notification owned by another user', async () => { + const adminUser = { + id: 42, + permissions: [{ regionId: 1, scopeId: SCOPES.ADMIN }], + }; + (currentUserService.currentUserId as jest.Mock).mockResolvedValue(42); + (usersService.userById as jest.Mock).mockResolvedValue(adminUser); + + const otherUserNotification = { id: 5, userId: 99 }; + (Notification.findByPk as jest.Mock).mockResolvedValue(otherUserNotification); + + mockRequest.params = { notificationId: '5' }; + mockRequest.body = { viewedAt: '2026-01-01' }; + + await updateNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.FORBIDDEN); + expect(mockJson).toHaveBeenCalledWith({ + message: 'User does not have permission to update this notification', + }); + }); + + it('returns 200 with updated notification state on success', async () => { + (Notification.findByPk as jest.Mock).mockResolvedValue(mockNotification); + const updatedData = { archivedAt: '2026-01-01' }; + const updatedResult = { id: 10, notificationId: 1, userId: 42, ...updatedData }; + (notificationsService.updateNotificationState as jest.Mock).mockResolvedValue(updatedResult); + + mockRequest.params = { notificationId: '1' }; + mockRequest.body = updatedData; + + await updateNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(notificationsService.updateNotificationState).toHaveBeenCalledWith(1, 42, updatedData); + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.OK); + expect(mockJson).toHaveBeenCalledWith(updatedResult); + }); + + it('returns 200 with updated state for a global notification', async () => { + const globalNotification = { id: 2, userId: null }; + (Notification.findByPk as jest.Mock).mockResolvedValue(globalNotification); + const updatedData = { viewedAt: '2026-01-01' }; + const updatedResult = { id: 10, notificationId: 2, userId: 42, ...updatedData }; + (notificationsService.updateNotificationState as jest.Mock).mockResolvedValue(updatedResult); + + mockRequest.params = { notificationId: '2' }; + mockRequest.body = updatedData; + + await updateNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(notificationsService.updateNotificationState).toHaveBeenCalledWith(2, 42, updatedData); + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.OK); + expect(mockJson).toHaveBeenCalledWith(updatedResult); + }); + + it('calls handleErrors when an error is thrown', async () => { + const error = new Error('db error'); + (currentUserService.currentUserId as jest.Mock).mockRejectedValue(error); + mockRequest.params = { notificationId: '1' }; + mockRequest.body = {}; + + await updateNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(handleErrors).toHaveBeenCalledWith(mockRequest, mockResponse, error, logContext); + }); + }); + + describe('createGlobalNotificationHandler', () => { + it('returns 201 with the created notification', async () => { + const notificationData = { + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + id: 123, + text: 'System Update', + triggeredAt: '2026-06-01T00:00:00.000Z', + displayId: 'SYS-001', + }; + const createdNotification = { id: 1, type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE }; + (notificationsService.createGlobalNotification as jest.Mock).mockResolvedValue( + createdNotification + ); + + mockRequest.body = notificationData; + + await createGlobalNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(notificationsService.createGlobalNotification).toHaveBeenCalledWith( + notificationData.type, + { + metadata: { + id: 123, + recipientName: 'System Update', + userName: 'System Update', + date: notificationData.triggeredAt, + displayId: 'SYS-001', + }, + } + ); + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.CREATED); + expect(mockJson).toHaveBeenCalledWith(createdNotification); + }); + + it('handles null optional fields gracefully', async () => { + const notificationData = { type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE }; + const createdNotification = { id: 2, type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE }; + (notificationsService.createGlobalNotification as jest.Mock).mockResolvedValue( + createdNotification + ); + + mockRequest.body = notificationData; + + await createGlobalNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(notificationsService.createGlobalNotification).toHaveBeenCalledWith( + notificationData.type, + { + metadata: { + id: undefined, + recipientName: undefined, + userName: undefined, + date: undefined, + displayId: undefined, + }, + } + ); + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.CREATED); + }); + + it('returns 400 when type is missing', async () => { + mockRequest.body = { triggeredAt: '2026-06-01T00:00:00.000Z' }; + + await createGlobalNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Invalid notification type') }) + ); + }); + + it('returns 400 when type is not in the admin whitelist', async () => { + mockRequest.body = { type: 'changesRequested' }; + + await createGlobalNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Invalid notification type') }) + ); + }); + + it('returns 400 when triggeredAt is not a valid date', async () => { + mockRequest.body = { + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + triggeredAt: 'not-a-date', + }; + + await createGlobalNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST); + expect(mockJson).toHaveBeenCalledWith({ message: 'Invalid triggeredAt date' }); + }); + + it('calls handleErrors when an error is thrown', async () => { + const error = new Error('creation failed'); + (notificationsService.createGlobalNotification as jest.Mock).mockRejectedValue(error); + + mockRequest.body = { type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE }; + + await createGlobalNotificationHandler(mockRequest as Request, mockResponse as Response); + + expect(handleErrors).toHaveBeenCalledWith(mockRequest, mockResponse, error, logContext); + }); + }); +}); diff --git a/src/routes/notifications/handlers.ts b/src/routes/notifications/handlers.ts new file mode 100644 index 0000000000..6d556fde07 --- /dev/null +++ b/src/routes/notifications/handlers.ts @@ -0,0 +1,111 @@ +import type { Request, Response } from 'express'; +import StatusCodes from 'http-status-codes'; +import { ADMIN_BROADCASTABLE_NOTIFICATION_TYPES } from '../../constants'; +import handleErrors from '../../lib/apiErrorHandler'; +import db from '../../models'; +import NotificationsPolicy from '../../policies/notifications'; +import { currentUserId } from '../../services/currentUser'; +import { + createGlobalNotification, + getNotifications, + updateNotificationState, +} from '../../services/notifications'; +import { userById } from '../../services/users'; + +const { Notification } = db; + +const namespace = 'HANDLERS:NOTIFICATIONS'; + +const logContext = { + namespace, +}; + +export async function getNotificationsHandler(req: Request, res: Response) { + try { + const { limit, sortBy, sortDirection, offset } = req.query; + + const userId = await currentUserId(req, res); + + const notifications = await getNotifications(userId, [], { + limit: limit ? Number(limit) : undefined, + sortBy: typeof sortBy === 'string' ? sortBy : undefined, + sortDirection: typeof sortDirection === 'string' ? sortDirection : undefined, + offset: offset ? Number(offset) : undefined, + }); + + res.status(StatusCodes.OK).json(notifications); + } catch (error) { + await handleErrors(req, res, error, logContext); + } +} + +export async function updateNotificationHandler(req: Request, res: Response) { + try { + const notificationId = Number(req.params.notificationId); + const updatedNotification = req.body; + const userId = await currentUserId(req, res); + const user = await userById(userId); + + const notification = await Notification.findByPk(notificationId); + + if (!notification) { + return res.status(StatusCodes.NOT_FOUND).json({ message: 'Notification not found' }); + } + + const policy = new NotificationsPolicy(user, notification); + + if (!policy.canUpdateNotification()) { + return res + .status(StatusCodes.FORBIDDEN) + .json({ message: 'User does not have permission to update this notification' }); + } + + // Prevent writing state for notifications owned by another user + if (notification.userId !== null && notification.userId !== userId) { + return res + .status(StatusCodes.FORBIDDEN) + .json({ message: 'User does not have permission to update this notification' }); + } + + const updated = await updateNotificationState(notification.id, userId, updatedNotification); + + return res.status(StatusCodes.OK).json(updated); + } catch (error) { + await handleErrors(req, res, error, logContext); + } +} + +export async function createGlobalNotificationHandler(req: Request, res: Response) { + // admin access is checked in the middleware + try { + const notificationData = req.body; + const { type, triggeredAt } = notificationData; + + if (!type || !ADMIN_BROADCASTABLE_NOTIFICATION_TYPES.includes(type)) { + return res.status(StatusCodes.BAD_REQUEST).json({ + message: `Invalid notification type. Must be one of: ${ADMIN_BROADCASTABLE_NOTIFICATION_TYPES.join(', ')}`, + }); + } + + let parsedTriggeredAt: Date | undefined; + if (triggeredAt !== undefined && triggeredAt !== null) { + parsedTriggeredAt = new Date(triggeredAt); + if (Number.isNaN(parsedTriggeredAt.getTime())) { + return res.status(StatusCodes.BAD_REQUEST).json({ message: 'Invalid triggeredAt date' }); + } + } + + const notification = await createGlobalNotification(type, { + metadata: { + id: notificationData.id ?? undefined, + recipientName: notificationData.text ?? undefined, + userName: notificationData.text ?? undefined, + date: parsedTriggeredAt?.toISOString(), + displayId: notificationData.displayId ?? undefined, + }, + }); + res.status(StatusCodes.CREATED).json(notification); + } catch (error) { + await handleErrors(req, res, error, logContext); + } +} diff --git a/src/routes/notifications/index.ts b/src/routes/notifications/index.ts new file mode 100644 index 0000000000..468789560e --- /dev/null +++ b/src/routes/notifications/index.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import { checkNotificationIdParam } from '../../middleware/checkIdParamMiddleware'; + +import userAdminAccessMiddleware from '../../middleware/userAdminAccessMiddleware'; +import transactionWrapper from '../transactionWrapper'; +import { + createGlobalNotificationHandler, + getNotificationsHandler, + updateNotificationHandler, +} from './handlers'; + +const router = express.Router(); + +router.post( + '/admin', + userAdminAccessMiddleware, + transactionWrapper(createGlobalNotificationHandler) +); +router.put( + '/:notificationId', + checkNotificationIdParam, + transactionWrapper(updateNotificationHandler) +); +router.get('/', transactionWrapper(getNotificationsHandler)); + +export default router; diff --git a/src/scopes/notifications/notificationType.ts b/src/scopes/notifications/notificationType.ts index 8c21b91577..5739a6215d 100644 --- a/src/scopes/notifications/notificationType.ts +++ b/src/scopes/notifications/notificationType.ts @@ -3,64 +3,24 @@ import { NOTIFICATION_TYPES } from '../../constants'; const VALID_TYPES = ['activityReport', 'collabReport', 'trainingReport', 'systemRelated', 'other']; -export const NOTIFICATION_TYPE_MAP: Record = { - activityReport: [ - NOTIFICATION_TYPES.ACTIVITY_REPORT_COLLABORATOR_ADDED, - NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, - NOTIFICATION_TYPES.ACTIVITY_REPORT_SUBMITTED, - NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED, - NOTIFICATION_TYPES.ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED, - NOTIFICATION_TYPES.ACTIVITY_REPORT_RESUBMITTED, - ], - collabReport: [ - NOTIFICATION_TYPES.COLLAB_REPORT_COLLABORATOR_ADDED, - NOTIFICATION_TYPES.COLLAB_REPORT_SUBMITTED, - NOTIFICATION_TYPES.COLLAB_REPORT_RESUBMITTED, - NOTIFICATION_TYPES.COLLAB_REPORT_NEEDS_ACTION, - NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED, - ], - trainingReport: [ - NOTIFICATION_TYPES.TRAINING_REPORT_POC_ADDED, - NOTIFICATION_TYPES.TRAINING_REPORT_COLLABORATOR_ADDED, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_CREATED, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_SUBMITTED, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_NEEDS_ACTION, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_RESUBMITTED, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_COMPLETED, - NOTIFICATION_TYPES.TRAINING_REPORT_TASK_DUE, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_IMPORTED, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_INFO_MISSING, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_INFO_PAST_DUE, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_INFO_MISSING, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_INFO_PAST_DUE, - NOTIFICATION_TYPES.TRAINING_REPORT_NO_SESSIONS_CREATED, - NOTIFICATION_TYPES.TRAINING_REPORT_NO_SESSIONS_PAST_DUE, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_NOT_COMPLETED, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_NOT_COMPLETED_PAST_DUE, - NOTIFICATION_TYPES.TRAINING_REPORT_POC_ADDED_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_COLLABORATOR_ADDED_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_SUBMITTED_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_NEEDS_ACTION_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_INFO_MISSING_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_SESSION_INFO_MISSING_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_NO_SESSIONS_CREATED_DIGEST, - NOTIFICATION_TYPES.TRAINING_REPORT_EVENT_NOT_COMPLETED_DIGEST, - ], - systemRelated: [ - NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, - NOTIFICATION_TYPES.SYSTEM_UNPLANNED_OUTAGE, - ], - other: [ - NOTIFICATION_TYPES.COMMUNICATION_LOG_TTA_STAFF_ADDED, - NOTIFICATION_TYPES.COMMUNICATION_LOG_RECIPIENT_IN_GROUP, - NOTIFICATION_TYPES.COMMUNICATION_LOG_TTA_STAFF_ADDED_DIGEST, - NOTIFICATION_TYPES.COMMUNICATION_LOG_RECIPIENT_IN_GROUP_DIGEST, - NOTIFICATION_TYPES.MONITORING_GOAL_ADDED, - NOTIFICATION_TYPES.MONITORING_DATA_RECEIVED, - NOTIFICATION_TYPES.GROUP_CO_OWNER_ADDED, - NOTIFICATION_TYPES.GROUP_SHARED, - ], -}; +const KEY_TO_CATEGORY: [string, string][] = [ + ['ACTIVITY_REPORT_', 'activityReport'], + ['COLLAB_REPORT_', 'collabReport'], + ['TRAINING_REPORT_', 'trainingReport'], + ['SYSTEM_', 'systemRelated'], +]; + +export const NOTIFICATION_TYPE_MAP: Record = Object.entries( + NOTIFICATION_TYPES +).reduce( + (acc, [key, value]) => { + const category = KEY_TO_CATEGORY.find(([prefix]) => key.startsWith(prefix))?.[1] ?? 'other'; + if (!acc[category]) acc[category] = []; + acc[category].push(value as string); + return acc; + }, + {} as Record +); function filterToNotificationTypes(types: string[]): string[] { const resolved = types diff --git a/src/seeders/20260608135145-notifications.js b/src/seeders/20260608135145-notifications.js index 7cc043b4cf..5660168dd5 100644 --- a/src/seeders/20260608135145-notifications.js +++ b/src/seeders/20260608135145-notifications.js @@ -1,7 +1,6 @@ const { NOTIFICATION_TYPES } = require('../constants'); const notifications = [ - // User-specific notification (user 5 — standard seed user) { id: 30001, userId: 5, @@ -13,7 +12,6 @@ const notifications = [ createdAt: new Date(), updatedAt: new Date(), }, - // Viewed notification (user 5) { id: 30002, userId: 5, @@ -22,12 +20,9 @@ const notifications = [ link: '/activity-reports/2/review', label: 'Activity Report #2', text: 'Changes were requested on Activity Report #2.', - archivedAt: null, - viewedAt: '2025-01-02', createdAt: new Date(), updatedAt: new Date(), }, - // Archived notification (user 5) { id: 30003, userId: 5, @@ -36,13 +31,10 @@ const notifications = [ link: '/activity-reports/3/review', label: 'Activity Report #3', text: 'Changes were requested on Activity Report #3.', - archivedAt: '2025-01-03', - viewedAt: '2025-01-03', createdAt: new Date(), updatedAt: new Date(), triggeredAt: '2025-01-02', }, - // Global notification (no userId) { id: 30004, userId: null, @@ -51,8 +43,6 @@ const notifications = [ link: '/notifications', label: 'System Notice', text: 'A system-wide notice for all users.', - archivedAt: null, - viewedAt: null, createdAt: new Date(), updatedAt: new Date(), triggeredAt: null, diff --git a/src/services/notifications.test.js b/src/services/notifications.test.js new file mode 100644 index 0000000000..f6ba5bc7ff --- /dev/null +++ b/src/services/notifications.test.js @@ -0,0 +1,468 @@ +import faker from '@faker-js/faker'; +import { NOTIFICATION_TYPES } from '../constants'; +import db from '../models'; +import { + createGlobalNotification, + createNotification, + deleteNotification, + deleteNotificationsByEntityAndType, + getNotifications, + updateNotificationState, +} from './notifications'; + +const { Notification, NotificationUserState, User } = db; + +describe('Notification service', () => { + let user; + let otherUser; + let createdNotificationIds = []; + + const activityMetadata = (id = faker.datatype.number({ min: 99001, max: 99999 })) => ({ + id, + recipientName: faker.company.companyName(), + userName: faker.name.findName(), + date: '01/15/2026', + displayId: `R01-AR-${id}`, + }); + + const outageMetadata = { + id: faker.datatype.number({ min: 99001, max: 99999 }), + recipientName: faker.company.companyName(), + userName: faker.name.findName(), + date: '01/15/2026 12:00 PM ET', + }; + + const trackNotification = (notification) => { + createdNotificationIds.push(notification.id); + return notification; + }; + + const createTrackedNotification = async (overrides = {}) => { + const notification = await Notification.create({ + userId: user.id, + entityId: faker.datatype.number({ min: 99001, max: 99999 }), + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + text: faker.lorem.sentence(), + ...overrides, + }); + + return trackNotification(notification); + }; + + beforeAll(async () => { + user = await User.create({ + id: faker.datatype.number({ min: 99001, max: 99999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + lastLogin: new Date(), + }); + + otherUser = await User.create({ + id: faker.datatype.number({ min: 88001, max: 88999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + lastLogin: new Date(), + }); + }); + + afterEach(async () => { + if (createdNotificationIds.length) { + await NotificationUserState.destroy({ where: { notificationId: createdNotificationIds } }); + await Notification.destroy({ where: { id: createdNotificationIds } }); + createdNotificationIds = []; + } + }); + + afterAll(async () => { + if (createdNotificationIds.length) { + await NotificationUserState.destroy({ where: { notificationId: createdNotificationIds } }); + await Notification.destroy({ where: { id: createdNotificationIds } }); + } + + await User.destroy({ where: { id: [user.id, otherUser.id] } }); + await db.sequelize.close(); + }); + + describe('createNotification', () => { + it('creates a user notification with link and label when the type has configuration', async () => { + const metadata = activityMetadata(); + + const notification = trackNotification( + await createNotification( + user.id, + metadata.id, + NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + { metadata } + ) + ); + + expect(notification.userId).toBe(user.id); + expect(notification.entityId).toBe(metadata.id); + expect(notification.type).toBe(NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION); + expect(notification.text).toBe( + `${metadata.userName} has requested changes to your Activity Report for ${metadata.recipientName}.` + ); + expect(notification.link).toBe(`/activity-reports/${metadata.id}`); + expect(notification.label).toBe('View AR'); + expect(notification.displayId).toBe(metadata.displayId); + }); + + it('creates a user notification with null link and label when configuration returns null', async () => { + const notification = trackNotification( + await createNotification( + user.id, + faker.datatype.number({ min: 99001, max: 99999 }), + NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + { metadata: outageMetadata } + ) + ); + + expect(notification.userId).toBe(user.id); + expect(notification.type).toBe(NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE); + expect(notification.text).toBe( + `Planned outage: the TTA Hub will be closed for maintenance from ${outageMetadata.date}` + ); + expect(notification.link).toBeNull(); + expect(notification.label).toBeNull(); + expect(notification.displayId).toBeNull(); + }); + + it('throws an error when the notification type has no configuration', async () => { + await expect( + createNotification( + user.id, + faker.datatype.number({ min: 99001, max: 99999 }), + 'invalidType', + { metadata: activityMetadata() } + ) + ).rejects.toThrow('No notification configuration found for type invalidType'); + }); + }); + + describe('createGlobalNotification', () => { + it('creates a global notification with the configured text', async () => { + const notification = trackNotification( + await createGlobalNotification(NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, { + metadata: outageMetadata, + }) + ); + + expect(notification.userId).toBeNull(); + expect(notification.type).toBe(NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE); + expect(notification.text).toBe( + `Planned outage: the TTA Hub will be closed for maintenance from ${outageMetadata.date}` + ); + expect(notification.displayId).toBeNull(); + }); + + it('throws an error when the notification type has no configuration', async () => { + await expect( + createGlobalNotification('invalidType', { + metadata: activityMetadata(), + }) + ).rejects.toThrow('No notification configuration found for type invalidType'); + }); + }); + + describe('updateNotificationState', () => { + it('creates a state row when none exists', async () => { + const notification = await createTrackedNotification(); + + const state = await updateNotificationState(notification.id, user.id, { + viewedAt: '2026-01-15', + }); + + expect(state.notificationId).toBe(notification.id); + expect(state.userId).toBe(user.id); + expect(state.viewedAt).toBe('2026-01-15'); + expect(state.archivedAt).toBeNull(); + }); + + it('updates an existing state row', async () => { + const notification = await createTrackedNotification(); + await NotificationUserState.create({ + notificationId: notification.id, + userId: user.id, + viewedAt: '2026-01-15', + archivedAt: null, + }); + + const state = await updateNotificationState(notification.id, user.id, { + archivedAt: '2026-01-20', + }); + + expect(state.viewedAt).toBe('2026-01-15'); + expect(state.archivedAt).toBe('2026-01-20'); + }); + + it('only updates viewedAt and archivedAt', async () => { + const notification = await createTrackedNotification({ text: 'Original text' }); + + await updateNotificationState(notification.id, user.id, { + viewedAt: '2026-02-01', + text: 'Ignored text', + }); + + const state = await NotificationUserState.findOne({ + where: { notificationId: notification.id, userId: user.id }, + }); + const foundNotification = await Notification.findByPk(notification.id); + + expect(state.viewedAt).toBe('2026-02-01'); + expect(state.archivedAt).toBeNull(); + expect(state.get('text')).toBeUndefined(); + expect(foundNotification.text).toBe('Original text'); + }); + + it('works for both user-scoped and global notifications', async () => { + const userNotification = await createTrackedNotification(); + const globalNotification = await createTrackedNotification({ userId: null }); + + const userState = await updateNotificationState(userNotification.id, user.id, { + viewedAt: '2026-03-01', + }); + const globalState = await updateNotificationState(globalNotification.id, user.id, { + archivedAt: '2026-03-02', + }); + + expect(userState.notificationId).toBe(userNotification.id); + expect(globalState.notificationId).toBe(globalNotification.id); + expect(globalState.userId).toBe(user.id); + }); + }); + + describe('deleteNotification', () => { + it('destroys the notification with the given ID and returns 1', async () => { + const notification = await createTrackedNotification(); + + const deletedCount = await deleteNotification(notification.id); + + expect(deletedCount).toBe(1); + const found = await Notification.findByPk(notification.id); + expect(found).toBeNull(); + }); + + it('throws when notificationId is falsy', async () => { + await expect(deleteNotification(0)).rejects.toThrow('notificationId is required'); + await expect(deleteNotification(null)).rejects.toThrow('notificationId is required'); + await expect(deleteNotification(undefined)).rejects.toThrow('notificationId is required'); + }); + }); + + describe('deleteNotificationsByEntityAndType', () => { + it('destroys all notifications for the given entityId and type', async () => { + const entityId = faker.datatype.number({ min: 99001, max: 99999 }); + const matchingOne = await createTrackedNotification({ + entityId, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + }); + const matchingTwo = await createTrackedNotification({ + entityId, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + }); + const differentType = await createTrackedNotification({ + entityId, + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + }); + + const deletedCount = await deleteNotificationsByEntityAndType( + entityId, + NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION + ); + + expect(deletedCount).toBe(2); + + const remaining = await Notification.findAll({ + where: { id: [matchingOne.id, matchingTwo.id, differentType.id] }, + }); + expect(remaining.map((n) => n.id)).toEqual([differentType.id]); + }); + + it('returns 0 when no notifications match', async () => { + const entityId = faker.datatype.number({ min: 99001, max: 99999 }); + await createTrackedNotification({ entityId, type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE }); + + const deletedCount = await deleteNotificationsByEntityAndType( + entityId, + NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION + ); + + expect(deletedCount).toBe(0); + }); + + it('throws when entityId is falsy', async () => { + await expect( + deleteNotificationsByEntityAndType(null, NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION) + ).rejects.toThrow('entityId is required'); + await expect( + deleteNotificationsByEntityAndType( + undefined, + NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION + ) + ).rejects.toThrow('entityId is required'); + }); + + it('throws when notificationType is falsy', async () => { + const entityId = faker.datatype.number({ min: 99001, max: 99999 }); + await expect(deleteNotificationsByEntityAndType(entityId, null)).rejects.toThrow( + 'notificationType is required' + ); + await expect(deleteNotificationsByEntityAndType(entityId, undefined)).rejects.toThrow( + 'notificationType is required' + ); + }); + }); + + describe('getNotifications', () => { + it('returns user-scoped notifications for the user', async () => { + const ownNotification = await createTrackedNotification({ triggeredAt: '2026-05-01' }); + const otherNotification = await createTrackedNotification({ + userId: otherUser.id, + triggeredAt: '2026-05-02', + }); + + const notifications = await getNotifications(user.id, [ + { id: [ownNotification.id, otherNotification.id] }, + ]); + + expect(notifications.map((notification) => notification.id)).toEqual([ownNotification.id]); + }); + + it('returns global notifications for all users', async () => { + const globalNotification = await createTrackedNotification({ + userId: null, + triggeredAt: '2026-05-03', + }); + + const notifications = await getNotifications(otherUser.id, [{ id: [globalNotification.id] }]); + + expect(notifications.map((notification) => notification.id)).toEqual([globalNotification.id]); + }); + + it('does not return notifications from other users', async () => { + const otherNotification = await createTrackedNotification({ + userId: otherUser.id, + triggeredAt: '2026-05-04', + }); + + const notifications = await getNotifications(user.id, [{ id: [otherNotification.id] }]); + + expect(notifications).toEqual([]); + }); + + it('filters out archived notifications', async () => { + const archivedNotification = await createTrackedNotification({ triggeredAt: '2026-05-05' }); + await NotificationUserState.create({ + notificationId: archivedNotification.id, + userId: user.id, + archivedAt: '2026-05-06', + viewedAt: null, + }); + + const notifications = await getNotifications(user.id, [{ id: [archivedNotification.id] }]); + + expect(notifications).toEqual([]); + }); + + it('returns unarchived notifications when state is missing or archivedAt is null', async () => { + const withoutState = await createTrackedNotification({ triggeredAt: '2026-05-07' }); + const withOpenState = await createTrackedNotification({ triggeredAt: '2026-05-08' }); + await NotificationUserState.create({ + notificationId: withOpenState.id, + userId: user.id, + archivedAt: null, + viewedAt: '2026-05-09', + }); + + const notifications = await getNotifications(user.id, [ + { id: [withoutState.id, withOpenState.id] }, + ]); + + expect(notifications.map((notification) => notification.id)).toEqual([ + withOpenState.id, + withoutState.id, + ]); + }); + + it('respects pagination', async () => { + const first = await createTrackedNotification({ triggeredAt: '2026-06-01' }); + const second = await createTrackedNotification({ triggeredAt: '2026-06-02' }); + const third = await createTrackedNotification({ triggeredAt: '2026-06-03' }); + + const notifications = await getNotifications( + user.id, + [{ id: [first.id, second.id, third.id] }], + { limit: 1, offset: 1 } + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].id).toBe(second.id); + }); + + it('respects sortBy and sortDirection', async () => { + const first = await createTrackedNotification({ triggeredAt: '2026-07-01' }); + const second = await createTrackedNotification({ triggeredAt: '2026-07-02' }); + const third = await createTrackedNotification({ triggeredAt: '2026-07-03' }); + + const notifications = await getNotifications( + user.id, + [{ id: [first.id, second.id, third.id] }], + { sortBy: 'triggeredAt', sortDirection: 'ASC' } + ); + + expect(notifications.map((notification) => notification.id)).toEqual([ + first.id, + second.id, + third.id, + ]); + }); + + it('attaches user state to returned notifications', async () => { + const notification = await createTrackedNotification({ triggeredAt: '2026-08-01' }); + await NotificationUserState.create({ + notificationId: notification.id, + userId: user.id, + viewedAt: '2026-08-02', + archivedAt: null, + }); + + const [result] = await getNotifications(user.id, [{ id: [notification.id] }]); + + expect(result.userState).toMatchObject({ + notificationId: notification.id, + userId: user.id, + viewedAt: '2026-08-02', + archivedAt: null, + }); + expect(result.viewedAt).toBe('2026-08-02'); + expect(result.archivedAt).toBeNull(); + expect(result.userStates).toHaveLength(1); + }); + + it('includes viewedAt and archivedAt as own properties on returned objects', async () => { + const notification = await createTrackedNotification({ triggeredAt: '2026-08-03' }); + await NotificationUserState.create({ + notificationId: notification.id, + userId: user.id, + viewedAt: '2026-01-01', + archivedAt: null, + }); + + const results = await getNotifications(user.id, [], {}); + const found = results.find((n) => n.id === notification.id); + expect(found).toBeDefined(); + + expect(Object.hasOwn(found, 'viewedAt')).toBe(true); + expect(Object.hasOwn(found, 'archivedAt')).toBe(true); + expect(Object.hasOwn(found, 'userState')).toBe(true); + expect(found.viewedAt).toBe('2026-01-01'); + expect(found.archivedAt).toBeNull(); + + const json = JSON.parse(JSON.stringify(found)); + expect(json.viewedAt).toBe('2026-01-01'); + expect(json.archivedAt).toBeNull(); + }); + }); +}); diff --git a/src/services/notifications.ts b/src/services/notifications.ts new file mode 100644 index 0000000000..586f601ced --- /dev/null +++ b/src/services/notifications.ts @@ -0,0 +1,254 @@ +import { Op } from 'sequelize'; +import { NOTIFICATION_CONFIGURATION } from '../constants'; +import db from '../models'; +import type { + NotificationMetadata, + NotificationModel, + NotificationScope, + NotificationType, + NotificationUserStateModel, + NotificationWithState, +} from './types/notifications'; + +const { Notification, NotificationUserState } = db; +const NOTIFICATION_PER_PAGE = 10; + +const ALLOWED_SORT_FIELDS = ['triggeredAt', 'createdAt', 'updatedAt'] as const; +type AllowedSortField = (typeof ALLOWED_SORT_FIELDS)[number]; +const ALLOWED_SORT_DIRECTIONS = ['ASC', 'DESC'] as const; +type AllowedSortDirection = (typeof ALLOWED_SORT_DIRECTIONS)[number]; + +/** + * Creates a notification for a specific user and entity. + * @param {number} userId The recipient user ID. + * @param {number} entityId The related entity ID. + * @param {NotificationType} notificationType The notification configuration key to apply. + * @param {{ metadata: NotificationMetadata }} options Values used to generate the notification content. + * @param {NotificationMetadata} options.metadata Metadata passed to the notification text and link builders. + * @returns {Promise} The newly created notification record. + * @throws {Error} Throws when the notification type has no configuration. + */ +async function createNotification( + userId: number, + entityId: number, + notificationType: NotificationType, + { metadata }: { metadata: NotificationMetadata } +): Promise { + const notificationConfig = NOTIFICATION_CONFIGURATION[notificationType]; + if (!notificationConfig) { + throw new Error(`No notification configuration found for type ${notificationType}`); + } + + const notificationText = notificationConfig.textFn(metadata); + const notificationLink = notificationConfig.linkFn + ? notificationConfig.linkFn(metadata) + : undefined; + const notificationLinkText = notificationConfig.linkText + ? notificationConfig.linkText() + : undefined; + + const displayId = notificationConfig.displayId + ? notificationConfig.displayId(metadata) + : undefined; + + return Notification.create({ + userId, + entityId, + type: notificationType, + text: notificationText, + link: notificationLink, + label: notificationLinkText, + displayId, + }); +} + +/** + * Creates a global notification that is not scoped to a specific user. + * @param {NotificationType} notificationType The notification configuration key to apply. + * @param {{ metadata: NotificationMetadata }} options Values used to generate the notification content. + * @param {NotificationMetadata} options.metadata Metadata passed to the notification text and link builders. + * @returns {Promise} The newly created global notification record. + * @throws {Error} Throws when the notification type has no configuration. + */ +async function createGlobalNotification( + notificationType: NotificationType, + { metadata }: { metadata: NotificationMetadata } +): Promise { + const notificationConfig = NOTIFICATION_CONFIGURATION[notificationType]; + if (!notificationConfig) { + throw new Error(`No notification configuration found for type ${notificationType}`); + } + + const notificationText = notificationConfig.textFn(metadata); + const notificationLink = notificationConfig.linkFn + ? notificationConfig.linkFn(metadata) + : undefined; + const notificationLinkText = notificationConfig.linkText + ? notificationConfig.linkText() + : undefined; + + const displayId = notificationConfig.displayId + ? notificationConfig.displayId(metadata) + : undefined; + + return Notification.create({ + type: notificationType, + text: notificationText, + link: notificationLink, + label: notificationLinkText, + displayId, + }); +} + +/** + * Creates or updates the current user's notification state. + * @param {number} notificationId The notification ID. + * @param {number} userId The user ID. + * @param {{ viewedAt?: string | null; archivedAt?: string | null }} updates Allowed state updates. + * @returns {Promise} The persisted notification user state. + */ +async function updateNotificationState( + notificationId: number, + userId: number, + updates: { viewedAt?: string | null; archivedAt?: string | null } +): Promise { + const allowedFields: Array<'viewedAt' | 'archivedAt'> = ['viewedAt', 'archivedAt']; + const fieldsToUpdate: Partial> = {}; + + for (const field of allowedFields) { + if (field in updates) { + fieldsToUpdate[field] = updates[field]; + } + } + + let state = await NotificationUserState.findOne({ + where: { notificationId, userId }, + }); + + if (!state) { + state = await NotificationUserState.create({ notificationId, userId, ...fieldsToUpdate }); + return state; + } + + if (Object.keys(fieldsToUpdate).length > 0) { + await state.update(fieldsToUpdate); + } + + return state; +} + +/** + * Deletes a single notification by ID. + * Not to be called from HTTP handlers — use programmatically (e.g. scheduled cleanup job). + * @param {number} notificationId The ID of the notification to delete. + * @returns {Promise} The number of deleted notifications (0 or 1). + * @throws {Error} Throws when notificationId is falsy. + */ +async function deleteNotification(notificationId: number): Promise { + if (!notificationId) { + throw new Error('notificationId is required'); + } + return Notification.destroy({ where: { id: notificationId } }); +} + +/** + * Deletes all notifications for a given entity and notification type. + * Used to invalidate stale notifications when a state change makes them no longer actionable + * (e.g. an Activity Report returned to "needs action" invalidates pending approval-request + * notifications for that report's approvers). + * Not to be called from HTTP handlers — call it inline in the same service function that + * performs the state change. + * @param {number} entityId The ID of the entity whose notifications should be removed. + * @param {NotificationType} notificationType The notification type to target. + * @returns {Promise} The number of deleted notifications. + * @throws {Error} Throws when entityId or notificationType is falsy. + */ +async function deleteNotificationsByEntityAndType( + entityId: number, + notificationType: NotificationType +): Promise { + if (!entityId) { + throw new Error('entityId is required'); + } + if (!notificationType) { + throw new Error('notificationType is required'); + } + return Notification.destroy({ where: { entityId, type: notificationType } }); +} + +/** + * Retrieves notifications matching the provided scopes with pagination and sorting. + * @param {number} userId Current user ID used for scoped and global notifications. + * @param {NotificationScope[]} scopes Query scopes combined with AND filtering. + * @param {{ limit?: number; offset?: number; sortBy?: string; sortDirection?: string }} [options] Pagination and sort options. + * @param {number} [options.limit=10] Maximum number of notifications to return. + * @param {number} [options.offset=0] Number of notifications to skip. + * @param {string} [options.sortBy='triggeredAt'] Notification field used for sorting. + * @param {string} [options.sortDirection='DESC'] Sort direction for the query. + * @returns {Promise} The matching notifications with user state. + */ +// Retrieves notifications for a user, with sorting and pagination +async function getNotifications( + userId: number, + scopes: NotificationScope[], + { limit = NOTIFICATION_PER_PAGE, offset = 0, sortBy = 'triggeredAt', sortDirection = 'DESC' } = {} +): Promise { + const sort = ALLOWED_SORT_FIELDS.includes(sortBy as AllowedSortField) ? sortBy : 'triggeredAt'; + const normalizedDirection = sortDirection.toUpperCase(); + const direction = ALLOWED_SORT_DIRECTIONS.includes(normalizedDirection as AllowedSortDirection) + ? (normalizedDirection as AllowedSortDirection) + : 'DESC'; + + const rawLimit = Number(limit) || NOTIFICATION_PER_PAGE; + const limitValue = Math.max(1, Math.min(rawLimit, 100)); + const offsetValue = Math.max(0, Number(offset) || 0); + + const notifications = await Notification.findAll({ + where: { + [Op.and]: [ + { + [Op.or]: [{ userId }, { userId: null }], + }, + ...scopes, + db.sequelize.literal('("userStates"."archivedAt" IS NULL OR "userStates"."id" IS NULL)'), + ], + }, + include: [ + { + model: NotificationUserState, + as: 'userStates', + where: { userId }, + required: false, + }, + ], + subQuery: false, + order: [[sort, direction]], + limit: limitValue, + offset: offsetValue, + }); + + return notifications.map((notification) => { + const notificationWithStates = notification as NotificationWithState & { + userStates?: NotificationUserStateModel[]; + }; + const userState = notificationWithStates.userStates?.[0] ?? null; + + const plain = notification.get({ plain: true }) as NotificationWithState; + plain.userState = userState + ? (userState.get({ plain: true }) as NotificationUserStateModel) + : null; + plain.viewedAt = userState?.viewedAt ?? null; + plain.archivedAt = userState?.archivedAt ?? null; + + return plain; + }); +} + +export { + createGlobalNotification, + createNotification, + deleteNotification, + deleteNotificationsByEntityAndType, + getNotifications, + updateNotificationState, +}; diff --git a/src/services/types/notifications.ts b/src/services/types/notifications.ts new file mode 100644 index 0000000000..cc8dac572d --- /dev/null +++ b/src/services/types/notifications.ts @@ -0,0 +1,50 @@ +import type { Model, WhereOptions } from 'sequelize'; + +type NotificationScope = WhereOptions; + +interface NotificationMetadata { + id: number | undefined; + recipientName: string | undefined; + userName: string | undefined; + date: string | undefined; + displayId: string | undefined; +} + +type NotificationType = + typeof import('../../constants').NOTIFICATION_TYPES[keyof typeof import('../../constants').NOTIFICATION_TYPES]; + +interface NotificationModel extends Model { + id: number; + userId: number | null; + entityId: number | null; + type: NotificationType; + link: string | null; + label: string | null; + displayId: string | null; + text: string | null; + triggeredAt: string | null; + isGlobal?: boolean; +} + +interface NotificationUserStateModel extends Model { + id: number; + notificationId: number; + userId: number; + viewedAt: string | null; + archivedAt: string | null; +} + +interface NotificationWithState extends NotificationModel { + userState?: NotificationUserStateModel | null; + viewedAt?: string | null; + archivedAt?: string | null; +} + +export type { + NotificationMetadata, + NotificationModel, + NotificationScope, + NotificationType, + NotificationUserStateModel, + NotificationWithState, +};