From 58c8efa50da7170ef47971fe182f96e6b39f4ff7 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 1 Jun 2026 12:40:12 -0400 Subject: [PATCH 01/23] feat: add Notifications schema (model, migrations, seed, tests) - Add NOTIFICATION_TYPES and actionable_notifications feature flag to constants - Create Notifications table migration with all spec columns including archivedAt/viewedAt (DATEONLY) - Add actionable_notifications to enum_Users_flags migration - Create Notification Sequelize model with User association - Add User.hasMany(Notification) association - Add seed data with user-specific and global notifications - Add model tests (8 passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/constants.js | 6 + ...260601000000-create-notifications-table.js | 86 +++++++++++ ...d-actionable-notifications-feature-flag.js | 21 +++ src/models/notification.js | 71 +++++++++ src/models/tests/notification.test.js | 139 ++++++++++++++++++ src/models/user.js | 1 + src/seeders/20260601000000-notifications.js | 78 ++++++++++ 7 files changed, 402 insertions(+) create mode 100644 src/migrations/20260601000000-create-notifications-table.js create mode 100644 src/migrations/20260601000001-add-actionable-notifications-feature-flag.js create mode 100644 src/models/notification.js create mode 100644 src/models/tests/notification.test.js create mode 100644 src/seeders/20260601000000-notifications.js diff --git a/src/constants.js b/src/constants.js index e68721b3be..b93e99b51e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -121,6 +121,10 @@ const USER_SETTINGS = { }, }; +const NOTIFICATION_TYPES = { + ACTIVITY_REPORT_CHANGES_REQUESTED: USER_SETTINGS.EMAIL.KEYS.CHANGE_REQUESTED, +}; + const EMAIL_ACTIONS = { COLLABORATOR_ADDED: 'collaboratorAssigned', NEEDS_ACTION: 'changesRequested', @@ -226,6 +230,7 @@ const MAINTENANCE_TYPE = { const FEATURE_FLAGS = [ 'quality_assurance_dashboard', 'monitoring-regional-dashboard', + 'actionable_notifications', ]; const MAINTENANCE_CATEGORY = { @@ -272,6 +277,7 @@ module.exports = { NEXTSTEP_NOTETYPE, RESOURCE_ACTIONS, USER_SETTINGS, + NOTIFICATION_TYPES, EMAIL_ACTIONS, S3_ACTIONS, EMAIL_DIGEST_FREQ, diff --git a/src/migrations/20260601000000-create-notifications-table.js b/src/migrations/20260601000000-create-notifications-table.js new file mode 100644 index 0000000000..d26e1e0cbc --- /dev/null +++ b/src/migrations/20260601000000-create-notifications-table.js @@ -0,0 +1,86 @@ +const { prepMigration, removeTables } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + await queryInterface.createTable( + 'Notifications', + { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'Users', + key: 'id', + }, + }, + entityId: { + type: Sequelize.INTEGER, + allowNull: true, + }, + type: { + type: Sequelize.ENUM(['emailWhenChangeRequested']), + allowNull: false, + }, + link: { + type: Sequelize.TEXT, + allowNull: true, + }, + label: { + type: Sequelize.TEXT, + allowNull: true, + }, + text: { + type: Sequelize.TEXT, + allowNull: true, + }, + isArchived: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + isViewed: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + archivedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + viewedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }, + { transaction } + ); + }); + }, + + async down(queryInterface, _) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + await removeTables(queryInterface, transaction, ['Notifications']); + }); + }, +}; diff --git a/src/migrations/20260601000001-add-actionable-notifications-feature-flag.js b/src/migrations/20260601000001-add-actionable-notifications-feature-flag.js new file mode 100644 index 0000000000..bfe7eeef19 --- /dev/null +++ b/src/migrations/20260601000001-add-actionable-notifications-feature-flag.js @@ -0,0 +1,21 @@ +const { prepMigration, updateUsersFlagsEnum } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + return updateUsersFlagsEnum( + queryInterface, + transaction, + [], + ['quality_assurance_dashboard', 'monitoring-regional-dashboard', 'actionable_notifications'] + ); + }); + }, + + async down() { + // no rollbacks on enum mods, create a new migration to do that + }, +}; diff --git a/src/models/notification.js b/src/models/notification.js new file mode 100644 index 0000000000..931d6d361d --- /dev/null +++ b/src/models/notification.js @@ -0,0 +1,71 @@ +const { Model } = require('sequelize'); +const { NOTIFICATION_TYPES } = require('../constants'); + +export default (sequelize, DataTypes) => { + class Notification extends Model { + static associate(models) { + Notification.belongsTo(models.User, { foreignKey: 'userId', as: 'user' }); + } + } + + Notification.init( + { + userId: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: { + tableName: 'Users', + }, + key: 'id', + }, + }, + entityId: { + type: DataTypes.INTEGER, + allowNull: true, + }, + type: { + type: DataTypes.ENUM(Object.values(NOTIFICATION_TYPES)), + allowNull: false, + }, + link: { + type: DataTypes.TEXT, + allowNull: true, + }, + label: { + type: DataTypes.TEXT, + allowNull: true, + }, + text: { + type: DataTypes.TEXT, + allowNull: true, + }, + isArchived: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + isViewed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + archivedAt: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + viewedAt: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'Notification', + timestamps: true, + paranoid: false, + } + ); + + return Notification; +}; diff --git a/src/models/tests/notification.test.js b/src/models/tests/notification.test.js new file mode 100644 index 0000000000..354715aab4 --- /dev/null +++ b/src/models/tests/notification.test.js @@ -0,0 +1,139 @@ +import faker from '@faker-js/faker'; +import { NOTIFICATION_TYPES } from '../../constants'; +import db, { Notification, User } from '..'; + +describe('Notification model', () => { + let user; + + beforeAll(async () => { + user = await User.create({ + id: faker.datatype.number({ min: 10000, max: 100000 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + email: faker.internet.email(), + role: ['Specialist'], + lastLogin: new Date(), + }); + }); + + afterAll(async () => { + await Notification.destroy({ where: { userId: user.id } }); + await User.destroy({ where: { id: user.id } }); + await db.sequelize.close(); + }); + + it('creates a notification with all required fields', async () => { + const notification = await Notification.create({ + userId: user.id, + entityId: 42, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + link: '/activity-reports/42/review', + label: 'Activity Report #42', + text: 'Changes were requested.', + }); + + expect(notification.id).toBeDefined(); + expect(notification.userId).toEqual(user.id); + expect(notification.entityId).toEqual(42); + expect(notification.type).toEqual(NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED); + expect(notification.link).toEqual('/activity-reports/42/review'); + expect(notification.label).toEqual('Activity Report #42'); + expect(notification.text).toEqual('Changes were requested.'); + + await notification.destroy(); + }); + + it('defaults isArchived and isViewed to false', async () => { + const notification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + }); + + expect(notification.isArchived).toBe(false); + expect(notification.isViewed).toBe(false); + expect(notification.archivedAt).toBeNull(); + expect(notification.viewedAt).toBeNull(); + + await notification.destroy(); + }); + + it('allows nullable userId (global notification)', async () => { + const notification = await Notification.create({ + userId: null, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + text: 'Global system notice.', + }); + + expect(notification.userId).toBeNull(); + + await notification.destroy(); + }); + + it('allows nullable entityId', async () => { + const notification = await Notification.create({ + userId: user.id, + entityId: null, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + }); + + expect(notification.entityId).toBeNull(); + + await notification.destroy(); + }); + + it('rejects an invalid type value', async () => { + await expect( + Notification.create({ + userId: user.id, + type: 'invalidType', + }) + ).rejects.toThrow(); + }); + + it('sets timestamps automatically', async () => { + const notification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + }); + + expect(notification.createdAt).toBeDefined(); + expect(notification.updatedAt).toBeDefined(); + + await notification.destroy(); + }); + + it('user association returns the related user', async () => { + const notification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + }); + + const withUser = await Notification.findOne({ + where: { id: notification.id }, + include: [{ model: User, as: 'user' }], + }); + + expect(withUser.user).toBeDefined(); + expect(withUser.user.id).toEqual(user.id); + + await notification.destroy(); + }); + + it('persists archivedAt and viewedAt when set', async () => { + const notification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + isViewed: true, + viewedAt: '2026-01-15', + isArchived: true, + archivedAt: '2026-01-16', + }); + + const found = await Notification.findOne({ where: { id: notification.id } }); + expect(found.viewedAt).toEqual('2026-01-15'); + expect(found.archivedAt).toEqual('2026-01-16'); + + await notification.destroy(); + }); +}); diff --git a/src/models/user.js b/src/models/user.js index 5b94c91714..53ba6cda9e 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -46,6 +46,7 @@ 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.CommunicationLog, { foreignKey: 'userId', as: 'communicationLogs' }); // User can belong to a national center through a national center user. diff --git a/src/seeders/20260601000000-notifications.js b/src/seeders/20260601000000-notifications.js new file mode 100644 index 0000000000..4de1e945f1 --- /dev/null +++ b/src/seeders/20260601000000-notifications.js @@ -0,0 +1,78 @@ +const { NOTIFICATION_TYPES } = require('../constants'); + +const notifications = [ + // User-specific notification (user 5 — standard seed user) + { + id: 30001, + userId: 5, + entityId: 1, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + link: '/activity-reports/1/review', + label: 'Activity Report #1', + text: 'Changes were requested on Activity Report #1.', + isArchived: false, + isViewed: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + // Viewed notification (user 5) + { + id: 30002, + userId: 5, + entityId: 2, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + link: '/activity-reports/2/review', + label: 'Activity Report #2', + text: 'Changes were requested on Activity Report #2.', + isArchived: false, + isViewed: true, + archivedAt: null, + viewedAt: '2025-01-02', + createdAt: new Date(), + updatedAt: new Date(), + }, + // Archived notification (user 5) + { + id: 30003, + userId: 5, + entityId: 3, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + link: '/activity-reports/3/review', + label: 'Activity Report #3', + text: 'Changes were requested on Activity Report #3.', + isArchived: true, + isViewed: true, + archivedAt: '2025-01-03', + viewedAt: '2025-01-03', + createdAt: new Date(), + updatedAt: new Date(), + }, + // Global notification (no userId) + { + id: 30004, + userId: null, + entityId: null, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + link: '/notifications', + label: 'System Notice', + text: 'A system-wide notice for all users.', + isArchived: false, + isViewed: false, + createdAt: new Date(), + updatedAt: new Date(), + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.bulkInsert('Notifications', notifications); + await queryInterface.sequelize.query( + `ALTER SEQUENCE "Notifications_id_seq" RESTART WITH ${notifications[notifications.length - 1].id + 1};` + ); + }, + + async down(queryInterface) { + await queryInterface.bulkDelete('Notifications', null); + }, +}; From 50ce3f05cf5caf40a66c4072c28d616abddadcae Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 1 Jun 2026 12:44:36 -0400 Subject: [PATCH 02/23] Simplify model --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 30 +++++++++++++++++++ specs/actionable-notifications/index.md | 8 ++--- ...260601000000-create-notifications-table.js | 10 ------- src/models/notification.js | 10 ------- src/models/tests/notification.test.js | 6 +--- src/seeders/20260601000000-notifications.js | 10 ++----- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index 4bc6120ad5..8d22581e6e 100644 --- a/docs/logical_data_model.encoded +++ b/docs/logical_data_model.encoded @@ -1 +1 @@ -xLrjR-IuaVxUlq9mFcmow0cIpUM0CtA7U3oUtS7DYs5xDaY2mA0bkfiPIUoGb6UTlVpt0qcH8YbA8YMrPsR3JtQJ5AluyArOh2h-aJ90M5ELcopx9WCF61NPWU2x4bOq-uJOFWFrheH5bXFyYMRt4B9Dbj6Fg3u00ggiH3LaZmUOOSBssChAIq1fzjCcoxBi1IO59EUun2JxnUz-zvzd__KR8_rcZ_AFDQGq-tQJPVyIh9ppho7TP6qzPpb33uWzNi4S7H2i6VrfaptBK96TPgXcS0T9TfhzOGThI023nVziiXq1DNjj5xYwU7LnTV7k_E8wE_cEvzEJNwDYas6sX-IYPeWzZdpnNfT2iFtmMGOqPmTCnkYTMGNrzpc1dE4pbfrc35MGCb0T9FRyP_PFNCFiDAlnfr_-YfTl3aLu-NzjDYv-dFZPuK3m0oJJ82vlNqCX6Vdg0uNaQR382bZ2gTARhCQCVyFWZsoJKGPOGxFJ3DPDDOJcEIvBS8S9OFZV2z41F5wX4Zu53lUeX2DV2JPYS0Xj_r2xlyEWV8LWMLn1Ui0rdG42ho8O25vODt27GNa3gqC961HCN19S7KRNF_yNnu4i8oqcxTJwwz_6KBo253SaVr80IKjpv6lDrd4WafMBhb1BZeI9_oT-tev0TMjz_jCcgq4bSHVf6q7fdl34jihmslxmVy-VZtNJS4zEzo0cQ8TgK4xqdDLNIrUENGIjTViuBp65rmqzmBeunpMV2dgpCVEk8A2uLXjF1klY9H3x4YUooH0LBOFbuqM16EHDkSEuX81_SWzAAifSCGOEG5V21qItiucUqtbweti09Dy3yijlV__xNnbZHdqtC6dVt_qT2eRE4GCCuJfavkPjsP6UqDLrFK-mReCx43GNPeqc9UFBs8tn7jK5gdUYPbL7ctR31U0kjITtwiHtJNKdcgYALhX631DKxrx9ggv5DxmpE-gPtMzyeU9jVGbBz039Opiikim713yv5L3u00bYZpPBb86w0AHmMOqJgbeChjLm2Xibk25pjaTl1uzEUVr5Dvy5xR4n9vdxd1AKvdgUABK6J5-QSAKvd2yRh07BLoWbz97hYGgcaC1A_eU1Iu8VK06B0MbXgbQFoD3_PAAhR_01vP-ddnadFVAzPEspJ81maIe4Y7xJbmfNU1RLunxz0NdFDKqMFx-TBOtf5fvBfh-717OIsA0mHPi31weL5sHLOhvfciQqqsaDbRk0JyHhA7nzADeDorDWO2ZDNqBZHyDNk60EhvCNvs_-zrndVw2ms6IWA12NBK7v3ZEkiSpbGEAQfDhtWLvi_Gjh-7ewiOnEzsu9AFiWCNpn6APHYj4z4RWRb-wlH87PIm2NTQcKwhlwVnE_E3pBk2hKhUZRZJ7ss-blpsVWwgJRcUsKiiU7djT0wc2dHczNx0PLi6NSff23Cc-u5gUujjILQYHsIne769ChN7OBKD6U8zv2jiT5h-wY8UhaLC0SLQ3yvU-sl_sLRofqcft-SRNY0r8K-Jl5zHqVUlMk0JktCVpZ4kv0BeUZpOjtFXbKQt6yUdz_2GX3EJfmzRvvjF7blwul8Q5cr_lB_O08Qq1PMcUe0zX13ZbisyLJBD9kqmu4xmYe-3ZnPHFVE9CP_sWWPM9SEdiAlx-niARDNOKe7yxEQ10PZ46xQnWWEyWkyO6F83uRFUI5CEJDOC0pvddEWyHBdL9zCqDlJk4wW_QmUGuOt67oE7e-CKkY-N96I_AsQP1vzA_kqQ9ZirWxBA51fjooFS7DRg_xfvlSjueLPkeRZ7mjThGvhj93I3J5RM5sVzYTYA9SG53mAp_z-Qv11QXpU9ktNQA9eHen1ChR0Z98AbJRhAZS6X2-lZp0NexKSjogvSdckYl4jHrst_1DJMMqNvlwCLw0GSe666ipkOBmbkFL2Cbk69csVByTRSPeCtIPVYi8-EJlRvxXTBIauVQiN2Fx7QFKFhVs0DR9vexHJL5nnTLU7hRRdbZvWlHyniUZUfUkMEym8eYTuwA_-wqjBWRD23ym4RUgIp1P-_vlPMo2Rb3DXhq43F2j_CYmvJR9fj8xuvSpXCAkJ9RXODuK1buFVixXvCL9IkJ7HYgqjL9k4xMh0NLi0AuFC4UWH9GT6mIgANt1QuuV8mOFz4aM5Kxf9MG0bP4tJ14NXvhGEQuE79ANqNAS5wMZg0dY3_ynczenDi31OrDm1usSmeicP_g0hS_TC6JmqvoWWoMYcaTotDoLC3iGc0jDIF_alGTKRQ-c3Bm7L6mrPXgYGiXIufsPrTv0seANi9apAfVSYLJd12vp9ix60oTP5ZkgfHqMrt74gpVD6e9LdPRvbnx-UFJwJB1ttDi3aosXS9X4em87UeCdrZ-ZGiN_0oXHyMeKiH_Q8dDFuTu1-8phKmvW1nXyNx-5P0Sj5JWZ3JoIaQ01SbKrDSh5X7o3Tw7tjRdptUYsOnVzWcveNTOYZ7FNWO0eWgFxZ6BgQM_qUqm7O7ACZQq037Hwr_Wk3hFKc5mx5jbKE1Om1wwkfuo-hpySIWoyra_7dVSlbMBfIkXkZctrLDZaHZvr67kYwrL32Tagixowgr6LM_iXh8i1_PWG45JUK4y4zK0a5XLPfkiLZDxcsBX3-VtsFtI6k0hmPI1FHYlxcyAPIhFLVpac1ERC5ll2X4xSi1mR4svKXzbcmhhmfejEStD4TrfpkkjYMx4fKctzPrzPgiV2nGEicHJABb69sJiWBzxEOSqW4QjdmF20JYqZiyqbgATRRL4UgS5cyKbC_6tUtlabgzWRLCwmhwh1lWa_c2jntBZBu-U5u8QVVxYwkdXtStlp-kt5zStPsqlVheW_rJRY_ZDi13GUilrhq0FEUmthNKPt80QBbu6o0HSXhz4_eTt0Z_3HCoAKKH7dOM3Gq5adPwDrpftT4TPoHv8ufep03zYnha6vSyQQL4TvZcCnnkrVctDMYUgFBq3-KSyW2-revviOg3wVWmskpvGHo9MvcuiS9sMnCqwi-qqCXCUq1VE3P9u7RB1YJmf1TcHB7NAcN72yJZSyKQeSp_UWtWNqMioiNzjLPi4Xmx5cgk6dQx-X9IwBWPm39FUOL04JfBN4qJQOVa69PmXTNb-XQSqetp8GEBcEVLM7IS6tTLi4E0WMZOtIjbCDruatrb7ldZVVid_TexeZWm_A0acPT0dLehPB4ZPMNbmxat45mv7HnIx146FdurwdUe69NShdul5LcFbwWPbQfisrUU9ZlKRtolcwPhfBZc50gyiXN3jNKi_ujT3UdFGjfuuxcPdy5XVe7ZsnWOcua6mldIR6WyzWKjWvzPG7OQONy-U8r91U0W9h5kFh6XF6ucd4fGE11egpvQcMrHyGYBmKxJl3lYAukgu0ONGF8RjXuB8K71btcsE-i8JvtKEM-iYAhZcsRdZzyJ5VHDFtCOvnOPVkQFtuycMNjS6ReTrThYl_MICgVP3VeFhZkX5qmfSUlolqCOo117zNy52275QjVyRYgonlfIjTR1rL7UNm7TZzJakdHiCGC4Ic5zz53Idv5QftttrHKr8tqTo6RA3xwCQBSqzpkrshcRZi2W3KTGFALurGuRx5DTlt_PGAv9CNsc7io-fs-k4bOGUvJyrXZt89Nu-iMy3Mvuj13lqQRP7NMLfULDB0RxIGjo9K2piZiN5FDiWix3YUjlv3KoprYdcPCWskHiJeGSDxlpXaJ0FS73wB_fiJ7bqhS7FuEMh0URi65X_FOJzgHOt3z4Xg3K5ZnTkl6PilFqbSDdSboYr_xpqWiFWJ4ckUxUVmkb8U3g5tT_AIyDMuSTThNIZUqDMyfCjEiszUlxnuT_FwrUkBbz-Sdfu8NOV3TNilVnAEah1Dg-znTzDfgHO8p37XVklI5BGjGlCzh41_O1kD3V0G33UAaqXFeXnlSOK_rTmiwOnnJOdfWwhyamy4vv3IuGBXdJItWzZXrMWVEUYlEUyAU2yS-3gbhhY2XpMuA-60Ik6U7QuXOwZUMTyuKvYFeAAPeQZcm-Oje1yDOsNM3TS3fknITXcgTzpwrsHT8_KMe18IszJlZb-QCx_TiR6nkuR8SsqTzDO3kM0WJZ6kdVMpxE_upkDTBKpWkQ9wiIX886rAfdsYQPDpv8m5qqIiPh3c7gGXi362TZj-O6_T47GfSz8wTUh9s_b1nZfz6ROYGlrftb-4RnlN4eRxflGYeIE1YSYtrqrLWK0JYFvvshUL6-V-ar-V6D-ST673T70p5rBDdDIKg5hKLoTH-uzGgfr7bVuGX75u33v0AS6uUazse4R_T0CSI5ExG5raXQKrrRKCxjAhnjhtK4qPQVBnB_pntIRqt2-ZT05ltRXpmMAZ6mPlSAIq5QkXehyxqKCMmkbQlfC5hM7BlplfDicH_VkjFxoUgarzVn9dCkMB-UTSd7m9Ovpx-ayEdjPAb1zB8IZUIDV-fTSpoXyzSEWw8IaNZkJl4MLXncDh3ENu8IZvUnmPYK-u5jzRF5cnBJ__40xraFFPb5lpiH-SmMFpiEBWCpjtHR6vFzQEfJSVRrUGK7ACYJ7pNF-rZEHLHk3QWyuNzlP9XjnrUyFyT4A9BnxkO4sAFMEWjXz_PSBm4uxDdzsoUDJrC2s4Y_hcITUDT0n9nn3FYNN5LSuzCHz_ZPF7YxOvmNgenOVwhqoR9Slnq9oVVQT_yHWzDbIvIVwVOk4sUcF5YUECXQUQDpyVMSTipzgYJW-h9Eb4zqn8bwom8AxQgzoJfxK-Y8sMzRLqhISGawcHCeQhtzt-Z36GxoiRfiYh0Tc1bemOCUBw4PayOz0w1w-xqir-d6R-MXUZT5XLNO0Ol_js7YrQC4k-l8E06umuR1Qf-CApngytaksyc11VMZo7UEifXx6bLj03xJocwLNN5FYZYEqOuzjMjz-CTLrRvEloQUk1qVTdmypdppRneV1bwvLnk-0fNX097bFOUWcD_xoPOwgHU-9hwh5dF67sz9QjyrxM-VbOkCxmylrIN2Xl7FzfAtrb9yeGzyW-fjDOTWvxPukcT7X80TDdSivZLcjuQIzu6ZnsDXqxXeKPacpMmrUpNJRl4Q5C1IuEAVkwlkHE3C98ThhqEyok-M4bZ8-sRK7pA91dNZIOPLuuc6DUE9JXGJ_M8STLOqtg5WHp91yl0jCY4rFDx2Mt_H1HcyFK876R0Stnw4PzpVE_u6VPBwKVa7pXg8RbKg7VhSzwf0FQWOXYM-XzDRTrPEh5PPCFM2spKeRtztAzdrIZGA-JbLnuV8DPp6zVne70U_IZvrt0Ur-ocfv-zwgsBtn-Xb-M1xEh2AlzoEY7iKAUkE8fVuQ0nFgaZGGe_bnBsPZx8nbzMAw1o4dZZYBVd9xoto3dhuKz0FA8wfq9m2vmhQp7WZbUoVV8BWYn3Zz3pWxh7eeh_qjr-01rN7mxftqzxGKk7hKPlug0vatqkZis18tW4Qg9c5XXP-iJ2bm1LC-v02IbzNHM6qbC8g3dHWKPi4DcgfGmDM65AkWTn7uPX8VK2dxyDoRK_fVmh6_eqmIXr17j77tjFNZ1-XGis3TbwTtht87UjLzAJKUGBEt7CjnMhoftNaCXT1hnR4FTEsCS56cFofWmsYjyv77S9nWNtMfPmB6l3flZE6P_aJ14th_rsMfTexYpfRu6_NUVN3S4e3hijcgjfdt-MoiFSvFjRrzC3kHSFJ8Kt50H7dvEJFWrmvNFZa7U8OiOF2jXc6VtCGP2I6oNPra5APNFHEkxR1clN3d6gvjqxHRVDUoM5rN6Am6XJkP6ENN2dZy0We0u1jcX8885sd0_Ulxm5s13FlDF8FuDItaVL6JSbINupxycSH9jB8Bt3v0GLC2rvr3JaPZuH0L5ECu-BjxsQc3_9iua7JAYnKVpN-uju3teGWhS3JzI4D-JF-W8m_Ba-vbMGuO_Gj-fXVaEpQ7TrqPFZi9Z6e0zhk3L4QvLpQFSdaBAkHbOmhYYuWtUION7-XfBlxwt1BlE22CxpSwkhix-_DU_gNr7W7ShIftmE1ky2MSDssCYRb8yaJXK8pt1EFnAqZskuJjlk3RB9CJWXDj_LDeAxRcy007bymBG_Hq6fDXa_sX0YTZZBQ2q8U8jzovJsOgODGh3496WXerwt6-fyclf8u68Ih5hT3TyYb2BVpbxsQeY0a3fz1QOz1HXVpbxXKe6IPY1ILFLNbsCC2uSVPtdBsTlkglwlevyIB7BnT9ReuH8gyfRG7j9Tu-zf0x8YTMVb_rHaE7FFkaaqxYdmz09b5h3JxS4Vxgr9MXS5fxKzjnqIz7EqnDlR6C6psll2QO-J0DsKWcgFbIE2Z51r8Tex9MVX9SVBy_bL8BA3rM2Q1IYplYr7zC-uJM6b_kARa9h1Asq3uPZbHRtEBnS9Mm2QmIiXDCeJcdk1S6Ku77WITBJ4312Lz2gLzennBNoQOQD8fKKKTCWyLZst2SgOJICKJe1Jm0alQ4IH2CGTQQe2QOnc6pVwGIG2r02nmkEl4Tnx6eIG2v0paD_2I09K5KrDSh5sUCDtYKrMm18wJOy5Aa4K0-WfFJtmFBovPi0I8g8XXLEmt9SLQ0kRXdOzKnXU641ecxQO9jby8XRL40H_A7lS2ZplHPwyYcpMwyPrnG-zgl4ILaKXbF2ny77zKCO3NoM8FD1bW1o04eYenSK4OhzN3umXn90Ba1ShvZC02S0IbPjKmYbwzOHSDgm3NjGxn90Fe2K0xcOPvS4ytTt4qx3E8t5ManekMYbjIa0f0IE1vm4YWiAZs-Hj1vZw2JLHm18nXD12HyFFbVmK88SLcrukGJI14W981z02PKUAhWv96t7549fy0n2ar1nK7bTAGlEf7yUX2T1CmXDWiM1vIr09IJ9PiEB37zqYuT1IV1uuD6XPCvvqdLJLI3TU8tXr5uE9d4d2ARl4MNf2ZKSgKVlCxC9icd8qX2Rn2kDgX2KF4-69gI6a4fhLHHMQP6Q12eXD8YGa3aXJ1Dk52LvXTPYbN2Qm2aDfW2GqaON2LMQTrCu9O060L82U9flGJ0bY1o8abzC94G97XSU-AC8w92CG2S0Ii0um1W1GfF1o3HAoP1u2g64Z8kC_pNp10u7n_jwUe96G3FuT7h2ny0dZHi7bnGlPXYPNLfeVB2aGuCV6CyRu0aPXKm2nG75VLKyeI9GcNgdFC7aXodvhSR1bUmRWt2QUDWymUQ7hV_sUKBAZrI2o8N8yyvvGeOZauHh7ogUTyiJC0gOuMVF4qOEHWaR3pR3ZwqdZ1H62HG7bFxpuWaNZel_bV64ZSJ6VJVztngNpJVP6QKuHpAeqcQp4MgWxEIt9zdpvt_znLq9ye_RGmbGlMLxb7-i8QN__UtVdZyNHGmFSywN_hNDxb49D_Bm7rgP6vmdEyrsF6ah_tGv6x4QXbPhFHwIdMQWONjCjba9AFjmA7X_ltZSR4OU9tSe5O3kOU6G-YdBJcf5p7YC8plDSZTjcu51OXlFsyCHzASUnURjxnNPUjBiprRdIbzk13EalzWClKavcw7-wyKzhdIHxL26_lQO_Cc70-Pm9xyy8czg4OZbJ7ntHBw6HzeqRqynvkHXUmPpyaE7itaCgPlf7XScSLLe5OhMyuG-iYflDMhsYZxMq5xfrNZmpv5jHhhSrMZ9DNggdQBKQZIDUJ55k2kw6lNXcUuI7vhNCRIrwMfscb7zjfrEvPtQpXWZpMPZq-qtgsvTUVl_Mrp0Cc33ciF6q6CasMbM36HjDMEpADhXFvLmwAtTalXYXJVVAxDQYdF7aynUXwAn73h4z6Au1Xtru6cFJD5ch495BJys2M8iEcjGQT_T7A3Jvgv2oNY-iaSjzvTRXswxnDWKAK_Tz1wsctbGaT-UPXKuq1livcn5-Yk2gzutYpZeFVx0fxIJjhKEJvp2CfAz48nY6N4ILoRgD-NFJjjdefmCBt7dFRMeRlOyxIU6avs-xQL8QMHDsfQSESs08N6gSy-A1wXS_3TvNegdt97efeqafQON_xLn8pKVPUlT3kx5I-Xx8qsh_TXDxx6mwRsb544wuEaghZNZfRRoptZXHSnuxjYvB6YcqfOBhrOj_jnmBrHhvcbXMRszmxj9m3cXnr8QMdaa2Vzg4qjZm8YjwmNnirL6Bg_SQwF3YVdIDLNd3QWO28sPVM_LyzX9ktGpz8Z9kch3WwYq4wsKPd05wXFqV9IMYgKxeovRW9F2wLnQWsYu96NprY7KTuFhaTkD5b-52_FuFKoyEteleQbeKBlosNLEc7cwoxgLwM5XkTPc9xJWcF0x9L0EwSI5bW5GYdOrB3mLHJCp99DlUvBvlBfxirnMRi_ns4hTWIUPjGTQR9tMgrcwNcgcpgfNh6o0mBgzvyZtg2pczPMbRDDdYNOuRdgNQU4L3yLovIBjG5R_YcuL-dO89ek-dJL0ttt98bR8xNtV2VLEbfBkdUJLsT1ojfcsxILrj1TV2BHNi9vxWEZwzUBMtkhAOHzTYrSFk7NKWJFh7JhMRFsdcvbKd0ubycVndQGaVweuRLixs-vtkwYPDuSp_Vg2qeh8NstRusjqNMMbL6SbBQsRTdNEvtF8Qw7RzbqW-6CCg-YTjdtr4vrPlzrrMxC7DwQTHdYrplCAZeK7idDR6l7rstLl3HfquqHTZzb4d2zFCqRGRiVdxel7RVTZ4n10UTRUvGQkjdQlNk3JRJzJV_aqBEhmnpv6dI9A4X8xhJyyy1A6z4Y-xohq0DIExVNfZ_PPV6yjvaT7w1eOGp9pQy9r12wyrAUehZH4k_LuRCOC3OYLc1YR864pOoL6Xdh2IzeCzhfA5-ow2aFwO37zY4D0vQEwyOOgNViyr5Mz7JmbhSZ9w4hbvsLaeLhApMGNkRpO7wpEYOy5klLojHc9qRg00-zQOMSDpDSrkHZ0mKlNYfQvrcv5ZKgt8tlNgdpujqjZdpIvsZWX35U_ZAgjrB1RgVayXV5G7v5cPqZmpg6gL2TE-KrdIHLlVMNg_kesRQl_wtI07XNvYhfsx2PEAjmNyJEe-UlniLdJKjMYrYcdforLHzJKXXdWB5QdZIksVZkBfVGwHQxHVg7DDPjanfXRgeZ3kh97hpfkBZ6xVqqYtT3Qz5GvYxH0QYMux6c3vChXfc-qjcSDvTEsfuNQ642lPD-YhJz73LjcqTMIk45Z9yNkDfJMxBakQ-Lrk9DvJjskvusgsRnkdLElUpUdwZNNt5drTi2s5Sv5rRjBg5fnVpk8fs14Q3IgFdIfC-fKuaB3_swpFQwWh6JJRRDhEVsGzlznLv_bTBr-84F4FOuVopa88RoCtZ2skyLe3jtmspJwj_jpQFc4kevENwYRGyEikxlBKpJK9PxylP4UVCky_mWkuRRl0nXTTnVrJbTAT59qRJgOHJlTMxz4NkD61aoRlHPH_hcz5FaivLQqyj9MqORHr-eqHmr2H0dDW5LqVKQSkoal1tBZMflqe06Sjn-yfLVdIKM06WT4bTgqKsWzjpt6_vboqekcQitsbeGntQFhqtN95Uv6rgWMmBPMCqX9v5hYndyHlI6Ijc9zSVEzBjppN0TvzSeiKQSlhDyzJPeqx5FaCZg3_kYAM3o18PSYsOJSbsjw62qXP_l8T4vnmnuoyTHSTuGEF_UO5Lzxc49F0XcYnekbxxE-azJwrdTpwdQDshgwhPpukdtc3MtbVUk6sDh7Ug5U0VL15P8aCQ0zYgLYoqYoQbD4YgrlT2Dayj_-xhUSYfXnxMgnpMXpLo7635BjkdP6rUeIJrF7vUDkEWHAhoYgKwChT6NTRIVnpghBizTrrIxUw7pHi3I51oTZeq8WBVJyUsYhwNkVpIAXFr7onW-wVYLog9I9m2lrIVOlWoPcp4DJ2KpgP4JjPWn4BmclsdCSfI6EzAQ6EPYQpv6R1dlFpoFfUCNHdWlGAcHC3w0ZaGZggdL9sb8wZObpi0ov9HKwFXIkgdT1nPQLoAfLM_SKOb-0nGy8d0tUENNN5tz2R8yRvJ9Xf_SIUpnl1wajQOrrMsDUl22uAcn1KyAA0H5SjSTOkkYvV-Q-C1FpnvDHdUrT7robaY2xBO9R1tI74lT-mkFxFNUUvI4Ez17O23FXrk1tzUe7RvdN9MDTEjpi2Mvs-zv1Q6jdzmuLThvVelSmFa1kfEHqh81H9dp-z7DtWP8VSPVKvx-lL3Nt7SJdJrftLL2-kC4rZhfQKttu40lwzR0tBY_fLKeEEx7EdUe3_frBbstxYdDkvM6UwtrQApKWlZBdPjBs79g_OBRtkz4L1UAdQfVU7BCRAxxlssVDhQyUDhzQshdgYiushfdBg65-ek-omxj8NcArrr52QDx9Qt5SBfFlYNbzDxTjiX9rqpQt5jas_vLAT9sjqeYwgfpDve-mBfggtFm_ \ No newline at end of file +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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDY454jXxxPWxrjt1BeGfDpNjdgOnNQ-vM64Iq_0OBxmzlU7ll8U7VZu6m3ESeByK3zCRnxtSIb_PnpJ4UxIFwJIMdJ_ZsQ8LBppYWkbIEkUjhpMSs1Uyd1uxcuuTuiECs3RhNR3P7gttY52cuZg7b7rTNt8V3s53F5tw7MRN_2mNLe-sRK7pAB3El6XmbNdXu1hpmg4L7dJp7VBhDTQaRa4mH_Bn0bGjii3LbRtafZ4Yg7MObYXkCw0v7jsehtd-5_ng_QLqav8FGDMmN7ecSkbPRvX0EqvJR0kzZmgtVbwzi7NoVQzbHjpm_bwkzvDQ9kZrGPCBZsymY_djopWGU8z-z3oRUEzBLjFJxttMzeNF3VFBSl1szOIr7ndzRbZ82s0yvSyGa5YNlP7upPzhAHlp3YMZx-ur43bft8_cczDJ_Xla_36r1q0UKRqp0TW5hfNrMApBA_bSsmKXrk5do1TaNPr1vN_83n-WVZR0pcaVbtk1YqVjvQ02n7aJlTwW3K4ZU8GgQYPMsDdgbW9N0DLpxaT9QRqirWPQamZekL511ko8eYebJ8jQeGfw1x5V1k4HnuBVHevvYU1BUFKuzEM6KEw8TdxGzXwyO7qA5koRilJkTUv0xzelfQOZI9RsMo9kgrULEw-X43gDSB-Xtgyn_fkqnsLC6EqLFl8uxZEC2_wrB1xRLuVDSVpMlqWO8gyd-zLgFSUuBwg-9Vvtdrmt1A0wx39lhQPz_aizJ-Kcszy-A1rOS_K6Kt13HVJyEI7prupcFZj4UuSiOl2iX66ExySO226XRvvb5QHKlgAjh_fbE7SLDq0P6kYe0Lt3RdbHPTiJ40bcRX1O9ohy0o08Y6EG7IaZMA0D3pF0tqK3R2Yam1E8VyDItWSLE7Xb2VxphqcSHHkguVq390GLSEqvr7Gi9ZvHCIEEiu-BfyyQsB-9Crc7Z6ZnqVo7ZGju3xg82tV3RpZ4z-GFEW2pFFb-fjMGuO_GTsfXlaDpQFUrmQMZy9W6O8yhU7M4QrNpABUdaFBQ32nXN57n1k-amcDzZQLVtrk2NMUZafrcPrTNPt_-wz_KlYF0knMbJdWO4jw4iuRjCH4tAPv09QgHdbWbVYNf7jVmdJUQdMKnOzI4cyV8I2pS_EwsDeqxDbcbpVUUowiHK3ul0DtVOK2AJVQF1fI8FTu2IXl2ZTHkkqnbP6Fo4XOr8a4DAFKgubArsDBN5H6L0jdeFlaKCIh_SlQoLL84WD9fD33fgSZ-SlOAbGoICDEJfggzMXfXbJdwhD5Vpjvqb_XzxVcGofSBhBT62P5MbRU0zfBk7dj97P0J-m8l-wDWmz_Bqacc1L67eHDXjeQVRWd-T6lBrhWiFAdjYUgMefsd9zxOPWxj9Jv9C8t2D611bQ3gG-Ke5nD4VOZ6daTAUFdnybnR9QZyK2M8HY7gp6BxZm4alCRmSfyVJcYDe9NsmN2iqk8UXvUhdarWZO2LS1h5ASsz89WIF0uyINeQ0aRe2bhrIZk6kBu-pR1Hf98YQXea_WW0zvPA60rZb4x04m19Fwa4qGX4dP5gWcaC9jlfF07a0fJ0yO8Zxr4SYvi4a0kGyq9Y0aW2L5LDtTe5lC6xf6OB02dTXeSYbG3g0PIK_XvONhvy4m194T6mmcbOZgiArCKj0xkUAGmlB43KJHjiimmhSOiA6MAcxueeyxssU_8Dk5klWTSKFlPppabP58PJmiV1n_LG68qObo3pGPO0SW1A8gCN5173_bo-p8SIG2v0NA-Op00d04fMRLC8fUlM4N3QW0vxKEyIG3w0b6kvc6UN1FC7YnDEmpYDHM5CQBbefRKf0AG4ZWUS18eB2ezyaRGUO-WarKS0ICRRIWaV3pvNy52275PjuBa4qWH82I0VG0cL7YguEIHjnnH2QV0CGfDGSL1vNIaBpgH_7eGdGJC8JOB5WUKjG2KaoMR3Ymn_hOo7GKdmUE3HeMJEUT9rKrKWtNYDuTHU3YPn9mYcxn5bwGer7Ab7x_ky2R9foD8GcyGhZQeGb3nlhIQaXf1AQrKKLccHcWGg8JI8a90v8KmJRXGbUONMOfLmci0f3QO0aD965mbLcdTJE2M01W5I0dYQRq4m9OWSY99VJ2H42HuN7lYZ2EYGZ40d04h0kC2OaVeJE3OuHlJKP4qpZLAoifv2A27Z8kD_13w1my7nFZgVe17G3Buud_0ny4dZni4b1TCPHcQN5XeVBEb4vGV6ytHwGaOXaq1nG74VOq-e25IcddTFCFbXIawzSN0buyyWd2RUWq-m-M7hFncVaF8ZbI1o8V8y3v-GuSWauTg7YkUvzmIC0cRujVY4aODHmiQ3pV0RzGaZXL42nG6b__PwmeNZud-2FM4ZiN4VbV-tncNpJNP6ASwHDCFOR3P23PIT_FOaU_xyhx-uAq7-SNie0QgtR8_oZpMazB-__TlpvwA8uNxFwwL_RRDxLCADFFo74dCZ5sMdsOudRUMVBcSZLiDGijRBAjbf1XgsnsIRbO2o3uU2z_UBnyrcvELsXrC1w1uM3gkVikoaLioCbqgoqontqxOP66IsyJpC7C5_wiLmtlvUaQqdpVvizLhfoutW2kalDaCFTiwkKFzfvrwjT97jK8R-zfZrawygpAcT_FA8TLEBG6pDn7T7hDVmI9kksrECENdOdb4v-Q5JitaLKZVJlLgOp5MXLJXQRpVwsAgyrRZPA_fOGtkbLwk3VuviCzBbhiPBhj5JxHIbLkDfp8ifnbtHrRWFptIN-T2yZk6jxNgpqupgX_zqAk_KTiOOQNOrDjr-i-hMbRV_lyK5B0Csh3dL67gf9JlDYe6ihQxOB8vsR5Gb77gfMn9VhF3cUu9NIt7kU9BPwt0qbaE7cLuCr-2ZNeFjiIdQZ5MugEN7Pa6CHMSDBksZf0DysjnLE1blU_QuvVvow_1jbnYR8-MfczwhVpCFAlBRqwm21tg3tPojI7z5SDNxPb4DUYzZy2djf6sjmnFdi8nbBpwbM4QSn9MP-atvizFs3IqdmylSUMmkgfizxnwAuMIdRtifKXfPaxSbPqvNm90urRbdnGDKBlwRF2_5S-vOTDD6ibBJY__Qk66iZxBrRWVtuYNylM6urNxiflUgiEazD1N1Ek1TBwuruwMsyizuuKMLyTonSrdGJALjBRnQjVXpmxrGhPcdXMLqUuTtau5pGe_bDBJoI1R-rIQMnu0HMzSBucUhZ5nEt6kZmvdvqZLNvu6i60YDcNrlqNdif5sw6NhXPLsruK4GxKGhfHbSWVi4Fn-dfMBfpg1b2t1IE9tBQn0DbwLitZgC-cvmFNQxqUAhrC8yVWypxuxUItWg7jGcFflcX9cdkwnRcPugBDVQx8GM71D-fmJA8Psuq380Ij6kXeM7K7HbXcJoRKyodqUxDyibSNkEnn9r1vwih3tGOcbQJuffkjHCdKLNh6o0mBgzvyZtg2pczPMbRDDdYNOuRdgNQU64XmBTSf5s8Aj_HRSAVpi-bSNVpXhWxpxp4IlazhxlX7edIqdtJlBgrT1ojfcsxILrl1TV2BHNi9vxW6ZwUd5hRtNbi8-cujK3RXrru4owGyUQhVzqCvEBqw54_Yo-avpaJnN4Rcjds_rk5pNpvjHCFqsXj2BoLzlsU8gkgwoKwgoafRHfHtD-FOUy5hfjtmM2lrInYjvfsrS_qPdqxQxRcZsuCUqqmiawRqyu5Xx8pcrvnDEtQMyD7dJZXAIFsKISBqypHj1kn-VkYyTjzsCJ45rwrjxbHlMsTgzULDXjFrD_-JHLrB6XVHLrYYX9IEoq_jZ9IogaH_9zHz03K3ksr-S_scVnlRMuFZfysS0OafbR4wuZrCBBVOhhIaEqMu_7PipGW5Yf4cCJ96nch5IHePxmiZOZuQxI1RkEO4WVBEOVSGYeV5mttZYkQv-7EjBRmLFIYZKPNQdyTHbPg9RMPhABtBhO7wnU4ryLwDPRgyr80Di5DV2k5NRMm7nTinO3E7ZvMg54rzgjg45qEx9thLeF_tPfx9DchJPEVCVLBwDgApNiLcf-dSBgK1-HPsT8yCxXgbGdJlbDPqaLRtrbwlxgDcsh_-jqW1uL-OgwTkmcJYhS5zLdKFFNgyTcJKjLYrkdd9wsL1rHKnjcWBEgEcrSiVNRMIpbroboZVOBRQxP93F6t5957DR47htgkBdCxFurcNH3QzDJvIpI0gcLuBAd3P8hXvk-qTgUDPHFsvuMQcC0lP9zYxR-73LicSLNI-86ZvqKkzjGMhFdkgJAQRYJUGvjr_D6KJQlM-VGTU_c55rppPr5jmEs9JWNLUykeQZY_dOGpy68q6YGFdIfCsWgSQ7XVxVP7bTG4jbqs-nQJZ-a_N_SrMSvNwyVo11pZ-F7yXg4C5x6RnZRtM8qXsxuRHxzs_qvl7n2NSUdBzJDuM7Ixkxo50zrYIT_RwG7tpBlFu9BkEtx04RJxgBUQQ949qLJqy4iskdU-YLoYXiPC6tsMaJvv_PIvBEKMz7AIrj56qPVg_CSDGWH9NG5hABhLyIvc_BMG6vSQxCUD83pzeEtzEfrKX6WnW5HfJRj55hFRO_nVwQSzAAvMhEzPM5CzsXwcfPSuMuaHRK0jbJD82MHQuaR_qNqXaZQYlN5pVUwSCzp7UJabLcYJbz8tprDcZpiK-GoFeF-w8fPF8CXboBPXDoNQteOBM5d-yXqJd737ZBnt5ntX0u_zvWLJprCeIU1357ZnVBtMTz9RdthkpcqNQEsZhQL4q_RPzxG4kzRDyHI7-k5UWNKhNT8EifcQZJDGRMK93wWaOhbOijTahGfTiNI6zrScVotlwCkvo9cSRi2RJFQt5M8CODLkrQUKRLYnVFKiVwuwm-1qjEAwjHeWgxylEsaw5cqFFjdHtRnvkcBiAKvF3WP6nLMQQ4tuKDBfk_HDek5_aJ9C3ze-agCKIaJmLpga-nV1a_Cc8Ud4iBKQOlQpHY8Y19EsdF4fI7E-QRMEfYRppcS1dlapsFfUDhHdblHAcnChw8ZiGZggdLPsf8_ZOcTi0ov9GGTlmnNVRo0i6LviYffw-v3x0km-061iy5xhEvAv4-8xN6Zt33iz5vYhyxRGMvBMcFT09cNEGYk2blGbB3YIWIJzXXW5psNqjmNkcB-U3B8sHxxmIILLDfj4TpYe3lek7SU7d_okVCi3t7AZy16c1wtHiIhan-ykLwNZNMHSRCdkDdjUuUhhPtTEwZO-Nw9tbFn0NOZ9QVZ3unMmkUZFtSNAFcnUGaz_nseejntEfy_QTrLGVdt1bCSTT-c-l0X5lJhOGzVNjAhw1nsZ9qxrGTyHfSks_QrwLpYOTxmVLuhDI2Uc7FRQNiZpLymsplZQ4A1-2dgqaDaTcF5ztsRhxdqwc6JhgMTgwxzR8ETkHKrJ5hq_NXu5ycBiVQAImZDEtdLZijbyZqnhmJdjaqIKRTtSwd8jlclQQ9fjaeZwgfoDfi_mhfegdBp_m00 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 128ee6b5b3..3789fce43c 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1053,6 +1053,20 @@ class NextSteps{ completeDate : date } +class Notifications{ + * id : integer : + userId : integer : REFERENCES "Users".id + * createdAt : timestamp with time zone + * type : enum + * updatedAt : timestamp with time zone + archivedAt : date + entityId : integer + label : text + link : text + text : text + viewedAt : date +} + class ObjectiveCollaborators{ * id : integer : * collaboratorTypeId : integer : REFERENCES "CollaboratorTypes".id @@ -1346,6 +1360,7 @@ class Users{ } enum enum_Users_flags { + actionable_notifications monitoring-regional-dashboard quality_assurance_dashboard } @@ -2520,6 +2535,20 @@ class ZALNextSteps{ session_sig : text } +class ZALNotifications{ + * 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 ZALObjectiveCollaborators{ * id : bigint : * data_id : bigint @@ -3083,6 +3112,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" Notifications : user, notifications Users "1" --[#black,dashed,thickness=2]--{ "n" ObjectiveCollaborators : user, objectiveCollaborators Users "1" --[#black,dashed,thickness=2]--{ "n" Permissions : user, permissions Users "1" --[#black,dashed,thickness=2]--{ "n" SessionReportPilotTrainers : trainer, sessionTrainers diff --git a/specs/actionable-notifications/index.md b/specs/actionable-notifications/index.md index 38cacdd21a..f159f674de 100644 --- a/specs/actionable-notifications/index.md +++ b/specs/actionable-notifications/index.md @@ -36,8 +36,8 @@ Points: 5 - link: computed link - label: label for link - text: computed message (see notification configuration, next section) -- isArchived: Boolean, default false -- isViewed: Boolean, default false +- archivedAt: Date, nullable +- viewedAt: Date, nullable ```timestamps: true``` ```paranoid: false``` @@ -56,7 +56,7 @@ As example. These will be created as we build out notifications const NOTIFICATION_TYPES = { // for example - ACTIVITY_REPORT_CHANGES_REQUESTED: 'activity_report_changes_requested', + ACTIVITY_REPORT_CHANGES_REQUESTED: 'emailWhenChangeRequested', }; const NOTIFICATION_CONFIGURATION = { @@ -89,7 +89,7 @@ Ideally, this function should be **plug and play**. See Registering a new notifi ```createGlobalNotification``` ```updateNotification(notificationId, updatedNotification)``` -Updates notififications, atomically (only _isArchived_ and _isViewed_ will be updated, should be enforced via code, in both the service, the joi validation, and the model configuration if possible) +Updates notififications, atomically (only _archivedAt_ and _viewedAt_ will be updated, should be enforced via code, in both the service, the joi validation, and the model configuration if possible) ```js // just an example diff --git a/src/migrations/20260601000000-create-notifications-table.js b/src/migrations/20260601000000-create-notifications-table.js index d26e1e0cbc..c06ebad1c5 100644 --- a/src/migrations/20260601000000-create-notifications-table.js +++ b/src/migrations/20260601000000-create-notifications-table.js @@ -43,16 +43,6 @@ module.exports = { type: Sequelize.TEXT, allowNull: true, }, - isArchived: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - isViewed: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, archivedAt: { type: Sequelize.DATEONLY, allowNull: true, diff --git a/src/models/notification.js b/src/models/notification.js index 931d6d361d..1c5354acdd 100644 --- a/src/models/notification.js +++ b/src/models/notification.js @@ -40,16 +40,6 @@ export default (sequelize, DataTypes) => { type: DataTypes.TEXT, allowNull: true, }, - isArchived: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - isViewed: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - }, archivedAt: { type: DataTypes.DATEONLY, allowNull: true, diff --git a/src/models/tests/notification.test.js b/src/models/tests/notification.test.js index 354715aab4..7baac641dc 100644 --- a/src/models/tests/notification.test.js +++ b/src/models/tests/notification.test.js @@ -44,14 +44,12 @@ describe('Notification model', () => { await notification.destroy(); }); - it('defaults isArchived and isViewed to false', async () => { + it('defaults archivedAt and viewedAt to null', async () => { const notification = await Notification.create({ userId: user.id, type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, }); - expect(notification.isArchived).toBe(false); - expect(notification.isViewed).toBe(false); expect(notification.archivedAt).toBeNull(); expect(notification.viewedAt).toBeNull(); @@ -124,9 +122,7 @@ describe('Notification model', () => { const notification = await Notification.create({ userId: user.id, type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, - isViewed: true, viewedAt: '2026-01-15', - isArchived: true, archivedAt: '2026-01-16', }); diff --git a/src/seeders/20260601000000-notifications.js b/src/seeders/20260601000000-notifications.js index 4de1e945f1..765b41c0bc 100644 --- a/src/seeders/20260601000000-notifications.js +++ b/src/seeders/20260601000000-notifications.js @@ -10,8 +10,6 @@ const notifications = [ link: '/activity-reports/1/review', label: 'Activity Report #1', text: 'Changes were requested on Activity Report #1.', - isArchived: false, - isViewed: false, createdAt: new Date(), updatedAt: new Date(), }, @@ -24,8 +22,6 @@ const notifications = [ link: '/activity-reports/2/review', label: 'Activity Report #2', text: 'Changes were requested on Activity Report #2.', - isArchived: false, - isViewed: true, archivedAt: null, viewedAt: '2025-01-02', createdAt: new Date(), @@ -40,8 +36,6 @@ const notifications = [ link: '/activity-reports/3/review', label: 'Activity Report #3', text: 'Changes were requested on Activity Report #3.', - isArchived: true, - isViewed: true, archivedAt: '2025-01-03', viewedAt: '2025-01-03', createdAt: new Date(), @@ -56,8 +50,8 @@ const notifications = [ link: '/notifications', label: 'System Notice', text: 'A system-wide notice for all users.', - isArchived: false, - isViewed: false, + archivedAt: null, + viewedAt: null, createdAt: new Date(), updatedAt: new Date(), }, From bd6ed6a4b6ed6f3fdfcbf09668eecc0fcdba716f Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 1 Jun 2026 13:38:49 -0400 Subject: [PATCH 03/23] Update to add "triggeredAt" to model --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 1 + .../20260601000000-create-notifications-table.js | 4 ++++ src/models/notification.js | 16 ++++++++++++++++ src/seeders/20260601000000-notifications.js | 2 ++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index 8d22581e6e..60998d725d 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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDY454jXxxPWxrjt1BeGfDpNjdgOnNQ-vM64Iq_0OBxmzlU7ll8U7VZu6m3ESeByK3zCRnxtSIb_PnpJ4UxIFwJIMdJ_ZsQ8LBppYWkbIEkUjhpMSs1Uyd1uxcuuTuiECs3RhNR3P7gttY52cuZg7b7rTNt8V3s53F5tw7MRN_2mNLe-sRK7pAB3El6XmbNdXu1hpmg4L7dJp7VBhDTQaRa4mH_Bn0bGjii3LbRtafZ4Yg7MObYXkCw0v7jsehtd-5_ng_QLqav8FGDMmN7ecSkbPRvX0EqvJR0kzZmgtVbwzi7NoVQzbHjpm_bwkzvDQ9kZrGPCBZsymY_djopWGU8z-z3oRUEzBLjFJxttMzeNF3VFBSl1szOIr7ndzRbZ82s0yvSyGa5YNlP7upPzhAHlp3YMZx-ur43bft8_cczDJ_Xla_36r1q0UKRqp0TW5hfNrMApBA_bSsmKXrk5do1TaNPr1vN_83n-WVZR0pcaVbtk1YqVjvQ02n7aJlTwW3K4ZU8GgQYPMsDdgbW9N0DLpxaT9QRqirWPQamZekL511ko8eYebJ8jQeGfw1x5V1k4HnuBVHevvYU1BUFKuzEM6KEw8TdxGzXwyO7qA5koRilJkTUv0xzelfQOZI9RsMo9kgrULEw-X43gDSB-Xtgyn_fkqnsLC6EqLFl8uxZEC2_wrB1xRLuVDSVpMlqWO8gyd-zLgFSUuBwg-9Vvtdrmt1A0wx39lhQPz_aizJ-Kcszy-A1rOS_K6Kt13HVJyEI7prupcFZj4UuSiOl2iX66ExySO226XRvvb5QHKlgAjh_fbE7SLDq0P6kYe0Lt3RdbHPTiJ40bcRX1O9ohy0o08Y6EG7IaZMA0D3pF0tqK3R2Yam1E8VyDItWSLE7Xb2VxphqcSHHkguVq390GLSEqvr7Gi9ZvHCIEEiu-BfyyQsB-9Crc7Z6ZnqVo7ZGju3xg82tV3RpZ4z-GFEW2pFFb-fjMGuO_GTsfXlaDpQFUrmQMZy9W6O8yhU7M4QrNpABUdaFBQ32nXN57n1k-amcDzZQLVtrk2NMUZafrcPrTNPt_-wz_KlYF0knMbJdWO4jw4iuRjCH4tAPv09QgHdbWbVYNf7jVmdJUQdMKnOzI4cyV8I2pS_EwsDeqxDbcbpVUUowiHK3ul0DtVOK2AJVQF1fI8FTu2IXl2ZTHkkqnbP6Fo4XOr8a4DAFKgubArsDBN5H6L0jdeFlaKCIh_SlQoLL84WD9fD33fgSZ-SlOAbGoICDEJfggzMXfXbJdwhD5Vpjvqb_XzxVcGofSBhBT62P5MbRU0zfBk7dj97P0J-m8l-wDWmz_Bqacc1L67eHDXjeQVRWd-T6lBrhWiFAdjYUgMefsd9zxOPWxj9Jv9C8t2D611bQ3gG-Ke5nD4VOZ6daTAUFdnybnR9QZyK2M8HY7gp6BxZm4alCRmSfyVJcYDe9NsmN2iqk8UXvUhdarWZO2LS1h5ASsz89WIF0uyINeQ0aRe2bhrIZk6kBu-pR1Hf98YQXea_WW0zvPA60rZb4x04m19Fwa4qGX4dP5gWcaC9jlfF07a0fJ0yO8Zxr4SYvi4a0kGyq9Y0aW2L5LDtTe5lC6xf6OB02dTXeSYbG3g0PIK_XvONhvy4m194T6mmcbOZgiArCKj0xkUAGmlB43KJHjiimmhSOiA6MAcxueeyxssU_8Dk5klWTSKFlPppabP58PJmiV1n_LG68qObo3pGPO0SW1A8gCN5173_bo-p8SIG2v0NA-Op00d04fMRLC8fUlM4N3QW0vxKEyIG3w0b6kvc6UN1FC7YnDEmpYDHM5CQBbefRKf0AG4ZWUS18eB2ezyaRGUO-WarKS0ICRRIWaV3pvNy52275PjuBa4qWH82I0VG0cL7YguEIHjnnH2QV0CGfDGSL1vNIaBpgH_7eGdGJC8JOB5WUKjG2KaoMR3Ymn_hOo7GKdmUE3HeMJEUT9rKrKWtNYDuTHU3YPn9mYcxn5bwGer7Ab7x_ky2R9foD8GcyGhZQeGb3nlhIQaXf1AQrKKLccHcWGg8JI8a90v8KmJRXGbUONMOfLmci0f3QO0aD965mbLcdTJE2M01W5I0dYQRq4m9OWSY99VJ2H42HuN7lYZ2EYGZ40d04h0kC2OaVeJE3OuHlJKP4qpZLAoifv2A27Z8kD_13w1my7nFZgVe17G3Buud_0ny4dZni4b1TCPHcQN5XeVBEb4vGV6ytHwGaOXaq1nG74VOq-e25IcddTFCFbXIawzSN0buyyWd2RUWq-m-M7hFncVaF8ZbI1o8V8y3v-GuSWauTg7YkUvzmIC0cRujVY4aODHmiQ3pV0RzGaZXL42nG6b__PwmeNZud-2FM4ZiN4VbV-tncNpJNP6ASwHDCFOR3P23PIT_FOaU_xyhx-uAq7-SNie0QgtR8_oZpMazB-__TlpvwA8uNxFwwL_RRDxLCADFFo74dCZ5sMdsOudRUMVBcSZLiDGijRBAjbf1XgsnsIRbO2o3uU2z_UBnyrcvELsXrC1w1uM3gkVikoaLioCbqgoqontqxOP66IsyJpC7C5_wiLmtlvUaQqdpVvizLhfoutW2kalDaCFTiwkKFzfvrwjT97jK8R-zfZrawygpAcT_FA8TLEBG6pDn7T7hDVmI9kksrECENdOdb4v-Q5JitaLKZVJlLgOp5MXLJXQRpVwsAgyrRZPA_fOGtkbLwk3VuviCzBbhiPBhj5JxHIbLkDfp8ifnbtHrRWFptIN-T2yZk6jxNgpqupgX_zqAk_KTiOOQNOrDjr-i-hMbRV_lyK5B0Csh3dL67gf9JlDYe6ihQxOB8vsR5Gb77gfMn9VhF3cUu9NIt7kU9BPwt0qbaE7cLuCr-2ZNeFjiIdQZ5MugEN7Pa6CHMSDBksZf0DysjnLE1blU_QuvVvow_1jbnYR8-MfczwhVpCFAlBRqwm21tg3tPojI7z5SDNxPb4DUYzZy2djf6sjmnFdi8nbBpwbM4QSn9MP-atvizFs3IqdmylSUMmkgfizxnwAuMIdRtifKXfPaxSbPqvNm90urRbdnGDKBlwRF2_5S-vOTDD6ibBJY__Qk66iZxBrRWVtuYNylM6urNxiflUgiEazD1N1Ek1TBwuruwMsyizuuKMLyTonSrdGJALjBRnQjVXpmxrGhPcdXMLqUuTtau5pGe_bDBJoI1R-rIQMnu0HMzSBucUhZ5nEt6kZmvdvqZLNvu6i60YDcNrlqNdif5sw6NhXPLsruK4GxKGhfHbSWVi4Fn-dfMBfpg1b2t1IE9tBQn0DbwLitZgC-cvmFNQxqUAhrC8yVWypxuxUItWg7jGcFflcX9cdkwnRcPugBDVQx8GM71D-fmJA8Psuq380Ij6kXeM7K7HbXcJoRKyodqUxDyibSNkEnn9r1vwih3tGOcbQJuffkjHCdKLNh6o0mBgzvyZtg2pczPMbRDDdYNOuRdgNQU64XmBTSf5s8Aj_HRSAVpi-bSNVpXhWxpxp4IlazhxlX7edIqdtJlBgrT1ojfcsxILrl1TV2BHNi9vxW6ZwUd5hRtNbi8-cujK3RXrru4owGyUQhVzqCvEBqw54_Yo-avpaJnN4Rcjds_rk5pNpvjHCFqsXj2BoLzlsU8gkgwoKwgoafRHfHtD-FOUy5hfjtmM2lrInYjvfsrS_qPdqxQxRcZsuCUqqmiawRqyu5Xx8pcrvnDEtQMyD7dJZXAIFsKISBqypHj1kn-VkYyTjzsCJ45rwrjxbHlMsTgzULDXjFrD_-JHLrB6XVHLrYYX9IEoq_jZ9IogaH_9zHz03K3ksr-S_scVnlRMuFZfysS0OafbR4wuZrCBBVOhhIaEqMu_7PipGW5Yf4cCJ96nch5IHePxmiZOZuQxI1RkEO4WVBEOVSGYeV5mttZYkQv-7EjBRmLFIYZKPNQdyTHbPg9RMPhABtBhO7wnU4ryLwDPRgyr80Di5DV2k5NRMm7nTinO3E7ZvMg54rzgjg45qEx9thLeF_tPfx9DchJPEVCVLBwDgApNiLcf-dSBgK1-HPsT8yCxXgbGdJlbDPqaLRtrbwlxgDcsh_-jqW1uL-OgwTkmcJYhS5zLdKFFNgyTcJKjLYrkdd9wsL1rHKnjcWBEgEcrSiVNRMIpbroboZVOBRQxP93F6t5957DR47htgkBdCxFurcNH3QzDJvIpI0gcLuBAd3P8hXvk-qTgUDPHFsvuMQcC0lP9zYxR-73LicSLNI-86ZvqKkzjGMhFdkgJAQRYJUGvjr_D6KJQlM-VGTU_c55rppPr5jmEs9JWNLUykeQZY_dOGpy68q6YGFdIfCsWgSQ7XVxVP7bTG4jbqs-nQJZ-a_N_SrMSvNwyVo11pZ-F7yXg4C5x6RnZRtM8qXsxuRHxzs_qvl7n2NSUdBzJDuM7Ixkxo50zrYIT_RwG7tpBlFu9BkEtx04RJxgBUQQ949qLJqy4iskdU-YLoYXiPC6tsMaJvv_PIvBEKMz7AIrj56qPVg_CSDGWH9NG5hABhLyIvc_BMG6vSQxCUD83pzeEtzEfrKX6WnW5HfJRj55hFRO_nVwQSzAAvMhEzPM5CzsXwcfPSuMuaHRK0jbJD82MHQuaR_qNqXaZQYlN5pVUwSCzp7UJabLcYJbz8tprDcZpiK-GoFeF-w8fPF8CXboBPXDoNQteOBM5d-yXqJd737ZBnt5ntX0u_zvWLJprCeIU1357ZnVBtMTz9RdthkpcqNQEsZhQL4q_RPzxG4kzRDyHI7-k5UWNKhNT8EifcQZJDGRMK93wWaOhbOijTahGfTiNI6zrScVotlwCkvo9cSRi2RJFQt5M8CODLkrQUKRLYnVFKiVwuwm-1qjEAwjHeWgxylEsaw5cqFFjdHtRnvkcBiAKvF3WP6nLMQQ4tuKDBfk_HDek5_aJ9C3ze-agCKIaJmLpga-nV1a_Cc8Ud4iBKQOlQpHY8Y19EsdF4fI7E-QRMEfYRppcS1dlapsFfUDhHdblHAcnChw8ZiGZggdLPsf8_ZOcTi0ov9GGTlmnNVRo0i6LviYffw-v3x0km-061iy5xhEvAv4-8xN6Zt33iz5vYhyxRGMvBMcFT09cNEGYk2blGbB3YIWIJzXXW5psNqjmNkcB-U3B8sHxxmIILLDfj4TpYe3lek7SU7d_okVCi3t7AZy16c1wtHiIhan-ykLwNZNMHSRCdkDdjUuUhhPtTEwZO-Nw9tbFn0NOZ9QVZ3unMmkUZFtSNAFcnUGaz_nseejntEfy_QTrLGVdt1bCSTT-c-l0X5lJhOGzVNjAhw1nsZ9qxrGTyHfSks_QrwLpYOTxmVLuhDI2Uc7FRQNiZpLymsplZQ4A1-2dgqaDaTcF5ztsRhxdqwc6JhgMTgwxzR8ETkHKrJ5hq_NXu5ycBiVQAImZDEtdLZijbyZqnhmJdjaqIKRTtSwd8jlclQQ9fjaeZwgfoDfi_mhfegdBp_m00 \ No newline at end of file +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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDY454jXxxPWxrjt1BeGfDpNjdgOnNQ-r8Ov1ZuiQPeA6p7ZrxUcDWUSzHlJpCGgUuHZzfNYTtZWkvrRwodwd8HwbVKodiUny7CyMhOBXKHXCDzCvxt-juTY-u3NrsDbqxB0VPXItMM-5pVPolKU4CXNhEQJiw_gI-tq8k-Bfqkymk-Rdkx1wj6qBcaU1TUP5WwlC2mVNc1SEhV2Ww--Ghw-n9dKBWZcIZnUqQf5Lhgxi9JUN4O6kmpH7SPi1pV7eHdtDyx_Wb-mlfUkKV1IgXkNISv9BpthN1Dgncs9Rw7rekFNzweMoasz-BJFiXlVtShsVr3n1hr-QN7XyWrdCRr_6WS1xzAFdASLxNxAQddwFjBOlVAwPNvRJjwixhFt8wBV4Gbu5uob_Xe34kl6Dn7J-N4lPcCSe6NsBhu78IUE-DT-SdlBV8ESFh3q0yeZgdGd0Bd2jhCVYNrx99zmk2B4EFqF-8Ulg4Ih_utxu0FLJ0dPE--3S2rmyQiy75JJEc-Zr46q86i4ZL7apiSBELi4Kk0AeddDdIahhjR4s-9f4GC-D2Z9WHnbLAM6QrGfLq3k8_J88Zq8M_5vopEi4Ny9BoA6lEOHoHxJpZRRtu0NhKx1WtvIbTwzp1thNVYar7K6ojDyNSLkzgjnv3ONGQOG03lM8ZV7ff3qkOy9eh_2Hnt6VOLn0i6KnshuwR8xZo_v5mX1vPTclLkjDnlrJzStnl_lakY40rM4NV6irxVDVz7agDzd-zf7fm9ghTvc26oeYwiy9exzcXFVPOTixP163PoKCSuO_na082OVpBAiWfV9bRNtzBqEygrW2olX0HnBe6dRBYofxdu3eC7C3mpgHuXy0GK0SW-pe6S42RNZe0VvM6c13OGQUGFmRbl8-g632AqlmdtzDuYpQy0tl7o0Xg81hpw6cOp7nYOeZSPnzN3o7ri3-JPp9EcH4Yu_cFtPSm7lGHM6u6txN8RudVz11cUN9zpEjXWn_XBvJ2_CTcaExhmqu7ON7D01xNC6h8rohcaUvFOMKrsPW2UEAYZTu9nKUwsii_VhU4kmwkvNiD3kxkZhxyr__flOT0TwjA7N2GvFm9fmrR8-9k4dn12zHZVJ4B_4hIVUuXk-y4UuiAngZ9jnaHaPduIPvjxLfyB79ActIzrnUZO3oUG7e_Wu3KcooVpIWHEpn5b3Q46wuTjjbAiCRavUmiX49Qa2fbnANgiMMlhY8g1J8HlV9fuXR-PUpbwsI8W2KJgE1IKz5zfUpLwXaa8IPdZHLxTNI24lDqQUE_dBsfhl4xslFXrI-N62zDKg8jAgy1RINTFVOI-g0dDWNUDaV1Hl-XfPECgsAEWoTgBCr_7HByATRMxR6PU59RKzKjnJjF3tnnZPpQ2_nIO9f5AO12we4LHygHxcO8Ef3DFPaKiBBZvVdsYf1vOSgGJGAKMSMstyW8E4rXfVx9752QmIjj0-6OvKMzpYyNFLi0ci4h8JJA4vfxWN1bE1nu4dIqn0mGbVGgbVQCSJrzMc6ZIAL557J8F670RYtLC9e6ADq0fu0IFf99OX68EgCL1DCOp3PZkW981UW18uN77cFubZS981SW9nN4nD04g2gQkhMBk0DtYKrMm18wJOy5Aa4K0-WfFJtmFBovPi0I8g8XXLEmt9SLQ0kRXdOzKnXU641ecxQO9jbM8bRLCYmCtrNH9dljTsJxyBTUWsyfl2ndtXEog8md18-3ZwkXy9e5Ba4cG-o1903K18PlQ28kFRdyhK-b01o0U9wnMG6E05GicgRGYXTjuw0qmPqs8Dwbm3o0QHSoyKykoIOFsASS1h6QImAOqBBHIkjJG4W9N0yu2HGN51uv8kazHX39wa-0a0otbjA-7ZmkOA74kImQWFF9P0cG4e0-G1AgFHGmSqXQJkc44g3Pn2QX8g3okjAMN0c_VCWF0cQGMWIB0uiRm4g8Kas6rva-0jcF0nAWiS3ZmubSywJhfkg0kd6Qmodzd8mYJj1C7sFA4jNgE58FNhVx4sGJKEMXjWaNcbKXQ3aUNyr8JM4L5gheh1AYzGaK0cXGOI4p0bXcd2ZACahj1QhXDC4Jcam1O2KDhf8gDAycy8f0JG8a1B0qtmBWYb1v42K_68Y8KhmkF307qL0XsG4EW5G0iO5nepKdy2nmJ6YfoPhcccKafNr54896HSP_oFm2HmEZlVH-GIDW6RmnVc4Zu5F6ZSEBYcOpZ0okxJG-6199oe_C9wdqnCo2fa4YWEA-nXxGaMWClMyUuB93rDowes3Anb_1k4qyHvyWiqFM__9-8IK7wa4aGkHvtduX0n79WdNFbGypxqdO1Gmmw_59umSZ18s7co6twXF62cC4YWEAFstrnCk71V_4-m96ucD-wZylpKkcs-oCqfnZgGPncQp4MgWxEIt9zdpvt_znLq9ye_RGmbGlMLxb7-i8QN__UtVdZyNHGptUrul_MkRtQCIRkJXFvIO6xaeEyrsF6ah_tGv6x4QXfArNbR9JZFGi3qcswm4b7qu5Bo_NpnkDYCljpkS2a3ti71O_PHb9xLYPhnKafrckPkspS0WiutdOUO8_bClXlFszufiFMdsPwlNIbzk15T8Vx4PUh1pTeNwJpltQgMBR8SozBV7h9zyLM5ExUINHwoRMWHYQoQ-EsAzXaVQTDsUOip9m_QCoiaFdPhDgv2wcUxLmcIk2gt2qNgxqSTMvQt6pLxHnnhQAxrQ7VnpP9kHBdSrNdADdcgdABKQJsDUJ37kYgx6VNYckyo7vdKCRstNcvrcL3_yfrDvfxOpnaYpgx7fzfjLjwwy_VyjBc0Pi67DgSRGIotPQLOCP6rrnMPnj6EZAk7GIzsI-6A5DzyJl5gASyUJp5w7ex4SEipqOhW67_KQR8zDqMQimaKjFpOBOYmwQt1f7oKTuDFchiB9UDkpnottbrk7Rxl4s1mfJztqNlsRUL2Htvvc5JZG6-pcR4NwAuAhtZUBQj1x6ODFQITjQnsUE8TbB7lnASKouYIkpDHlof-TjczeEHbUvivZSr7Tx7dtK0mdEttRIv7Io9gyBJbpkWA6nAdEFIiUe7Bnt-HvAPvpnwARDfALcbxyryODOtsKhNSxk1SluUyDmwtsOpUzLuLDxw6f2DO1xtfnhHaljPL_nWilgepdZftBWcOgRMlXrQh5dnlkXMhDF2qiezixl9iCd1DwBAUXbKUoyAysiJm6ZDYwNX0_MsNaSk9U6ntEp9UkkJeFPCL0QCpiUulEOoVjqitG2o_hgWqFWcWdMYdDu0hS9_ZvE2qLItS6BLk0ayBfN5w3QBWiPVFMOTHtW-knsuqMNwKMvl5xc7btz5x2K_6WDl7PD2VCFDrbtShqL66vrcOdjE2Oy3ibK0xfn8MM0L2ATZKiF8IYApCaaszxalayshrPBedRSpoMg3lmP6NjWHPBqtfIJDEbPkeikc9b0mJMxJr7laTbCQ-lB6MRFakqmt7JkqmB9pmKw9QBj0DP_IkwLVZRyQai_dRM07_tcOzO8RVtVIVKErbAkdUILw-6bhFDj6qlgUEz-4AWlOBrt0D4qzUBMtkhAuTzD1Ml7d3hg0DdqXqwrcpzfvkPN9mE9V9dyPta97-g87PREzlkTxkecJUdPlff2gKLaR_QjiTJT5rbfLHd9IsbJJkQy-ivvBNGRVik4FobYbNqJjk--edEf6zttTRimOrffn5Erdbxmh7mG7PkooEUlarxQ_2W6oSaVSmcudnvcZ62TZi-TryyRhiVcu3eqhFsBZUgjxLxzQB2RlkP-ikdgQAE3Ushg5D4IK9Yf_N7JbvI8JsIxpk27e3QiRq-_z4-Y--jnVNHuSi6nf3Ct9fm7Q4MNkvJN5SQejrwF3PZXWR4IfKOco1XCsEbYWprX9Ur6GbtbIxOTGH3-c0n_uX3GENZkl76SLtxFDIHtWwUabQioUXAvQ_Bo4Arj3QJNUJMnVvWzPhuga2ttbfjHWBOBgY1TwsmimRcwvgr6C31ozSA9RhMRaKDejkHlMlLUlYtIsEVD6krSU8xhduPLLkfORTIy-iKLeVwY3mxGu9t3bUbEd7ARpfBgdZhArFtLxTfM_rVfmFqgCXNrBLZDt5IuRweFeEQlruzDcjQgbhSEkNqjANgY9hQC0ESLTLfuugjtyvYARzEaMkqNsXppIQPCUQMAkAm9lReLSVDPMRthyca6rgRdYfdaHP8hGINFMsGN3dSz8tMzgoXVDhsj50R0-YLx5kqzUUeOSiilbeIDtZifDZTXjAQFTSbLKx3dSnpQBkUDugoUTqwXwvxDwVecclkBBeTi2t1kQXwTmb55VUtWtWEHeH6W_IXIvr1KuaB3V-xpVQuWfB8fjjcrtBw8Ut_uwu-o_bw_K26c7iSFvRN88JnCdx3sEuMepbqms_pwD_kp-7b4-ewFdsXRWuFatPtbwTug4iy-NiZFVYMUVyHNC9jtmSmcdONzKuJ9JefcfeEPj5EzzOlaLFSo81fizSYoZ-tboAVfToALbxQAjem-bgTvwX1Y2YXAs0LNRyYpjsKjmPourgRzQ01dBSVlALNhvEA03KEY2grQQVGUcvxZFypvQGNpTMQxIqBOxf7qzEqv0ft8ocg1R2bQWOfYLnBtFWleZT4qbQiBsw-ruLxdkiW9w_C4dNwGlhkQTBaOPyYb_4Pz4TNpE8P3BaKoIRalbpJmsW9Ezj7fdEA6_QGYUVckYDq-Bd7h7ZgOGez2MI86Y-Mly_wJdBhMz_DeUqQjNMqhPnusZxpXfPutxeXblfOBzGhe6wzGz9HDbEZQGwgfIJn0OrKB1TRxfAaJR4hbjxevihal_qTTJaNCOlR5MYRqUQkGemPhDgryeog5IkUfuxrnrr_29IULbIdHXDqvUTj9qNFekNPFpkoYpTFNuGjpU71oTYei4eBlGaVMZ9zZxPPBFGdIeRvGTDNOenAcGZcKfza_p9uOiOyF9KGfavRr6x6G4IKSDAU8ouDSSutjTR1t7pEuJJO8tyUIyVJZlRSYbPXOteL7Ob5K5UjozAM_6bCx8Hbo2qXwFXbkEhZ1OGjovDLILjt7s9VWCKF29mDtcDrLo9_GcoF6-M6OQVt4djrtWvoMz8QwmR8lCn1S5NOWgQ45LSYcBJ70BlekPJclj0LyyUJGSxss0ycgQJIRelW5WVTGSUzy_3uay-TPtc8Ktu4DiBqk3KYNftyuCtrkcoiYuoRFS7DRjyxN6tjxDv1nSxtI_IUYW-m6oav7NzWj18-7_svkq39ZyrBwFdlG1NbljFv_4dhhWhAlpUOugZxDjM73x6WNmzx-FAINaNdi6FitAa-u3EwTDcshqtd4W_pXk_pMga1ySISsqxR6sd-WjdU6KSJ2iHFKPSU8hCRAxxlstpDfLSFctGjxLnrxMSRxCojg67Iek_7mxj8NeorLrn2QDx9gtPSBfFlYNabEBTjaeYwlPjBHRRDVqqJJRDL6b5NbRFP_17MHLELc_y7 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 3789fce43c..89fe990a28 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1064,6 +1064,7 @@ class Notifications{ label : text link : text text : text + triggeredAt : date viewedAt : date } diff --git a/src/migrations/20260601000000-create-notifications-table.js b/src/migrations/20260601000000-create-notifications-table.js index c06ebad1c5..1b3af18ec0 100644 --- a/src/migrations/20260601000000-create-notifications-table.js +++ b/src/migrations/20260601000000-create-notifications-table.js @@ -51,6 +51,10 @@ module.exports = { type: Sequelize.DATEONLY, allowNull: true, }, + triggeredAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, createdAt: { type: Sequelize.DATE, allowNull: false, diff --git a/src/models/notification.js b/src/models/notification.js index 1c5354acdd..5721f53dde 100644 --- a/src/models/notification.js +++ b/src/models/notification.js @@ -44,10 +44,26 @@ export default (sequelize, DataTypes) => { 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/seeders/20260601000000-notifications.js b/src/seeders/20260601000000-notifications.js index 765b41c0bc..49e7b4658b 100644 --- a/src/seeders/20260601000000-notifications.js +++ b/src/seeders/20260601000000-notifications.js @@ -40,6 +40,7 @@ const notifications = [ viewedAt: '2025-01-03', createdAt: new Date(), updatedAt: new Date(), + triggeredAt: '2025-01-02', }, // Global notification (no userId) { @@ -54,6 +55,7 @@ const notifications = [ viewedAt: null, createdAt: new Date(), updatedAt: new Date(), + triggeredAt: null, }, ]; From e367d50b9c9584e30a88a0ecb2e90984ff72a359 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Tue, 2 Jun 2026 09:49:22 -0400 Subject: [PATCH 04/23] Add all enum types --- specs/actionable-notifications/index.md | 125 +++++++++++-- .../notifications.csv | 176 ++++++++++++++++++ src/constants.js | 128 ++++++++++++- ...260601000000-create-notifications-table.js | 64 ++++++- src/models/tests/notification.test.js | 16 +- src/seeders/20260601000000-notifications.js | 8 +- 6 files changed, 486 insertions(+), 31 deletions(-) create mode 100644 specs/actionable-notifications/notifications.csv diff --git a/specs/actionable-notifications/index.md b/specs/actionable-notifications/index.md index 5e568d06be..7ed51fcb5c 100644 --- a/specs/actionable-notifications/index.md +++ b/specs/actionable-notifications/index.md @@ -48,31 +48,121 @@ Create simple seeded data, also for use during development ### Notification configuration -As example. These will be created as we build out notifications +The full enum lives in [`src/constants.js`](../../src/constants.js) under `NOTIFICATION_TYPES`. The table below maps each notification trigger (by CSV row ID) to its enum key. See [`notifications.csv`](./notifications.csv) for full copy, recipient, and channel detail. -```ts +**Convention:** one enum key per *trigger*. When the same event fans out to multiple recipients (e.g., approver, creator, collaborator), those variants share a single key — the recipient is resolved at send time via metadata passed to `createNotification`. + +**Excluded rows:** rows with status `Out of scope` in the CSV are not represented in the enum. Rows with status `Paused` are included and noted below. -// also add to UserSettings enum -// @src/constants.js +**Worked example:** +```ts +// src/constants.js const NOTIFICATION_TYPES = { - // for example - ACTIVITY_REPORT_CHANGES_REQUESTED: 'emailWhenChangeRequested', + ACTIVITY_REPORT_NEEDS_ACTION: 'changesRequested', + // ... etc }; +// Paired NOTIFICATION_CONFIGURATION entry (to be added per notification) const NOTIFICATION_CONFIGURATION = { - // and so forth. We need custom functions for each type since each notification has bespoke text - [NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED]: { - textFn: ({ userName, recipientName }) => `${userName} has requested changes to your Activity Report for ${recipientName}.`, - // whether or not we display primary button style or outline button style ("view" vs "take action") - actionable: true, - linkFn: ({id}) => `/activity-report/${id}`, - linkText: () => 'View AR', - }, -} - + [NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION]: { + textFn: ({ userName, recipientName }) => + `${userName} has requested changes to your Activity Report for ${recipientName}.`, + // whether or not we display primary button style or outline button style ("view" vs "take action") + actionable: true, + linkFn: ({ id }) => `/activity-reports/${id}`, + linkText: () => 'View AR', + }, +}; ``` +#### Notification inventory + +##### Activity Report + +| CSV row(s) | Enum key | Notes | +|---|---|---| +| AR-1a/b | `ACTIVITY_REPORT_COLLABORATOR_ADDED` | existing | +| AR-2a–d, AR-3a–d | `ACTIVITY_REPORT_SUBMITTED` | existing; covers both creator & collaborator submitting | +| AR-4a–d, AR-5a–d | `ACTIVITY_REPORT_RESUBMITTED` | new | +| AR-6a–f, AR-8a–f | `ACTIVITY_REPORT_NEEDS_ACTION` | existing; covers Approver 1 & 2 requesting changes | +| AR-7a–f, AR-9a–f | `ACTIVITY_REPORT_APPROVED` | existing; covers Approver 1 & 2 approvals | +| `ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED` | recipient notified on final approval | existing | +| AR-10a | `ACTIVITY_REPORT_SUBMITTED_DIGEST` | existing | +| AR-11 | `ACTIVITY_REPORT_NEEDS_ACTION_DIGEST` | existing | +| AR-12 | `ACTIVITY_REPORT_APPROVED_DIGEST` | existing | +| AR-13 | `ACTIVITY_REPORT_COLLABORATOR_DIGEST` | existing | +| `ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED_DIGEST` | recipient digest | existing | +| AR-14 | `ACTIVITY_REPORT_SUBMITTED_TO_COLLABORATOR_DIGEST` | new | +| AR-15 | `ACTIVITY_REPORT_COLLABORATOR_SUBMITTED_DIGEST` | new | +| AR-20 to AR-25 | _(out of scope — not in enum)_ | | + +##### Collaborative Report + +| CSV row(s) | Enum key | Notes | +|---|---|---| +| CR-1a/b | `COLLAB_REPORT_COLLABORATOR_ADDED` | new | +| CR-2a–d, CR-3a–d | `COLLAB_REPORT_SUBMITTED` | new | +| CR-4a–d, CR-5a–d | `COLLAB_REPORT_RESUBMITTED` | new | +| CR-6a–f, CR-8a–f | `COLLAB_REPORT_NEEDS_ACTION` | new | +| CR-7a–f, CR-9a–f | `COLLAB_REPORT_APPROVED` | new | +| CR-10a | `COLLAB_REPORT_SUBMITTED_DIGEST` | new | +| CR-11, CR-14 | `COLLAB_REPORT_NEEDS_ACTION_DIGEST` | new | +| CR-12, CR-15 | `COLLAB_REPORT_APPROVED_DIGEST` | new | +| CR-13 | `COLLAB_REPORT_COLLABORATOR_DIGEST` | new | +| CR-16 | `COLLAB_REPORT_SUBMITTED_TO_COLLABORATOR_DIGEST` | new | +| CR-17 | `COLLAB_REPORT_COLLABORATOR_SUBMITTED_DIGEST` | new | + +##### Training Report + +| CSV row(s) | Enum key | Notes | +|---|---|---| +| TR-1a/b | `TRAINING_REPORT_POC_ADDED` | new | +| TR-2a/b | `TRAINING_REPORT_COLLABORATOR_ADDED` | existing | +| TR-3a–e | `TRAINING_REPORT_SESSION_SUBMITTED` | new | +| TR-4a–f | `TRAINING_REPORT_SESSION_NEEDS_ACTION` | new | +| TR-5a–d, TR-6a–d | `TRAINING_REPORT_SESSION_RESUBMITTED` | new | +| _(existing)_ | `TRAINING_REPORT_SESSION_CREATED` | existing | +| _(existing)_ | `TRAINING_REPORT_EVENT_COMPLETED` | existing | +| _(existing)_ | `TRAINING_REPORT_TASK_DUE` | existing; cron umbrella | +| _(existing)_ | `TRAINING_REPORT_EVENT_IMPORTED` | existing | +| TR-5b, TR-7b _(Paused)_ | `TRAINING_REPORT_EVENT_INFO_MISSING` | new; Paused | +| TR-6a, TR-8a _(Paused)_ | `TRAINING_REPORT_EVENT_INFO_PAST_DUE` | new; Paused | +| TR-9a/b, TR-10a/b, TR-11a/b _(Paused)_ | `TRAINING_REPORT_SESSION_INFO_MISSING` | new; Paused | +| TR-10c, TR-12 _(Paused)_ | `TRAINING_REPORT_SESSION_INFO_PAST_DUE` | new; Paused | +| TR-13, TR-15 _(Paused)_ | `TRAINING_REPORT_NO_SESSIONS_CREATED` | new; Paused | +| TR-14, TR-16 _(Paused)_ | `TRAINING_REPORT_NO_SESSIONS_PAST_DUE` | new; Paused | +| TR-17 _(Paused)_ | `TRAINING_REPORT_EVENT_NOT_COMPLETED` | new; Paused | +| TR-18 _(Paused)_ | `TRAINING_REPORT_EVENT_NOT_COMPLETED_PAST_DUE` | new; Paused | +| TR-19 | `TRAINING_REPORT_POC_ADDED_DIGEST` | new | +| TR-20 | `TRAINING_REPORT_COLLABORATOR_ADDED_DIGEST` | new | +| TR-21 | `TRAINING_REPORT_SESSION_SUBMITTED_DIGEST` | new | +| TR-22 | `TRAINING_REPORT_SESSION_NEEDS_ACTION_DIGEST` | new | +| TR-23 | `TRAINING_REPORT_EVENT_INFO_MISSING_DIGEST` | new | +| TR-24, TR-25 | `TRAINING_REPORT_SESSION_INFO_MISSING_DIGEST` | new | +| TR-26 | `TRAINING_REPORT_NO_SESSIONS_CREATED_DIGEST` | new | +| TR-27 | `TRAINING_REPORT_EVENT_NOT_COMPLETED_DIGEST` | new | + +##### Communication Log + +| CSV row(s) | Enum key | Notes | +|---|---|---| +| CL-1a/b | `COMMUNICATION_LOG_TTA_STAFF_ADDED` | new | +| CL-2a/b | `COMMUNICATION_LOG_RECIPIENT_IN_GROUP` | new | +| CL-3 | `COMMUNICATION_LOG_TTA_STAFF_ADDED_DIGEST` | new | +| CL-4 | `COMMUNICATION_LOG_RECIPIENT_IN_GROUP_DIGEST` | new | + +##### Monitoring / Group / System + +| CSV row(s) | Enum key | Notes | +|---|---|---| +| Misc-1a/b (Draft) | `MONITORING_GOAL_ADDED` | new; Draft status | +| Misc-1a/b (monitoring data) | `MONITORING_DATA_RECEIVED` | new | +| Misc-2a/b | `GROUP_CO_OWNER_ADDED` | new | +| Misc-3a/b | `GROUP_SHARED` | new | +| Misc-4a/b | `SYSTEM_PLANNED_OUTAGE` | new | +| Misc-5a | `SYSTEM_UNPLANNED_OUTAGE` | new | + ### Services **Ticket #2: [Create Notifications service](https://jira.acf.gov/browse/TTAHUB-5384)** @@ -186,10 +276,11 @@ To be expanded upon in conjunction with PM - Refactor home page to use new tiled markup - Create notifications page/table, less filter component +- Filter component, filters - Create notifications preference page - Move email opt-in - Modify site header to add bell component/update avatar menu - Modify admin interface for creating site alerts to also create notifications - Add filter component to notifications page/table -Note: additional tickets will be needed to *register* the new notifications/emails. +Note: additional tickets will be needed to *register* the new notifications/emails on a per/notification basis diff --git a/specs/actionable-notifications/notifications.csv b/specs/actionable-notifications/notifications.csv new file mode 100644 index 0000000000..dd48c3a68c --- /dev/null +++ b/specs/actionable-notifications/notifications.csv @@ -0,0 +1,176 @@ +Category,Role,Action (trigger),Type,Content or Subject line,CTA,Received by,Status,ID,Email content,Notes +Activity Report,Creator,Adds a Collaborator,In-app,[Creator's name] added you as a Collaborator on their Activity Report for [Recipient name].,View AR,Collaborator,Proposed,AR-1a,, +Activity Report,Creator,Adds a Collaborator,Email,Activity Report R01-AR-17967: Added as collaborator,,Collaborator,Published,AR-1b,Actionable Notifications email content, +Activity Report,Creator,Submits a report for approval,In-app,An Activity Report for [Recipient name] has been submitted for approval.,Review AR,Approvers,Proposed,AR-2a,,review vs. approval? Go with Approval +Activity Report,Creator,Submits a report for approval,Email,Activity Report R01-AR-17967: Submitted for approval,,Approvers,Published,AR-2b ,Actionable Notifications email content,"updated existing message with ""approval""" +Activity Report,Creator,Submits a report for approval,In-app,[Creator's Name] has submitted an Activity Report for approval.,View AR,Collaborators,Proposed,AR-2c,, +Activity Report,Creator,Submits a report for approval,Email,Activity Report R01-AR-17967: Submitted for approval,,Collaborators,Proposed,AR-2d,Actionable Notifications email content,Do we have this? +Activity Report,Collaborator,Submits a report for approval,In-app,An Activity Report for [Recipient's name] has been submitted for approval.,Review AR,Approvers,Proposed,AR-3a,, +Activity Report,Collaborator,Submits a report for approval,Email,Activity Report R01-AR-17967: Submitted for approval,,Approvers,Proposed,AR-3b,Actionable Notifications email content, +Activity Report,Collaborator,Submits a report for approval,In-app,[Collaborator's name] has submitted an Activity Report for approval.,View AR,Creator,Proposed,AR-3c,, +Activity Report,Collaborator,Submits a report for approval,Email,Activity Report R01-AR-17967: Submitted for approval,,Creator,Proposed,AR-3d,Actionable Notifications email content, +Activity Report,Creator,Re-submit a report for approval,In-app,A revised Activity Report for [Recipient's name] has been submitted for approval.,Review AR,Approvers,Proposed,AR-4a,, +Activity Report,Creator,RE-submit a report for approval,Email,Revised Activity Report R01-AR-17967: Submitted for approval,,Approvers,Proposed,AR-4b,Actionable Notifications email content, +Activity Report,Creator,Re-submit a report for approval,In-app,A revised Activity Report for [Recipient's name] has been submitted for approval.,View AR,Collaborator,Proposed,AR-4c,, +Activity Report,Creator,Re-submit a report for approval,Email,Revised Activity Report R01-AR-17967: Submitted for approval,,Collaborator,Proposed,AR-4d,Actionable Notifications email content, +Activity Report,Collaborator,RE-submit a report for approval,In-app,A revised Activity Report for [Recipient's name] has been submitted for approval.,Review AR,Approvers,Proposed,AR-5a,, +Activity Report,Collaborator,RE-submit a report for approval,Email,Revised Activity Report R01-AR-17967: Submitted for approval,,Approvers,Proposed,AR-5b,Actionable Notifications email content, +Activity Report,Collaborator,RE-submit a report for approval,In-app,[Collaborator's name] has submitted a revised Activity Report for approval.,View AR,Creator,Proposed,AR-5c,, +Activity Report,Collaborator,RE-submit a report for approval,Email,Revised Activity Report R01-AR-17967: Submitted for approval,,Creator,Proposed,AR-5d,Actionable Notifications email content, +Activity Report,System,Near report submission deadline (5th working day of the month),In-app,The submission deadline for Activity Report R01-AR-17967 is near. ,Take action,Creator,Out of scope,,,NEW - Wishlist item 03/24/26 Digest? +Activity Report,System,Near report submission deadline (5th working day of the month),Email,Submit draft Activity Report R01-AR-17967: Submission deadline 07/05/2026,,Creator,Out of scope,,Actionable Notifications email content,Version 2 per Heather - https://adhoc.slack.com/archives/C022R5301L4/p1774474899507799 +Activity Report,System,Near report submission deadline (5th working day of the month),In-app,The submission deadline for Activity Report R01-AR-17967 is near. ,Take action,Collaborator,Out of scope,,, +Activity Report,System,Near report submission deadline (5th working day of the month),Email,Submit draft Activity Report R01-AR-17967: Submission deadline 07/05/2026,,Collaborator,Out of scope,,Actionable Notifications email content, +Activity Report,System,"Activity Report pending approval for ""X' days",In-app,"An Activity Report for [Recipient name] has been pending approval for ""X"" days.",View AR,TTAC/Managers,Out of scope,,,NEW - Wishlist item 03/24/26 Digest? +Activity Report,System,"Activity Report pending approval for ""X' days",Email,"Activity Report R01-AR-17967: Pending approval for ""X"" days",,TTAC/Managers,Out of scope,,Actionable Notifications email content,Version 2 - https://adhoc.slack.com/archives/C022R5301L4/p1774474899507799 +Activity Report,Approver 1,Reviews report and sets status to needs action,In-app,[Approver 1 name] has requested changes to your Activity Report for [Recipient name].,Take action/View AR,Creator,Proposed,AR-6a,, +Activity Report,Approver 1,Reviews report and sets status to needs action,Email,Activity Report R01-AR-17967: Changes requested,,Creator,Published,AR-6b,Actionable Notifications email content,Text includes Approver name +Activity Report,Approver 1,Reviews report and sets status to needs action,In-app,[Approver 1 name] has requested changes to your Activity Report for [Recipient name].,Take action,Collaborator,Proposed,AR-6c,, +Activity Report,Approver 1,Reviews report and sets status to needs action,Email,Activity Report R01-AR-17967: Changes requested,,Collaborator,Proposed,AR-6d,Actionable Notifications email content, +Activity Report,Approver 1,Reviews report and sets status to needs action,In-app,[Approver 1 name] has requested changes to an Activity Report for [Recipient name].,View AR,Approver 2,Proposed,AR-6e,, +Activity Report,Approver 1,Reviews report and sets status to needs action,Email,Activity Report R01-AR-17967: Changes requested by [Approver 1 name],,Approver 2,Proposed,AR-6f,Actionable Notifications email content,LInk text is different for Approver 2 +Activity Report,Approver 1,Reviews report and sets status to approved,In-app,[Approver 1 name] has approved your Activity Report for [Recipient name].,View AR,Creator,Proposed,AR-7a,, +Activity Report,Approver 1,Reviews report and sets status to approved,Email,Activity Report R01-AR-17967: Approved,,Creator,Published,AR-7b,Actionable Notifications email content,Added approver name +Activity Report,Approver 1,Reviews report and sets status to approved,In-app,[Approver 1 name] has approved your Activity Report for [Recipient name].,View AR,Collaborator,Proposed,AR-7c,, +Activity Report,Approver 1,Reviews report and sets status to approved,Email,Activity Report R01-AR-17967: Approved,,Collaborator,Published,AR-7d,Actionable Notifications email content, +Activity Report,Approver 1,Reviews report and sets status to approved,In-app,[Approver 1 name] has approved your Activity Report for [Recipient name].,View AR,Approver 2,Proposed,AR-7e,, +Activity Report,Approver 1,Reviews report and sets status to approved,Email,Activity Report R01-AR-17967: Approved by [Approver 1 name],,Approver 2,Proposed,AR-7f,Actionable Notifications email content, +Activity Report,Approver 2,Reviews report and sets status to needs action,In-app,[Approver 2 name] has requested changes to your Activity Report for [Recipient name].,Take action/View AR,Creator,Proposed,AR-8a,, +Activity Report,Approver 2,Reviews report and sets status to needs action,Email,Activity Report R01-AR-17967: Changes requested,,Creator,Proposed,AR-8b,Actionable Notifications email content,"Text includes Approver name, update existing email" +Activity Report,Approver 2,Reviews report and sets status to needs action,In-app,[Approver 2 name] has requested changes to your Activity Report for [Recipient name].,Take action,Collaborator,Proposed,AR-8c,, +Activity Report,Approver 2,Reviews report and sets status to needs action,Email,Activity Report R01-AR-17967: Changes requested,,Collaborator,Proposed,AR-8d,Actionable Notifications email content, +Activity Report,Approver 2,Reviews report and sets status to needs action,In-app,[Approver 2 name] has requested changes to an Activity Report for [Recipient name].,View AR,Approver 1,Proposed,AR-8e,, +Activity Report,Approver 2,Reviews report and sets status to needs action,Email,Activity Report R01-AR-17967: Changes requested by [Approver 2 name],,Approver 1,Proposed,AR-8f,Actionable Notifications email content,LInk text is different for Approver 1 +Activity Report,Approver 2,Reviews report and sets status to approved,In-app,[Approver 2 name] has approved your Activity Report for [Recipient name].,View AR,Creator,Proposed,AR-9a,, +Activity Report,Approver 2,Reviews report and sets status to approved,Email,Activity Report R01-AR-17967: Approved,,Creator,Proposed,AR-9b,Actionable Notifications email content, +Activity Report,Approver 2,Reviews report and sets status to approved,In-app,[Approver 2 name] has approved your Activity Report for [Recipient name]..,View AR,Collaborator,Proposed,AR-9c,, +Activity Report,Approver 2,Reviews report and sets status to approved,Email,Activity Report R01-AR-17967: Approved,,Collaborator,Proposed,AR-9d,Actionable Notifications email content, +Activity Report,Approver 2,Reviews report and sets status to approved,In-app,[Approver 2 name] has approved your Activity Report for [Recipient name].,View AR,Approver 1,Proposed,AR-9e,, +Activity Report,Approver 2,Reviews report and sets status to approved,Email,Activity Report R01-AR-17967: Approved by [Approver 2 name],,Approver 1,Proposed,AR-9f,Actionable Notifications email content, +Activity Report,Digest,Activity Reports for review,Email,TTA Hub [daily/weekly/monthly] digest: Activity Reports for approval,,Approvers,Proposed,AR-10a,,Digest +Activity Report,Digest,Activity Reports need action,Email,TTA Hub [daily/weekly/monthly] digest: Activity Report changes requested,,Creator/Collaborator,Proposed,AR-11,Actionable Notifications email content, +Activity Report,Digest,Activity Reports - approved,Email,TTA Hub [daily/weekly/monthly] digest: approved Activity Reports,,Creator/Collaborator,Proposed,AR-12,Actionable Notifications email content, +Activity Report,Digest,Added as a Collaborator,Email,TTA Hub [daily/weekly/monthly] digest: added as collaborator on Activity Reports,,Collaborator,Proposed,AR-13,Actionable Notifications email content, +Activity Report,Digest,Creator submits AR where I'm the Collab,Email,TTA Hub [daily/weekly/monthly] digest: Activity Reports submitted for approval,,Collaborator,Proposed,AR-14,Actionable Notifications email content, +Activity Report,Digest,Collab submits a report for approval,Email,TTA Hub [daily/weekly/monthly] digest: Activity Reports submitted for approval,,Creator,Proposed,AR-15,Actionable Notifications email content, +Collaborative Report,Creator,Adds a Collaborator,In-app,[Creator's name] added you as a Collaborator on their Collaboration Report for [Activity name].,View AR,Collaborator,Proposed,CR-1a,, +Collaborative Report,Creator,Adds a Collaborator,Email,Collaboration Report R01-CR-12345: Added as collaborator,,Collaborator,Proposed,CR-1b,Actionable Notifications email content, +Collaborative Report,Creator,Submits a report for approval,In-app,[Creator's name] submitted the [Activity name] Collaboration Report for approval.,Review CR,Approvers,Proposed,CR-2a,, +Collaborative Report,Creator,Submits a report for approval,Email,Collaboration Report R01-CR-12345: Submitted for approval,,Approvers,Proposed,CR-2b,Actionable Notifications email content, +Collaborative Report,Creator,Submits a report for approval,In-app,[Creator's name] submitted the [Activity name] Collaboration Report for approval.,View CR,Collaborator,Proposed,CR-2c,, +Collaborative Report,Creator,Submits a report for approval,Email,Collaboration Report R01-CR-12345: Submitted for approval,,Collaborator,Proposed,CR-2d,Actionable Notifications email content, +Collaborative Report,Collaborator,Submits a report for approval,In-app,[Collaborator's name] submitted the [Activity name] Collaboration Report for approval.,Review CR,Approvers,Proposed,CR-3a,, +Collaborative Report,Collaborator,Submits a report for approval,Email,Collaborator Report R01-CR-12345: Submitted for approval,,Approvers,Proposed,CR-3b,Actionable Notifications email content, +Collaborative Report,Collaborator,Submits a report for approval,In-app,[Collaborator's name] submitted the [Activity name] Collaboration Report for approval.,View CR,Creator,Proposed,CR-3c,, +Collaborative Report,Collaborator,Submits a report for approval,Email,Collaborator Report R01-CR-12345: Submitted for approval,,Creator,Proposed,CR-3d,, +Collaborative Report,Creator,Re-submit a report for approval,In-app,[Creator's name] submitted the revised [Activity name] Collaboration Report for approval.,Review CR,Approvers,Proposed,CR-4a,,Add Title +Collaborative Report,Creator,Re-submit a report for approval,Email,Revised Collaboration Report R01-CR-12345: Submitted for approval,,Approvers,Proposed,CR-4b,Actionable Notifications email content, +Collaborative Report,Creator,Re-submit a report for approval,In-app,[Creator's name] submitted the revised [Activity name] Collaboration Report for approval.,View CR,Collaborators,Proposed,CR-4c,, +Collaborative Report,Creator,Re-submit a report for approval,Email,Revised Collaboration Report R01-CR-12345: Submitted for approval,,Collaborators,Proposed,CR-4d,, +Collaborative Report,Collaborator,Re-submit a report for approval,In-app,[Collaborator's name] submitted the revised [Activity name] Collaboration Report for approval.,Review CR,Approvers,Proposed,CR-5a,, +Collaborative Report,Collaborator,Re-submit a report for approval,Email,Revised Collaboration Report R01-CR-12345: Submitted for approval,,Approvers,Proposed,CR-5b,, +Collaborative Report,Collaborator,Re-submit a report for approval,In-app,[Collaborator's name] has submitted a revised Collaboration Report for [Activity name].,View CR,Creator,Proposed,CR-5c,, +Collaborative Report,Collaborator,Re-submit a report for approval,Email,Revised Collaboration Report R01-CR-12345: Submitted for approval,,Creator,Proposed,CR-5d,, +Collaborative Report,Approver 1,Reviews report and sets status to needs action,In-app,[Approver 1 name] has requested changes to your Collaboration Report for [Activity name].,Take action/View AR,Creator,Proposed,CR-6a,, +Collaborative Report,Approver 1,Reviews report and sets status to needs action,Email,Collaboration Report R01-CR-12345: Changes requested,,Creator,Proposed,CR-6b,Actionable Notifications email content,Text includes Approver name +Collaborative Report,Approver 1,Reviews report and sets status to needs action,In-app,[Approver 1 name] has requested changes to your Collaboration Report for [Activity name].,Take action,Collaborator,Proposed,CR-6c,, +Collaborative Report,Approver 1,Reviews report and sets status to needs action,Email,Collaboration Report R01-CR-12345: Changes requested,,Collaborator,Proposed,CR-6d,Actionable Notifications email content, +Collaborative Report,Approver 1,Reviews report and sets status to needs action,In-app,[Approver 1 name] has requested changes to your Collaboration Report for [Activity name].,View AR,Approver 2,Proposed,CR-6e,, +Collaborative Report,Approver 1,Reviews report and sets status to needs action,Email,Collaboration Report R01-CR-12345: Changes requested by [Approver 1 name],,Approver 2,Proposed,CR-6f,,LInk text is different for Approver 2 +Collaborative Report,Approver 1,Reviews report and sets status to approved,In-app,[Approver 1 name] has approved your Collaboration Report for [Activity name].,View AR,Creator,Proposed,CR-7a,, +Collaborative Report,Approver 1,Reviews report and sets status to approved,Email,Collaboration Report R01-CR-12345: Approved,,Creator,Proposed,CR-7b,Actionable Notifications email content, +Collaborative Report,Approver 1,Reviews report and sets status to approved,In-app,[Approver 1 name] has approved your Collaboration Report for [Activity name].,View AR,Collaborator,Proposed,CR-7c,, +Collaborative Report,Approver 1,Reviews report and sets status to approved,Email,Collaboration Report R01-CR-12345: Approved,,Collaborator,Proposed,CR-7d,Actionable Notifications email content, +Collaborative Report,Approver 1,Reviews report and sets status to approved,In-app,[Approver 1 name] has approved a Collaboration Report for [Activity name].,View AR,Approver 2,Proposed,CR-7e,, +Collaborative Report,Approver 1,Reviews report and sets status to approved,Email,Collaboration Report R01-CR-12345: Approved by [Approver 1 name],,Approver 2,Proposed,CR-7f,, +Collaborative Report,Approver 2,Reviews report and sets status to needs action,In-app,[Approver 2 name] has requested changes to your Collaboration Report for [Activity name].,Take action/View AR,Creator,Proposed,CR-8a,, +Collaborative Report,Approver 2,Reviews report and sets status to needs action,Email,Collaboration Report R01-CR-12345: Changes requested,,Creator,Proposed,CR-8b,Actionable Notifications email content, +Collaborative Report,Approver 2,Reviews report and sets status to needs action,In-app,[Approver 2 name] has requested changes to your Collaboration Report for [Activity name].,Take action,Collaborator,Proposed,CR-8c,, +Collaborative Report,Approver 2,Reviews report and sets status to needs action,Email,Collaboration Report R01-CR-12345: Changes requested,,Collaborator,Proposed,CR-8d,Actionable Notifications email content, +Collaborative Report,Approver 2,Reviews report and sets status to needs action,In-app,[Approver 2 name] has requested changes to Collaboration Report for [Activity name].,View AR,Approver 1,Proposed,CR-8e,, +Collaborative Report,Approver 2,Reviews report and sets status to needs action,Email,Collaboration Report R01-CR-12345: Changes requested by [Approver 2 name],,Approver 1,Proposed,CR-8f,, +Collaborative Report,Approver 2,Reviews report and sets status to approved,In-app,[Approver 2 name] has approved your Collaboration Report for [Activity name].,View AR,Creator,Proposed,CR-9a,, +Collaborative Report,Approver 2,Reviews report and sets status to approved,Email,Collaboration Report R01-CR-12345: Approved,,Creator,Proposed,CR-9b,Actionable Notifications email content, +Collaborative Report,Approver 2,Reviews report and sets status to approved,In-app,[Approver 2 name] has approved your Collaboration Report for [Activity name].,View AR,Collaborator,Proposed,CR-9c,, +Collaborative Report,Approver 2,Reviews report and sets status to approved,Email,Collaboration Report R01-CR-12345: Approved,,Collaborator,Proposed,CR-9d,Actionable Notifications email content, +Collaborative Report,Approver 2,Reviews report and sets status to approved,In-app,[Approver 2 name] has approved a Collaboration Report for [Activity name].,View AR,Approver 1,Proposed,CR-9e,, +Collaborative Report,Approver 2,Reviews report and sets status to approved,Email,Collaboration Report R01-CR-12345: Approved by [Approver 2 name],,Approver 1,Proposed,CR-9f,, +Collaborative Report,Digest,Collaboration Reports for approval,Email,TTA Hub [daily/weekly/monthly] digest: Collaboration Reports for approval,,Approvers,Proposed,CR-10a,Actionable Notifications email content, +Collaborative Report,Digest,Collaboration Reports needs action,Email,Subject: TTA Hub [daily/weekly/monthly] digest: Collaboration Report changes requested,,Creator/Collaborator,Proposed,CR-11,Actionable Notifications email content, +Collaborative Report,Digest,Collaboration Reports - approved,Email,Subject: TTA Hub [daily/weekly/monthly] digest: approved Collaboration Reports,,Creator/Collaborator,Proposed,CR-12,Actionable Notifications email content, +Collaborative Report,Digest,Added as a Collaborator,Email,TTA Hub [daily/weekly/monthly] digest: added as collaborator on Collaboration Reports,,Collaborator,Proposed,CR-13,, +Collaborative Report,Digest,Collaboration Reports need action,Email,TTA Hub [daily/weekly/monthly] digest: Collaboration Report changes requested,,Creator/Collaborator,Proposed,CR-14,, +Collaborative Report,Digest,Collab Report - approved,Email,TTA Hub [daily/weekly/monthly] digest: approved Collaboration Reports,,Creator/Collaborator,Proposed,CR-15,, +Collaborative Report,Digest,Creator submits CR where I'm the Collab,Email,TTA Hub [daily/weekly/monthly] digest: Collaboration Reports submitted for approval,,Collaborator,Proposed,CR-16,, +Collaborative Report,Digest,Collab submits a report for approval,Email,TTA Hub [daily/weekly/monthly] digest: Collaboration Reports submitted for approval,,Creator,Proposed,CR-17,, +Training Report,Event Creator,Adds a Collaborator as a POC,In-app,[Creator's name] added you as a Regional point of contact on their Training Report.,View TR,Event POC,Proposed,TR-1a,Actionable Notifications email content,Spell out point of contact? yes +Training Report,Event Creator,Adds a Collaborator as a POC,Email,Training Report R01-TR-25-2345: Added as Regional point of contact,,Event POC,Proposed,TR-1b,, +Training Report,Event Creator,Adds a Collaborator,In-app,[Creator's name] added you as a Collaborator on their Training Report.,View TR,Event Collaborator,Proposed,TR-2a,, +Training Report,Event Creator,Adds a Collaborator,Email,Training Report R01-TR-25-2345: Added as Collaborator,,Event Collaborator,Proposed,TR-2b,, +Training Report,Event Creator,Submits a session for approval,In-app,Session: [Session name] submitted for approval.,Take action,Approver,Proposed,TR-3a,,"Event POC, Session Approver?" +Training Report,Event Creator,Submits a session for approval,Email,Training Report R01-TR-25-2345: [Session name] submitted for approval.,,Approver,Proposed,TR-3b,, +Training Report,Event Creator,Submits a session for approval,In-app,Session: [Session name] submitted for approval.,View TR,Event Collaborator,Proposed,TR-3c,, +Training Report,Event Creator,Submits a session for approval,Email,Training Report R01-TR-25-2345: [Session name] submitted for approval.,,Event Collaborator,Proposed,TR-3d,, +Training Report,Event Creator,Submits a session for approval,In-app,Training Report R01-TR-25-2345: [Session name] submitted for approval.,View TR,Event Collaborator,Proposed,TR-3e,, +Training Report,Approver 1,Reviews session report and sets status to needs action,In-app,[Approver 1 name] has requested changes to session [Session name].,View TR,Creator,Proposed,TR-4a,, +Training Report,Approver 1,Reviews session report and sets status to needs action,Email,Training Report R01-TR-25-2345: Session changes requested,,Creator,Proposed,TR-4b,, +Training Report,Approver 1,Reviews session report and sets status to needs action,In-app,[Approver 1 name] has requested changes to session: [Session name].,View TR,Event Collaborator,Proposed,TR-4c,, +Training Report,Approver 1,Reviews session report and sets status to needs action,Email,Training Report R01-TR-25-2345: Session changes requested,,Event Collaborator,Proposed,TR-4d,, +Training Report,Approver 1,Reviews session report and sets status to needs action,In-app,[Approver 1 name] has requested changes to session: [Session name].,View TR,Session Approver 2,Proposed,TR-4e,, +Training Report,Approver 1,Reviews session report and sets status to needs action,Email,Training Report R01-TR-25-2345: Session changes requested,,Session Approver 2,Proposed,TR-4f,, +Training Report,Event Creator,Re-submit a session for review,In-app,"A revised Training Report session: [Session name], has been submitted for review.",,Approvers,Proposed,TR-5a,,Remove block +Training Report,Event Creator,Re-submit a session for review,Email,Revised Training Report R01-CR-12345: [Session name] submitted for review,,Approvers,Proposed,TR-5b,, +Training Report,Event Creator,Re-submit a session for review,In-app,"A revised Training Report session: [Session name], has been submitted for review.",,Event Collaborator,Proposed,TR-5c,, +Training Report,Event Creator,Re-submit a sessionfor review,Email,Revised Training Report R01-CR-12345: [Session name] submitted for review,,Event Collaborator,Proposed,TR-5d,, +Training Report,Event Collaborator,Re-submit a session for review,In-app,A revised Training Report has been submitted for review.,,Approvers,Proposed,TR-6a,,Does Collaborating Specialist submit TR's? +Training Report,Event Collaborator,Re-submit a session for review,Email,Revised Training Report R01-TR-25-2345: Submitted for review,,Approvers,Proposed,TR-6b,,Remove block +Training Report,Event Collaborator,Re-submit a session for review,In-app,A revised Training Report has been submitted for review.,,Event Creator,Proposed,TR-6c,, +Training Report,Event Collaborator,Re-submit a session for review,Email,Revised Training Report R01-TR-25-2345: Submitted for review,,Event Creator,Proposed,TR-6d,, +Training Report,System,Event info missing 20 days past event start date,In-app,Submit event details for [Event name].,Take action,Event Creator,Proposed,TR-5a,,Event Specialist? +Training Report,System,Event info missing 20 days past event start date,Email,Reminder: Submit event details for Training Report R01-TR-25-2345,,Event Creator,Paused,TR-5b,Actionable Notifications email content,#1 +Training Report,System,Event info missing 20 days past previous email reminder,Email,Past due: Submit event details for Training Report R01-TR-23-1358,,Event Creator,Paused,TR-6a,,"#2 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,System,Event info missing 20 days past event start date,In-app,Submit event details for [Event name].,Take action,Event Collaborator,Proposed,TR-7a,, +Training Report,System,Event info missing 20 days past event start date,Email,Reminder: Submit event details for Training Report R01-TR-23-1358,,Event Collaborator,Paused,TR-7b,,#3 +Training Report,System,Event info missing 20 days past previous email reminder,Email,Past due: Submit event details for Training Report R01-TR-23-1358,,Event Collaborator,Paused,TR-8a,,"#4 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,System,Session info missing 20 days past the session start date,In-app,Submit session details for [Session name].,Take action,Event Creator,Proposed,TR-9a,, +Training Report,System,Session info missing 20 days past the session start date,Email,"Reminder: Submit session details for Training Report R01-TR-23-135 +",,Event Creator,Paused,TR-9b,,#5 +Training Report,System,Session info missing 20 days past the session start date,Email,Past due: Submit session details for Training Report R01-TR-23-1358,,Event Creator,Paused,TR-9c,,"#6 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,System,Session info missing 20 days past the session start date,In-app,Submit session details for [Session name].,,Event Collaborator,Paused,TR-10a,,#7 +Training Report,System,Session info missing 20 days past the session start date,Email,Reminder: Submit session details for Training Report R01-TR-23-1358,,Event Collaborator,Paused,TR-10b,,#7 +Training Report,System,Session info missing 20 days past reminder,Email,Past due: Submit session details for Training Report R01-TR-23-1358,,Event Collaborator,Paused,TR-10c,,#8 +Training Report,System,Session info missing 20 days past session start date,In-app,Submit session details for [Session name].,Take action,Regional POC,Paused,TR-11a,,#9 +Training Report,System,Session info missing 20 days past session start date,Email,Reminder: Submit Session Details for Training Report ,,Regional POC,Paused,TR-11b,,#9 +Training Report,System,Session info missing 20 days past email reminder,Email,Past due: Submit session details for Training Report R01-TR-23-1358,,Regional POC,Paused,TR-12,,"#10 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,System,No sessions created 20 days past event end date,Email,Reminder: Create a Session for Training Report R01-TR-23-1358,,Event Creator,Paused,TR-13,,#11 +Training Report,System,No sessions created 20 days past reminder,Email,Past due: Create a Session for Training Report R01-TR-23-1358,,Event Creator,Paused,TR-14,,"#12 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,System,No sessions created 20 days past event end date,Email,Reminder: Create a Session for Training Report R01-TR-23-1358,,Event Collaborator,Paused,TR-15,,#13 +Training Report,System,No sessions 20 days past email reminder,Email,Past due: Create a Session for Training Report R01-TR-23-1358,,Event Collaborator,Paused,TR-16,,"#14 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,System,Event not completed 20 days past event end date,Email,Reminder: Complete Training Report R01-TR-23-1358,,Event Creator,Paused,TR-17,,#15 +Training Report,System,Event not completed 20 days past reminder,Email,Past due: Complete Training Report R01-TR-23-1358,,Event Creator,Paused,TR-18,,"#16 No in-app or opt-out option. In-app, event info missing will still be displayed in notification center. Repeats every 10 days if no action is taken" +Training Report,Digest,Added as an Event POC,Email,TTA Hub [daily/weekly/monthly] digest: added as a Regional point of contact,,Event POC,Proposed,TR-19,Actionable Notifications email content, +Training Report,Digest,Added as Event Collaborator,Email,TTA Hub [daily/weekly/monthly] digest: added as an Event Collaborator,,Event Collaborator,Proposed,TR-20,, +Training Report,Digest,session submitted for review,Email,TTA Hub [daily/weekly/monthly] digest: session submitted for review,,Approvers,Proposed,TR-21,, +Training Report,Digest,Session changes requested,Email,TTA Hub [daily/weekly/monthly] digest: session changes requested,,Event Creator/Event Collaborator,Proposed,TR-22,, +Training Report,Digest,Event details not complete,Email,TTA Hub [daily/weekly/monthly] digest: submit event details,,Event Collaborator,Proposed,TR-23,, +Training Report,Digest,Session details not complete,Email,TTA Hub [daily/weekly/monthly] digest: submit session details,,Event Creator/Event Collaborator,Proposed,TR-24,, +Training Report,Digest,Session details not complete (POC),Email,TTA Hub [daily/weekly/monthly] digest: submit session details,,Regional POC,Proposed,TR-25,, +Training Report,Digest,No sessions created 20 days past event end date,Email,TTA Hub [daily/weekly/monthly] digest: Past due - Create a session,,Event Creator/Event Collaborator,Proposed,TR-26,, +Training Report,Digest,Event not completed 20 days past event end date,Email,TTA Hub [daily/weekly/monthly] digest: Past due - Complete event,,Event Creator,Proposed,TR-27,, +Other,System,Adds/opens a monitoring goal,In-app,New monitoring goals for recipients in your region are available.,View goals,TTACs & Managers,Draft,Misc-1a,Actionable Notifications email content,"Will trigger when the monitoring goal is added to the RTR for the first iteration and any subsequent re-opens. Original content: ""A monitoring goal for [Recipient name] is available in the TTA Hub.""" +Other,System,Adds/opens a monitoring goal,Email,New monitoring goals added for recipients in your region.,,TTACs & Managers,Draft,Misc-1b,,"We don't know what TTAC or Manager is associated with each recipient, do we? Phase 2? Original content: ""Monitoring goal added for [Recipient name]." +Other,System,New monitoring data was received.,In-app,New monitoring details for [Recipient name] are available.,View details,TTACs & Managers,Proposed,Misc-1a,,Replaced monitoring goal language in row 159 & 160 +Other,System,New monitoring data was received.,Email,New monitoring details were added for recipients in your region.,,TTACs & Managers,Proposed,Misc-1b,, +Other,Group Creator,"Adds a co-owner to ""my group""",In-app,[Creator's name] added you as a co-owner of the group: [Group name].,,Group Co-owner,Proposed,Misc-2a,, +Other,Group Creator,"Adds a co-owner to ""my group""",Email,Group [Group name]: Added as co-owner,,Group Co-owner,Proposed,Misc-2b,, +Other,Group Creator,"Shares their ""my group""",In-app,[Creator's name] shared the group: [Group name] with you.,,Group receiver,Proposed,Misc-3a,, +Other,Group Creator,"Shares their ""my group""",Email,Group [Group name]: shared with you,,Group receiver,Proposed,Misc-3b,, +System,System,TTA Hub planned outage,In-app,Planned outage: the TTA Hub will be closed for maintenance from [date through date].,,All roles,Proposed,Misc-4a,Actionable Notifications email content, +System,System,TTA Hub planned outage,Email,"TTA Hub scheduled outage [day, month, day]",,All roles,Proposed,Misc-4b,, +System,System,Unplanned TTA Hub outage,Email,The TTA Hub is temporarily down ,,All roles,Proposed,Misc-5a,,Do we have the ability to send this during an outage? Do we want this or keep our current process? +Communcation Log,Creator,Adds Other TTA staff to a Comm Log,In-app,[Creator's name] added you as TTA staff on their Communication Log for [Recipient name].,,TTA staff,Proposed,CL-1a,Actionable Notifications email content, +Communcation Log,Creator,Adds Other TTA staff to a Comm Log,Email,Communication Log R14-CL-12345: Added as TTA staff,,TTA staff,Proposed,CL-1b,, +Communcation Log,Creator,A Comm Log was entered for a recipient in a group,In-app,A Communication Log was entered for your recipient: [Recipient name] from the group: [My Group name].,,Program Specialist,Proposed,CL-2a,,Anyone with a group or just Program Specialists? +Communcation Log,Creator,A Comm Log was entered for a recipient in a group,Email,Communication Log R14-CL-12345: Entered for the recipient: [Recipient name] from group: [Group name],,Program Specialist,Proposed,CL-2b,, +Communcation Log,Digest,Added as TTA staff on a Comm log,Email,Subject: TTA Hub [daily/weekly/monthly] digest: added as TTA staff on a Communication log,,TTA staff,Proposed,CL-3,, +Communcation Log,Digest,"Comm log added for a recipient in ""My group""",Email,Subject: TTA Hub [daily/weekly/monthly] digest: Communication log added for a recipient in one of your groups,,Program Specialist,Proposed,CL-4,, \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index b93e99b51e..1c0aa2bc18 100644 --- a/src/constants.js +++ b/src/constants.js @@ -122,7 +122,133 @@ const USER_SETTINGS = { }; const NOTIFICATION_TYPES = { - ACTIVITY_REPORT_CHANGES_REQUESTED: USER_SETTINGS.EMAIL.KEYS.CHANGE_REQUESTED, + // ── Activity Report ────────────────────────────────────────────────────────── + // AR-1: Creator adds collaborator (existing) + ACTIVITY_REPORT_COLLABORATOR_ADDED: 'collaboratorAssigned', + // AR-6/8: Approver requests changes (existing) + ACTIVITY_REPORT_NEEDS_ACTION: 'changesRequested', + // AR-2/3: Creator or collaborator submits report for approval (existing) + ACTIVITY_REPORT_SUBMITTED: 'approverAssigned', + // AR-7/9: Approver approves report (existing) + ACTIVITY_REPORT_APPROVED: 'reportApproved', + // AR-13 digest: added as collaborator (existing) + ACTIVITY_REPORT_COLLABORATOR_DIGEST: 'collaboratorDigest', + // AR-11 digest: changes requested (existing) + ACTIVITY_REPORT_NEEDS_ACTION_DIGEST: 'changesRequestedDigest', + // AR-10 digest: reports for approval (existing) + ACTIVITY_REPORT_SUBMITTED_DIGEST: 'approverAssignedDigest', + // AR-12 digest: approved reports (existing) + ACTIVITY_REPORT_APPROVED_DIGEST: 'reportApprovedDigest', + // Recipient notified when their AR is approved (existing) + ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED: 'recipientReportApproved', + // Digest: recipient notified of approved ARs (existing) + ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED_DIGEST: 'recipientReportApprovedDigest', + // AR-4/5: Creator or collaborator re-submits a report for approval + ACTIVITY_REPORT_RESUBMITTED: 'activityReportResubmitted', + // AR-14 digest: creator submits AR where recipient is a collaborator + ACTIVITY_REPORT_SUBMITTED_TO_COLLABORATOR_DIGEST: 'activityReportSubmittedToCollaboratorDigest', + // AR-15 digest: collaborator submits AR, creator notified + ACTIVITY_REPORT_COLLABORATOR_SUBMITTED_DIGEST: 'activityReportCollaboratorSubmittedDigest', + + // ── Collaborative Report ────────────────────────────────────────────────────── + // CR-1: Creator adds collaborator + COLLAB_REPORT_COLLABORATOR_ADDED: 'collabReportCollaboratorAdded', + // CR-2/3: Creator or collaborator submits report for approval + COLLAB_REPORT_SUBMITTED: 'collabReportSubmitted', + // CR-4/5: Creator or collaborator re-submits report for approval + COLLAB_REPORT_RESUBMITTED: 'collabReportResubmitted', + // CR-6/8: Approver requests changes + COLLAB_REPORT_NEEDS_ACTION: 'collabReportNeedsAction', + // CR-7/9: Approver approves report + COLLAB_REPORT_APPROVED: 'collabReportApproved', + // CR-10 digest: reports for approval + COLLAB_REPORT_SUBMITTED_DIGEST: 'collabReportSubmittedDigest', + // CR-11/14 digest: changes requested + COLLAB_REPORT_NEEDS_ACTION_DIGEST: 'collabReportNeedsActionDigest', + // CR-12/15 digest: approved reports + COLLAB_REPORT_APPROVED_DIGEST: 'collabReportApprovedDigest', + // CR-13 digest: added as collaborator + COLLAB_REPORT_COLLABORATOR_DIGEST: 'collabReportCollaboratorDigest', + // CR-16 digest: creator submits CR where recipient is a collaborator + COLLAB_REPORT_SUBMITTED_TO_COLLABORATOR_DIGEST: 'collabReportSubmittedToCollaboratorDigest', + // CR-17 digest: collaborator submits CR, creator notified + COLLAB_REPORT_COLLABORATOR_SUBMITTED_DIGEST: 'collabReportCollaboratorSubmittedDigest', + + // ── Training Report ─────────────────────────────────────────────────────────── + // TR-1: Creator adds a regional POC + TRAINING_REPORT_POC_ADDED: 'trainingReportPocAdded', + // TR-2: Creator adds collaborator (existing) + TRAINING_REPORT_COLLABORATOR_ADDED: 'trainingReportCollaboratorAdded', + // Session created on an event (existing) + TRAINING_REPORT_SESSION_CREATED: 'trainingReportSessionCreated', + // TR-3: Creator submits a session for approval + TRAINING_REPORT_SESSION_SUBMITTED: 'trainingReportSessionSubmitted', + // TR-4: Approver requests changes to a session + TRAINING_REPORT_SESSION_NEEDS_ACTION: 'trainingReportSessionNeedsAction', + // TR-5/6: Creator or collaborator re-submits a session for review + TRAINING_REPORT_SESSION_RESUBMITTED: 'trainingReportSessionResubmitted', + // Event completed (existing) + TRAINING_REPORT_EVENT_COMPLETED: 'trainingReportEventCompleted', + // Cron umbrella for task-due reminders (existing) + TRAINING_REPORT_TASK_DUE: 'trainingReportTaskDueNotifications', + // Event imported from HSES (existing) + TRAINING_REPORT_EVENT_IMPORTED: 'trainingReportEventImported', + // TR-5/7 (Paused): event info missing 20 days past event start date + TRAINING_REPORT_EVENT_INFO_MISSING: 'trainingReportEventInfoMissing', + // TR-6/8 (Paused): event info still missing 20 days past previous reminder + TRAINING_REPORT_EVENT_INFO_PAST_DUE: 'trainingReportEventInfoPastDue', + // TR-9/10/11 (Paused): session info missing 20 days past session start date + TRAINING_REPORT_SESSION_INFO_MISSING: 'trainingReportSessionInfoMissing', + // TR-10c/12 (Paused): session info still missing 20 days past previous reminder + TRAINING_REPORT_SESSION_INFO_PAST_DUE: 'trainingReportSessionInfoPastDue', + // TR-13/15 (Paused): no sessions created 20 days past event end date + TRAINING_REPORT_NO_SESSIONS_CREATED: 'trainingReportNoSessionsCreated', + // TR-14/16 (Paused): still no sessions 20 days past previous reminder + TRAINING_REPORT_NO_SESSIONS_PAST_DUE: 'trainingReportNoSessionsPastDue', + // TR-17 (Paused): event not completed 20 days past event end date + TRAINING_REPORT_EVENT_NOT_COMPLETED: 'trainingReportEventNotCompleted', + // TR-18 (Paused): event not completed 20 days past previous reminder + TRAINING_REPORT_EVENT_NOT_COMPLETED_PAST_DUE: 'trainingReportEventNotCompletedPastDue', + // TR-19 digest: added as Event POC + TRAINING_REPORT_POC_ADDED_DIGEST: 'trainingReportPocAddedDigest', + // TR-20 digest: added as Event Collaborator + TRAINING_REPORT_COLLABORATOR_ADDED_DIGEST: 'trainingReportCollaboratorAddedDigest', + // TR-21 digest: session submitted for review + TRAINING_REPORT_SESSION_SUBMITTED_DIGEST: 'trainingReportSessionSubmittedDigest', + // TR-22 digest: session changes requested + TRAINING_REPORT_SESSION_NEEDS_ACTION_DIGEST: 'trainingReportSessionNeedsActionDigest', + // TR-23 digest: event details not complete + TRAINING_REPORT_EVENT_INFO_MISSING_DIGEST: 'trainingReportEventInfoMissingDigest', + // TR-24/25 digest: session details not complete + TRAINING_REPORT_SESSION_INFO_MISSING_DIGEST: 'trainingReportSessionInfoMissingDigest', + // TR-26 digest: no sessions created past event end date + TRAINING_REPORT_NO_SESSIONS_CREATED_DIGEST: 'trainingReportNoSessionsCreatedDigest', + // TR-27 digest: event not completed past event end date + TRAINING_REPORT_EVENT_NOT_COMPLETED_DIGEST: 'trainingReportEventNotCompletedDigest', + + // ── Communication Log ───────────────────────────────────────────────────────── + // CL-1: Creator adds TTA staff to a comm log + COMMUNICATION_LOG_TTA_STAFF_ADDED: 'communicationLogTtaStaffAdded', + // CL-2: Comm log entered for a recipient in a program specialist's group + COMMUNICATION_LOG_RECIPIENT_IN_GROUP: 'communicationLogRecipientInGroup', + // CL-3 digest: added as TTA staff on a comm log + COMMUNICATION_LOG_TTA_STAFF_ADDED_DIGEST: 'communicationLogTtaStaffAddedDigest', + // CL-4 digest: comm log added for a recipient in one of your groups + COMMUNICATION_LOG_RECIPIENT_IN_GROUP_DIGEST: 'communicationLogRecipientInGroupDigest', + + // ── Monitoring / Group / System ─────────────────────────────────────────────── + // Misc-1 (Draft): monitoring goal added/opened for recipients in a region + MONITORING_GOAL_ADDED: 'monitoringGoalAdded', + // Misc-1b: new monitoring data received for recipients in a region + MONITORING_DATA_RECEIVED: 'monitoringDataReceived', + // Misc-2: group co-owner added + GROUP_CO_OWNER_ADDED: 'groupCoOwnerAdded', + // Misc-3: group shared with user + GROUP_SHARED: 'groupShared', + // Misc-4: TTA Hub planned outage notification + SYSTEM_PLANNED_OUTAGE: 'systemPlannedOutage', + // Misc-5: TTA Hub unplanned outage notification + SYSTEM_UNPLANNED_OUTAGE: 'systemUnplannedOutage', }; const EMAIL_ACTIONS = { diff --git a/src/migrations/20260601000000-create-notifications-table.js b/src/migrations/20260601000000-create-notifications-table.js index 1b3af18ec0..f549136d29 100644 --- a/src/migrations/20260601000000-create-notifications-table.js +++ b/src/migrations/20260601000000-create-notifications-table.js @@ -1,5 +1,67 @@ const { prepMigration, removeTables } = require('../lib/migration'); +const NOTIFICATION_TYPES = [ + 'collaboratorAssigned', + 'changesRequested', + 'approverAssigned', + 'reportApproved', + 'collaboratorDigest', + 'changesRequestedDigest', + 'approverAssignedDigest', + 'reportApprovedDigest', + 'recipientReportApproved', + 'recipientReportApprovedDigest', + 'activityReportResubmitted', + 'activityReportSubmittedToCollaboratorDigest', + 'activityReportCollaboratorSubmittedDigest', + 'collabReportCollaboratorAdded', + 'collabReportSubmitted', + 'collabReportResubmitted', + 'collabReportNeedsAction', + 'collabReportApproved', + 'collabReportSubmittedDigest', + 'collabReportNeedsActionDigest', + 'collabReportApprovedDigest', + 'collabReportCollaboratorDigest', + 'collabReportSubmittedToCollaboratorDigest', + 'collabReportCollaboratorSubmittedDigest', + 'trainingReportPocAdded', + 'trainingReportCollaboratorAdded', + 'trainingReportSessionCreated', + 'trainingReportSessionSubmitted', + 'trainingReportSessionNeedsAction', + 'trainingReportSessionResubmitted', + 'trainingReportEventCompleted', + 'trainingReportTaskDueNotifications', + 'trainingReportEventImported', + 'trainingReportEventInfoMissing', + 'trainingReportEventInfoPastDue', + 'trainingReportSessionInfoMissing', + 'trainingReportSessionInfoPastDue', + 'trainingReportNoSessionsCreated', + 'trainingReportNoSessionsPastDue', + 'trainingReportEventNotCompleted', + 'trainingReportEventNotCompletedPastDue', + 'trainingReportPocAddedDigest', + 'trainingReportCollaboratorAddedDigest', + 'trainingReportSessionSubmittedDigest', + 'trainingReportSessionNeedsActionDigest', + 'trainingReportEventInfoMissingDigest', + 'trainingReportSessionInfoMissingDigest', + 'trainingReportNoSessionsCreatedDigest', + 'trainingReportEventNotCompletedDigest', + 'communicationLogTtaStaffAdded', + 'communicationLogRecipientInGroup', + 'communicationLogTtaStaffAddedDigest', + 'communicationLogRecipientInGroupDigest', + 'monitoringGoalAdded', + 'monitoringDataReceived', + 'groupCoOwnerAdded', + 'groupShared', + 'systemPlannedOutage', + 'systemUnplannedOutage', +]; + /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { @@ -28,7 +90,7 @@ module.exports = { allowNull: true, }, type: { - type: Sequelize.ENUM(['emailWhenChangeRequested']), + type: Sequelize.ENUM(NOTIFICATION_TYPES), allowNull: false, }, link: { diff --git a/src/models/tests/notification.test.js b/src/models/tests/notification.test.js index 7baac641dc..c62fd14498 100644 --- a/src/models/tests/notification.test.js +++ b/src/models/tests/notification.test.js @@ -27,7 +27,7 @@ describe('Notification model', () => { const notification = await Notification.create({ userId: user.id, entityId: 42, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, link: '/activity-reports/42/review', label: 'Activity Report #42', text: 'Changes were requested.', @@ -36,7 +36,7 @@ describe('Notification model', () => { expect(notification.id).toBeDefined(); expect(notification.userId).toEqual(user.id); expect(notification.entityId).toEqual(42); - expect(notification.type).toEqual(NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED); + expect(notification.type).toEqual(NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION); expect(notification.link).toEqual('/activity-reports/42/review'); expect(notification.label).toEqual('Activity Report #42'); expect(notification.text).toEqual('Changes were requested.'); @@ -47,7 +47,7 @@ describe('Notification model', () => { it('defaults archivedAt and viewedAt to null', async () => { const notification = await Notification.create({ userId: user.id, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, }); expect(notification.archivedAt).toBeNull(); @@ -59,7 +59,7 @@ describe('Notification model', () => { it('allows nullable userId (global notification)', async () => { const notification = await Notification.create({ userId: null, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, text: 'Global system notice.', }); @@ -72,7 +72,7 @@ describe('Notification model', () => { const notification = await Notification.create({ userId: user.id, entityId: null, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, }); expect(notification.entityId).toBeNull(); @@ -92,7 +92,7 @@ describe('Notification model', () => { it('sets timestamps automatically', async () => { const notification = await Notification.create({ userId: user.id, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, }); expect(notification.createdAt).toBeDefined(); @@ -104,7 +104,7 @@ describe('Notification model', () => { it('user association returns the related user', async () => { const notification = await Notification.create({ userId: user.id, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, }); const withUser = await Notification.findOne({ @@ -121,7 +121,7 @@ describe('Notification model', () => { it('persists archivedAt and viewedAt when set', async () => { const notification = await Notification.create({ userId: user.id, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, viewedAt: '2026-01-15', archivedAt: '2026-01-16', }); diff --git a/src/seeders/20260601000000-notifications.js b/src/seeders/20260601000000-notifications.js index 49e7b4658b..7cc043b4cf 100644 --- a/src/seeders/20260601000000-notifications.js +++ b/src/seeders/20260601000000-notifications.js @@ -6,7 +6,7 @@ const notifications = [ id: 30001, userId: 5, entityId: 1, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, link: '/activity-reports/1/review', label: 'Activity Report #1', text: 'Changes were requested on Activity Report #1.', @@ -18,7 +18,7 @@ const notifications = [ id: 30002, userId: 5, entityId: 2, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, link: '/activity-reports/2/review', label: 'Activity Report #2', text: 'Changes were requested on Activity Report #2.', @@ -32,7 +32,7 @@ const notifications = [ id: 30003, userId: 5, entityId: 3, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, link: '/activity-reports/3/review', label: 'Activity Report #3', text: 'Changes were requested on Activity Report #3.', @@ -47,7 +47,7 @@ const notifications = [ id: 30004, userId: null, entityId: null, - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_CHANGES_REQUESTED, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, link: '/notifications', label: 'System Notice', text: 'A system-wide notice for all users.', From 6d9000f6bb72c5fc0d209211714136a2b6f2a69d Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Tue, 2 Jun 2026 10:49:20 -0400 Subject: [PATCH 05/23] Scopes, work in progress --- src/scopes/notifications/createdAt.ts | 26 +++++ src/scopes/notifications/index.ts | 19 +++ src/scopes/notifications/notificationType.ts | 116 +++++++++++++++++++ src/scopes/notifications/userId.ts | 12 ++ src/scopes/utils.ts | 4 + 5 files changed, 177 insertions(+) create mode 100644 src/scopes/notifications/createdAt.ts create mode 100644 src/scopes/notifications/index.ts create mode 100644 src/scopes/notifications/notificationType.ts create mode 100644 src/scopes/notifications/userId.ts diff --git a/src/scopes/notifications/createdAt.ts b/src/scopes/notifications/createdAt.ts new file mode 100644 index 0000000000..74edb175f1 --- /dev/null +++ b/src/scopes/notifications/createdAt.ts @@ -0,0 +1,26 @@ +import { Op } from 'sequelize'; +import { compareDate, withinDateRange } from '../utils'; + +export function beforeCreateDate(date: string[]) { + return { + [Op.and]: { + [Op.or]: compareDate(date, 'createdAt', Op.lt), + }, + }; +} + +export function afterCreateDate(date: string[]) { + return { + [Op.and]: { + [Op.or]: compareDate(date, 'createdAt', Op.gt), + }, + }; +} + +export function withinCreateDate(dates: string[]) { + return { + [Op.and]: { + [Op.or]: withinDateRange(dates, 'createdAt'), + }, + }; +} diff --git a/src/scopes/notifications/index.ts b/src/scopes/notifications/index.ts new file mode 100644 index 0000000000..b8d02e13c9 --- /dev/null +++ b/src/scopes/notifications/index.ts @@ -0,0 +1,19 @@ +import { createFiltersToScopes } from '../utils'; +import { afterCreateDate, beforeCreateDate, withinCreateDate } from './createdAt'; +import { withUserId } from './userId'; + +export const topicToQuery = { + createdAt: { + bef: (query: string[]) => beforeCreateDate(query), + aft: (query: string[]) => afterCreateDate(query), + win: (query: string[]) => withinCreateDate(query), + in: (query: string[]) => withinCreateDate(query), + }, + userId: { + in: (query: string[]) => withUserId(query), + }, +}; + +export function notificationFiltersToScopes(filters, options, userId, validTopics) { + return createFiltersToScopes(filters, topicToQuery, options, userId, validTopics); +} diff --git a/src/scopes/notifications/notificationType.ts b/src/scopes/notifications/notificationType.ts new file mode 100644 index 0000000000..06afa7494e --- /dev/null +++ b/src/scopes/notifications/notificationType.ts @@ -0,0 +1,116 @@ +import { Op } from 'sequelize'; +import { NOTIFICATION_TYPES } from '../../constants'; +import { filterStringArrayToNumberArray } from '../utils'; + +const VALID_TYPES = ['activityReport', 'collabReport', 'trainingReport', 'systemRelated', 'other']; + +const NOTIFICATION_TYPE_MAP = { + [NOTIFICATION_TYPES.ACTIVITY_REPORT_COLLABORATOR_ADDED]: 'activityReport', + [NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION]: 'activityReport', + [NOTIFICATION_TYPES.ACTIVITY_REPORT_SUBMITTED]: 'activityReport', + [NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED]: 'activityReport', + [NOTIFICATION_TYPES.ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED]: 'activityReport', + [NOTIFICATION_TYPES.ACTIVITY_REPORT_RESUBMITTED]: 'activityReport', + + [NOTIFICATION_TYPES.COLLAB_REPORT_COLLABORATOR_ADDED]: 'collabReport', + [NOTIFICATION_TYPES.COLLAB_REPORT_SUBMITTED]: 'collabReport', + [NOTIFICATION_TYPES.COLLAB_REPORT_RESUBMITTED]: 'collabReport', + [NOTIFICATION_TYPES.COLLAB_REPORT_NEEDS_ACTION]: 'collabReport', + [NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED]: 'collabReport', + + // ── Training Report ─────────────────────────────────────────────────────────── + // TR-1: Creator adds a regional POC + TRAINING_REPORT_POC_ADDED: 'trainingReportPocAdded', + // TR-2: Creator adds collaborator (existing) + TRAINING_REPORT_COLLABORATOR_ADDED: 'trainingReportCollaboratorAdded', + // Session created on an event (existing) + TRAINING_REPORT_SESSION_CREATED: 'trainingReportSessionCreated', + // TR-3: Creator submits a session for approval + TRAINING_REPORT_SESSION_SUBMITTED: 'trainingReportSessionSubmitted', + // TR-4: Approver requests changes to a session + TRAINING_REPORT_SESSION_NEEDS_ACTION: 'trainingReportSessionNeedsAction', + // TR-5/6: Creator or collaborator re-submits a session for review + TRAINING_REPORT_SESSION_RESUBMITTED: 'trainingReportSessionResubmitted', + // Event completed (existing) + TRAINING_REPORT_EVENT_COMPLETED: 'trainingReportEventCompleted', + // Cron umbrella for task-due reminders (existing) + TRAINING_REPORT_TASK_DUE: 'trainingReportTaskDueNotifications', + // Event imported from HSES (existing) + TRAINING_REPORT_EVENT_IMPORTED: 'trainingReportEventImported', + // TR-5/7 (Paused): event info missing 20 days past event start date + TRAINING_REPORT_EVENT_INFO_MISSING: 'trainingReportEventInfoMissing', + // TR-6/8 (Paused): event info still missing 20 days past previous reminder + TRAINING_REPORT_EVENT_INFO_PAST_DUE: 'trainingReportEventInfoPastDue', + // TR-9/10/11 (Paused): session info missing 20 days past session start date + TRAINING_REPORT_SESSION_INFO_MISSING: 'trainingReportSessionInfoMissing', + // TR-10c/12 (Paused): session info still missing 20 days past previous reminder + TRAINING_REPORT_SESSION_INFO_PAST_DUE: 'trainingReportSessionInfoPastDue', + // TR-13/15 (Paused): no sessions created 20 days past event end date + TRAINING_REPORT_NO_SESSIONS_CREATED: 'trainingReportNoSessionsCreated', + // TR-14/16 (Paused): still no sessions 20 days past previous reminder + TRAINING_REPORT_NO_SESSIONS_PAST_DUE: 'trainingReportNoSessionsPastDue', + // TR-17 (Paused): event not completed 20 days past event end date + TRAINING_REPORT_EVENT_NOT_COMPLETED: 'trainingReportEventNotCompleted', + // TR-18 (Paused): event not completed 20 days past previous reminder + TRAINING_REPORT_EVENT_NOT_COMPLETED_PAST_DUE: 'trainingReportEventNotCompletedPastDue', + // TR-19 digest: added as Event POC + TRAINING_REPORT_POC_ADDED_DIGEST: 'trainingReportPocAddedDigest', + // TR-20 digest: added as Event Collaborator + TRAINING_REPORT_COLLABORATOR_ADDED_DIGEST: 'trainingReportCollaboratorAddedDigest', + // TR-21 digest: session submitted for review + TRAINING_REPORT_SESSION_SUBMITTED_DIGEST: 'trainingReportSessionSubmittedDigest', + // TR-22 digest: session changes requested + TRAINING_REPORT_SESSION_NEEDS_ACTION_DIGEST: 'trainingReportSessionNeedsActionDigest', + // TR-23 digest: event details not complete + TRAINING_REPORT_EVENT_INFO_MISSING_DIGEST: 'trainingReportEventInfoMissingDigest', + // TR-24/25 digest: session details not complete + TRAINING_REPORT_SESSION_INFO_MISSING_DIGEST: 'trainingReportSessionInfoMissingDigest', + // TR-26 digest: no sessions created past event end date + TRAINING_REPORT_NO_SESSIONS_CREATED_DIGEST: 'trainingReportNoSessionsCreatedDigest', + // TR-27 digest: event not completed past event end date + TRAINING_REPORT_EVENT_NOT_COMPLETED_DIGEST: 'trainingReportEventNotCompletedDigest', + + // ── Communication Log ───────────────────────────────────────────────────────── + // CL-1: Creator adds TTA staff to a comm log + COMMUNICATION_LOG_TTA_STAFF_ADDED: 'communicationLogTtaStaffAdded', + // CL-2: Comm log entered for a recipient in a program specialist's group + COMMUNICATION_LOG_RECIPIENT_IN_GROUP: 'communicationLogRecipientInGroup', + // CL-3 digest: added as TTA staff on a comm log + COMMUNICATION_LOG_TTA_STAFF_ADDED_DIGEST: 'communicationLogTtaStaffAddedDigest', + // CL-4 digest: comm log added for a recipient in one of your groups + COMMUNICATION_LOG_RECIPIENT_IN_GROUP_DIGEST: 'communicationLogRecipientInGroupDigest', + + // ── Monitoring / Group / System ─────────────────────────────────────────────── + // Misc-1 (Draft): monitoring goal added/opened for recipients in a region + MONITORING_GOAL_ADDED: 'monitoringGoalAdded', + // Misc-1b: new monitoring data received for recipients in a region + MONITORING_DATA_RECEIVED: 'monitoringDataReceived', + // Misc-2: group co-owner added + GROUP_CO_OWNER_ADDED: 'groupCoOwnerAdded', + // Misc-3: group shared with user + GROUP_SHARED: 'groupShared', + // Misc-4: TTA Hub planned outage notification + SYSTEM_PLANNED_OUTAGE: 'systemPlannedOutage', + // Misc-5: TTA Hub unplanned outage notification + SYSTEM_UNPLANNED_OUTAGE: 'systemUnplannedOutage', +}; + +export function withNotificationType(notificationTypes: string[]) { + return { + where: { + notificationType: { + [Op.in]: filterStringArrayToNumberArray(notificationTypes), + }, + }, + }; +} + +export function withoutNotificationType(notificationTypes: string[]) { + return { + where: { + notificationType: { + [Op.notIn]: filterStringArrayToNumberArray(notificationTypes), + }, + }, + }; +} diff --git a/src/scopes/notifications/userId.ts b/src/scopes/notifications/userId.ts new file mode 100644 index 0000000000..02678a306b --- /dev/null +++ b/src/scopes/notifications/userId.ts @@ -0,0 +1,12 @@ +import { Op } from 'sequelize'; +import { filterStringArrayToNumberArray } from '../utils'; + +export function withUserId(userIds: string[]) { + return { + where: { + userId: { + [Op.in]: filterStringArrayToNumberArray(userIds), + }, + }, + }; +} diff --git a/src/scopes/utils.ts b/src/scopes/utils.ts index 2a9e273b4e..4dd1c46329 100644 --- a/src/scopes/utils.ts +++ b/src/scopes/utils.ts @@ -268,3 +268,7 @@ export function filterToAllowedProgramTypes(programTypes: string[]): string[] { .flat(); return Array.from(new Set(allowedTypes)); } + +export function filterStringArrayToNumberArray(arr: string[]): number[] { + return arr.map((item) => Number(item)).filter((num) => !Number.isNaN(num)); +} From a4d249b306aca1a4a60c3b76431a304f1b65ac60 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Tue, 2 Jun 2026 10:52:05 -0400 Subject: [PATCH 06/23] Remove digest from notifications enum --- src/constants.js | 46 ------------------- ...260601000000-create-notifications-table.js | 23 ---------- 2 files changed, 69 deletions(-) diff --git a/src/constants.js b/src/constants.js index 1c0aa2bc18..1c326bbd0b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -131,24 +131,10 @@ const NOTIFICATION_TYPES = { ACTIVITY_REPORT_SUBMITTED: 'approverAssigned', // AR-7/9: Approver approves report (existing) ACTIVITY_REPORT_APPROVED: 'reportApproved', - // AR-13 digest: added as collaborator (existing) - ACTIVITY_REPORT_COLLABORATOR_DIGEST: 'collaboratorDigest', - // AR-11 digest: changes requested (existing) - ACTIVITY_REPORT_NEEDS_ACTION_DIGEST: 'changesRequestedDigest', - // AR-10 digest: reports for approval (existing) - ACTIVITY_REPORT_SUBMITTED_DIGEST: 'approverAssignedDigest', - // AR-12 digest: approved reports (existing) - ACTIVITY_REPORT_APPROVED_DIGEST: 'reportApprovedDigest', // Recipient notified when their AR is approved (existing) ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED: 'recipientReportApproved', - // Digest: recipient notified of approved ARs (existing) - ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED_DIGEST: 'recipientReportApprovedDigest', // AR-4/5: Creator or collaborator re-submits a report for approval ACTIVITY_REPORT_RESUBMITTED: 'activityReportResubmitted', - // AR-14 digest: creator submits AR where recipient is a collaborator - ACTIVITY_REPORT_SUBMITTED_TO_COLLABORATOR_DIGEST: 'activityReportSubmittedToCollaboratorDigest', - // AR-15 digest: collaborator submits AR, creator notified - ACTIVITY_REPORT_COLLABORATOR_SUBMITTED_DIGEST: 'activityReportCollaboratorSubmittedDigest', // ── Collaborative Report ────────────────────────────────────────────────────── // CR-1: Creator adds collaborator @@ -161,18 +147,6 @@ const NOTIFICATION_TYPES = { COLLAB_REPORT_NEEDS_ACTION: 'collabReportNeedsAction', // CR-7/9: Approver approves report COLLAB_REPORT_APPROVED: 'collabReportApproved', - // CR-10 digest: reports for approval - COLLAB_REPORT_SUBMITTED_DIGEST: 'collabReportSubmittedDigest', - // CR-11/14 digest: changes requested - COLLAB_REPORT_NEEDS_ACTION_DIGEST: 'collabReportNeedsActionDigest', - // CR-12/15 digest: approved reports - COLLAB_REPORT_APPROVED_DIGEST: 'collabReportApprovedDigest', - // CR-13 digest: added as collaborator - COLLAB_REPORT_COLLABORATOR_DIGEST: 'collabReportCollaboratorDigest', - // CR-16 digest: creator submits CR where recipient is a collaborator - COLLAB_REPORT_SUBMITTED_TO_COLLABORATOR_DIGEST: 'collabReportSubmittedToCollaboratorDigest', - // CR-17 digest: collaborator submits CR, creator notified - COLLAB_REPORT_COLLABORATOR_SUBMITTED_DIGEST: 'collabReportCollaboratorSubmittedDigest', // ── Training Report ─────────────────────────────────────────────────────────── // TR-1: Creator adds a regional POC @@ -209,32 +183,12 @@ const NOTIFICATION_TYPES = { TRAINING_REPORT_EVENT_NOT_COMPLETED: 'trainingReportEventNotCompleted', // TR-18 (Paused): event not completed 20 days past previous reminder TRAINING_REPORT_EVENT_NOT_COMPLETED_PAST_DUE: 'trainingReportEventNotCompletedPastDue', - // TR-19 digest: added as Event POC - TRAINING_REPORT_POC_ADDED_DIGEST: 'trainingReportPocAddedDigest', - // TR-20 digest: added as Event Collaborator - TRAINING_REPORT_COLLABORATOR_ADDED_DIGEST: 'trainingReportCollaboratorAddedDigest', - // TR-21 digest: session submitted for review - TRAINING_REPORT_SESSION_SUBMITTED_DIGEST: 'trainingReportSessionSubmittedDigest', - // TR-22 digest: session changes requested - TRAINING_REPORT_SESSION_NEEDS_ACTION_DIGEST: 'trainingReportSessionNeedsActionDigest', - // TR-23 digest: event details not complete - TRAINING_REPORT_EVENT_INFO_MISSING_DIGEST: 'trainingReportEventInfoMissingDigest', - // TR-24/25 digest: session details not complete - TRAINING_REPORT_SESSION_INFO_MISSING_DIGEST: 'trainingReportSessionInfoMissingDigest', - // TR-26 digest: no sessions created past event end date - TRAINING_REPORT_NO_SESSIONS_CREATED_DIGEST: 'trainingReportNoSessionsCreatedDigest', - // TR-27 digest: event not completed past event end date - TRAINING_REPORT_EVENT_NOT_COMPLETED_DIGEST: 'trainingReportEventNotCompletedDigest', // ── Communication Log ───────────────────────────────────────────────────────── // CL-1: Creator adds TTA staff to a comm log COMMUNICATION_LOG_TTA_STAFF_ADDED: 'communicationLogTtaStaffAdded', // CL-2: Comm log entered for a recipient in a program specialist's group COMMUNICATION_LOG_RECIPIENT_IN_GROUP: 'communicationLogRecipientInGroup', - // CL-3 digest: added as TTA staff on a comm log - COMMUNICATION_LOG_TTA_STAFF_ADDED_DIGEST: 'communicationLogTtaStaffAddedDigest', - // CL-4 digest: comm log added for a recipient in one of your groups - COMMUNICATION_LOG_RECIPIENT_IN_GROUP_DIGEST: 'communicationLogRecipientInGroupDigest', // ── Monitoring / Group / System ─────────────────────────────────────────────── // Misc-1 (Draft): monitoring goal added/opened for recipients in a region diff --git a/src/migrations/20260601000000-create-notifications-table.js b/src/migrations/20260601000000-create-notifications-table.js index f549136d29..9c2c96648d 100644 --- a/src/migrations/20260601000000-create-notifications-table.js +++ b/src/migrations/20260601000000-create-notifications-table.js @@ -5,26 +5,13 @@ const NOTIFICATION_TYPES = [ 'changesRequested', 'approverAssigned', 'reportApproved', - 'collaboratorDigest', - 'changesRequestedDigest', - 'approverAssignedDigest', - 'reportApprovedDigest', 'recipientReportApproved', - 'recipientReportApprovedDigest', 'activityReportResubmitted', - 'activityReportSubmittedToCollaboratorDigest', - 'activityReportCollaboratorSubmittedDigest', 'collabReportCollaboratorAdded', 'collabReportSubmitted', 'collabReportResubmitted', 'collabReportNeedsAction', 'collabReportApproved', - 'collabReportSubmittedDigest', - 'collabReportNeedsActionDigest', - 'collabReportApprovedDigest', - 'collabReportCollaboratorDigest', - 'collabReportSubmittedToCollaboratorDigest', - 'collabReportCollaboratorSubmittedDigest', 'trainingReportPocAdded', 'trainingReportCollaboratorAdded', 'trainingReportSessionCreated', @@ -42,18 +29,8 @@ const NOTIFICATION_TYPES = [ 'trainingReportNoSessionsPastDue', 'trainingReportEventNotCompleted', 'trainingReportEventNotCompletedPastDue', - 'trainingReportPocAddedDigest', - 'trainingReportCollaboratorAddedDigest', - 'trainingReportSessionSubmittedDigest', - 'trainingReportSessionNeedsActionDigest', - 'trainingReportEventInfoMissingDigest', - 'trainingReportSessionInfoMissingDigest', - 'trainingReportNoSessionsCreatedDigest', - 'trainingReportEventNotCompletedDigest', 'communicationLogTtaStaffAdded', 'communicationLogRecipientInGroup', - 'communicationLogTtaStaffAddedDigest', - 'communicationLogRecipientInGroupDigest', 'monitoringGoalAdded', 'monitoringDataReceived', 'groupCoOwnerAdded', From c2586712fd64a23f04cbcbe11206f2d9f72792d8 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Tue, 2 Jun 2026 11:14:16 -0400 Subject: [PATCH 07/23] Add notification scopes --- src/scopes/notifications/createdAt.test.ts | 116 ++++++++++++ .../notifications/notificationType.test.ts | 164 +++++++++++++++++ src/scopes/notifications/notificationType.ts | 166 +++++++----------- src/scopes/notifications/userId.test.ts | 117 ++++++++++++ src/scopes/notifications/userId.ts | 6 +- 5 files changed, 467 insertions(+), 102 deletions(-) create mode 100644 src/scopes/notifications/createdAt.test.ts create mode 100644 src/scopes/notifications/notificationType.test.ts create mode 100644 src/scopes/notifications/userId.test.ts diff --git a/src/scopes/notifications/createdAt.test.ts b/src/scopes/notifications/createdAt.test.ts new file mode 100644 index 0000000000..708c973e70 --- /dev/null +++ b/src/scopes/notifications/createdAt.test.ts @@ -0,0 +1,116 @@ +import faker from '@faker-js/faker'; +import { Op } from 'sequelize'; +import { NOTIFICATION_TYPES } from '../../constants'; +import db from '../../models'; +import { afterCreateDate, beforeCreateDate, withinCreateDate } from './createdAt'; + +const { Notification, User } = db; + +describe('notifications/createdAt scopes', () => { + let user; + let earlyNotification; + let middleNotification; + let lateNotification; + let possibleIds: number[]; + + beforeAll(async () => { + user = await User.create({ + id: faker.datatype.number({ min: 200000, max: 299999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + email: faker.internet.email(), + role: ['Specialist'], + lastLogin: new Date(), + }); + + earlyNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED, + createdAt: new Date('2019-06-15T12:00:00Z'), + updatedAt: new Date('2019-06-15T12:00:00Z'), + }); + + middleNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_SUBMITTED, + createdAt: new Date('2021-06-15T12:00:00Z'), + updatedAt: new Date('2021-06-15T12:00:00Z'), + }); + + lateNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + createdAt: new Date('2023-06-15T12:00:00Z'), + updatedAt: new Date('2023-06-15T12:00:00Z'), + }); + + possibleIds = [earlyNotification.id, middleNotification.id, lateNotification.id]; + }); + + afterAll(async () => { + await Notification.destroy({ where: { id: possibleIds } }); + await User.destroy({ where: { id: user.id } }); + await db.sequelize.close(); + }); + + describe('beforeCreateDate', () => { + it('returns notifications with createdAt before the given date', async () => { + const scope = beforeCreateDate(['2020-12-31']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: possibleIds }] }, + }); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining([earlyNotification.id])); + expect(found.map((n) => n.id)).not.toContain(middleNotification.id); + expect(found.map((n) => n.id)).not.toContain(lateNotification.id); + }); + + it('returns no notifications when all records are after the given date', async () => { + const scope = beforeCreateDate(['2018-01-01']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: possibleIds }] }, + }); + expect(found).toHaveLength(0); + }); + }); + + describe('afterCreateDate', () => { + it('returns notifications with createdAt after the given date', async () => { + const scope = afterCreateDate(['2022-01-01']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: possibleIds }] }, + }); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining([lateNotification.id])); + expect(found.map((n) => n.id)).not.toContain(earlyNotification.id); + expect(found.map((n) => n.id)).not.toContain(middleNotification.id); + }); + + it('returns no notifications when all records are before the given date', async () => { + const scope = afterCreateDate(['2025-01-01']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: possibleIds }] }, + }); + expect(found).toHaveLength(0); + }); + }); + + describe('withinCreateDate', () => { + it('returns notifications with createdAt between two dates', async () => { + const scope = withinCreateDate(['2020/01/01-2022/12/31']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: possibleIds }] }, + }); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining([middleNotification.id])); + expect(found.map((n) => n.id)).not.toContain(earlyNotification.id); + expect(found.map((n) => n.id)).not.toContain(lateNotification.id); + }); + + it('returns all notifications when range spans all records', async () => { + const scope = withinCreateDate(['2018/01/01-2024/12/31']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: possibleIds }] }, + }); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining(possibleIds)); + }); + }); +}); diff --git a/src/scopes/notifications/notificationType.test.ts b/src/scopes/notifications/notificationType.test.ts new file mode 100644 index 0000000000..126b565183 --- /dev/null +++ b/src/scopes/notifications/notificationType.test.ts @@ -0,0 +1,164 @@ +import { Op } from 'sequelize'; +import { NOTIFICATION_TYPES } from '../../constants'; +import { + NOTIFICATION_TYPE_MAP, + withNotificationType, + withoutNotificationType, +} from './notificationType'; + +describe('notifications/notificationType scope', () => { + describe('withNotificationType', () => { + it('maps activityReport to all activity report notification types', () => { + const scope = withNotificationType(['activityReport']); + const types = scope.notificationType[Op.in]; + + expect(types).toEqual( + expect.arrayContaining([ + 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, + ]) + ); + expect(types).toHaveLength(NOTIFICATION_TYPE_MAP.activityReport.length); + }); + + it('maps collabReport to all collaborative report notification types', () => { + const scope = withNotificationType(['collabReport']); + const types = scope.notificationType[Op.in]; + + expect(types).toEqual( + expect.arrayContaining([ + 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, + ]) + ); + expect(types).toHaveLength(NOTIFICATION_TYPE_MAP.collabReport.length); + }); + + it('maps trainingReport to all training report notification types', () => { + const scope = withNotificationType(['trainingReport']); + const types = scope.notificationType[Op.in]; + + expect(types).toEqual( + expect.arrayContaining([ + 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, + ]) + ); + }); + + it('maps systemRelated to system outage notification types', () => { + const scope = withNotificationType(['systemRelated']); + const types = scope.notificationType[Op.in]; + + expect(types).toEqual( + expect.arrayContaining([ + NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + NOTIFICATION_TYPES.SYSTEM_UNPLANNED_OUTAGE, + ]) + ); + expect(types).toHaveLength(NOTIFICATION_TYPE_MAP.systemRelated.length); + }); + + it('maps other to communication log, monitoring, and group notification types', () => { + const scope = withNotificationType(['other']); + const types = scope.notificationType[Op.in]; + + expect(types).toEqual( + expect.arrayContaining([ + NOTIFICATION_TYPES.COMMUNICATION_LOG_TTA_STAFF_ADDED, + NOTIFICATION_TYPES.COMMUNICATION_LOG_RECIPIENT_IN_GROUP, + NOTIFICATION_TYPES.MONITORING_GOAL_ADDED, + NOTIFICATION_TYPES.MONITORING_DATA_RECEIVED, + NOTIFICATION_TYPES.GROUP_CO_OWNER_ADDED, + NOTIFICATION_TYPES.GROUP_SHARED, + ]) + ); + }); + + it('returns a deduplicated union when multiple valid categories are provided', () => { + const combined = withNotificationType(['activityReport', 'systemRelated']); + const singleAR = withNotificationType(['activityReport']); + const singleSR = withNotificationType(['systemRelated']); + + const combinedTypes = combined.notificationType[Op.in]; + expect(combinedTypes).toHaveLength( + singleAR.notificationType[Op.in].length + singleSR.notificationType[Op.in].length + ); + }); + + it('returns an empty Op.in array for an invalid category', () => { + const scope = withNotificationType(['invalidCategory']); + expect(scope.notificationType[Op.in]).toHaveLength(0); + }); + + it('returns an empty Op.in array for an empty array', () => { + const scope = withNotificationType([]); + expect(scope.notificationType[Op.in]).toHaveLength(0); + }); + }); + + describe('withoutNotificationType', () => { + it('maps systemRelated to an Op.notIn clause with system outage types', () => { + const scope = withoutNotificationType(['systemRelated']); + const types = scope.notificationType[Op.notIn]; + + expect(types).toEqual( + expect.arrayContaining([ + NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + NOTIFICATION_TYPES.SYSTEM_UNPLANNED_OUTAGE, + ]) + ); + expect(types).toHaveLength(NOTIFICATION_TYPE_MAP.systemRelated.length); + }); + + it('excludes types for all provided categories', () => { + const scope = withoutNotificationType(['activityReport', 'collabReport']); + const types = scope.notificationType[Op.notIn]; + + expect(types).toEqual( + expect.arrayContaining([ + NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED, + NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED, + ]) + ); + }); + + it('returns an empty Op.notIn array for an invalid category', () => { + const scope = withoutNotificationType(['invalidCategory']); + expect(scope.notificationType[Op.notIn]).toHaveLength(0); + }); + }); + + describe('NOTIFICATION_TYPE_MAP coverage', () => { + it('includes every NOTIFICATION_TYPES value in at least one bucket', () => { + const allMappedValues = new Set(Object.values(NOTIFICATION_TYPE_MAP).flat()); + const allNotificationTypeValues = Object.values(NOTIFICATION_TYPES) as string[]; + + const missing = allNotificationTypeValues.filter((v) => !allMappedValues.has(v)); + + expect(missing).toHaveLength(0); + }); + }); +}); diff --git a/src/scopes/notifications/notificationType.ts b/src/scopes/notifications/notificationType.ts index 06afa7494e..29c9329fcf 100644 --- a/src/scopes/notifications/notificationType.ts +++ b/src/scopes/notifications/notificationType.ts @@ -1,116 +1,86 @@ import { Op } from 'sequelize'; import { NOTIFICATION_TYPES } from '../../constants'; -import { filterStringArrayToNumberArray } from '../utils'; const VALID_TYPES = ['activityReport', 'collabReport', 'trainingReport', 'systemRelated', 'other']; -const NOTIFICATION_TYPE_MAP = { - [NOTIFICATION_TYPES.ACTIVITY_REPORT_COLLABORATOR_ADDED]: 'activityReport', - [NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION]: 'activityReport', - [NOTIFICATION_TYPES.ACTIVITY_REPORT_SUBMITTED]: 'activityReport', - [NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED]: 'activityReport', - [NOTIFICATION_TYPES.ACTIVITY_REPORT_RECIPIENT_REPORT_APPROVED]: 'activityReport', - [NOTIFICATION_TYPES.ACTIVITY_REPORT_RESUBMITTED]: 'activityReport', - - [NOTIFICATION_TYPES.COLLAB_REPORT_COLLABORATOR_ADDED]: 'collabReport', - [NOTIFICATION_TYPES.COLLAB_REPORT_SUBMITTED]: 'collabReport', - [NOTIFICATION_TYPES.COLLAB_REPORT_RESUBMITTED]: 'collabReport', - [NOTIFICATION_TYPES.COLLAB_REPORT_NEEDS_ACTION]: 'collabReport', - [NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED]: 'collabReport', - - // ── Training Report ─────────────────────────────────────────────────────────── - // TR-1: Creator adds a regional POC - TRAINING_REPORT_POC_ADDED: 'trainingReportPocAdded', - // TR-2: Creator adds collaborator (existing) - TRAINING_REPORT_COLLABORATOR_ADDED: 'trainingReportCollaboratorAdded', - // Session created on an event (existing) - TRAINING_REPORT_SESSION_CREATED: 'trainingReportSessionCreated', - // TR-3: Creator submits a session for approval - TRAINING_REPORT_SESSION_SUBMITTED: 'trainingReportSessionSubmitted', - // TR-4: Approver requests changes to a session - TRAINING_REPORT_SESSION_NEEDS_ACTION: 'trainingReportSessionNeedsAction', - // TR-5/6: Creator or collaborator re-submits a session for review - TRAINING_REPORT_SESSION_RESUBMITTED: 'trainingReportSessionResubmitted', - // Event completed (existing) - TRAINING_REPORT_EVENT_COMPLETED: 'trainingReportEventCompleted', - // Cron umbrella for task-due reminders (existing) - TRAINING_REPORT_TASK_DUE: 'trainingReportTaskDueNotifications', - // Event imported from HSES (existing) - TRAINING_REPORT_EVENT_IMPORTED: 'trainingReportEventImported', - // TR-5/7 (Paused): event info missing 20 days past event start date - TRAINING_REPORT_EVENT_INFO_MISSING: 'trainingReportEventInfoMissing', - // TR-6/8 (Paused): event info still missing 20 days past previous reminder - TRAINING_REPORT_EVENT_INFO_PAST_DUE: 'trainingReportEventInfoPastDue', - // TR-9/10/11 (Paused): session info missing 20 days past session start date - TRAINING_REPORT_SESSION_INFO_MISSING: 'trainingReportSessionInfoMissing', - // TR-10c/12 (Paused): session info still missing 20 days past previous reminder - TRAINING_REPORT_SESSION_INFO_PAST_DUE: 'trainingReportSessionInfoPastDue', - // TR-13/15 (Paused): no sessions created 20 days past event end date - TRAINING_REPORT_NO_SESSIONS_CREATED: 'trainingReportNoSessionsCreated', - // TR-14/16 (Paused): still no sessions 20 days past previous reminder - TRAINING_REPORT_NO_SESSIONS_PAST_DUE: 'trainingReportNoSessionsPastDue', - // TR-17 (Paused): event not completed 20 days past event end date - TRAINING_REPORT_EVENT_NOT_COMPLETED: 'trainingReportEventNotCompleted', - // TR-18 (Paused): event not completed 20 days past previous reminder - TRAINING_REPORT_EVENT_NOT_COMPLETED_PAST_DUE: 'trainingReportEventNotCompletedPastDue', - // TR-19 digest: added as Event POC - TRAINING_REPORT_POC_ADDED_DIGEST: 'trainingReportPocAddedDigest', - // TR-20 digest: added as Event Collaborator - TRAINING_REPORT_COLLABORATOR_ADDED_DIGEST: 'trainingReportCollaboratorAddedDigest', - // TR-21 digest: session submitted for review - TRAINING_REPORT_SESSION_SUBMITTED_DIGEST: 'trainingReportSessionSubmittedDigest', - // TR-22 digest: session changes requested - TRAINING_REPORT_SESSION_NEEDS_ACTION_DIGEST: 'trainingReportSessionNeedsActionDigest', - // TR-23 digest: event details not complete - TRAINING_REPORT_EVENT_INFO_MISSING_DIGEST: 'trainingReportEventInfoMissingDigest', - // TR-24/25 digest: session details not complete - TRAINING_REPORT_SESSION_INFO_MISSING_DIGEST: 'trainingReportSessionInfoMissingDigest', - // TR-26 digest: no sessions created past event end date - TRAINING_REPORT_NO_SESSIONS_CREATED_DIGEST: 'trainingReportNoSessionsCreatedDigest', - // TR-27 digest: event not completed past event end date - TRAINING_REPORT_EVENT_NOT_COMPLETED_DIGEST: 'trainingReportEventNotCompletedDigest', - - // ── Communication Log ───────────────────────────────────────────────────────── - // CL-1: Creator adds TTA staff to a comm log - COMMUNICATION_LOG_TTA_STAFF_ADDED: 'communicationLogTtaStaffAdded', - // CL-2: Comm log entered for a recipient in a program specialist's group - COMMUNICATION_LOG_RECIPIENT_IN_GROUP: 'communicationLogRecipientInGroup', - // CL-3 digest: added as TTA staff on a comm log - COMMUNICATION_LOG_TTA_STAFF_ADDED_DIGEST: 'communicationLogTtaStaffAddedDigest', - // CL-4 digest: comm log added for a recipient in one of your groups - COMMUNICATION_LOG_RECIPIENT_IN_GROUP_DIGEST: 'communicationLogRecipientInGroupDigest', - - // ── Monitoring / Group / System ─────────────────────────────────────────────── - // Misc-1 (Draft): monitoring goal added/opened for recipients in a region - MONITORING_GOAL_ADDED: 'monitoringGoalAdded', - // Misc-1b: new monitoring data received for recipients in a region - MONITORING_DATA_RECEIVED: 'monitoringDataReceived', - // Misc-2: group co-owner added - GROUP_CO_OWNER_ADDED: 'groupCoOwnerAdded', - // Misc-3: group shared with user - GROUP_SHARED: 'groupShared', - // Misc-4: TTA Hub planned outage notification - SYSTEM_PLANNED_OUTAGE: 'systemPlannedOutage', - // Misc-5: TTA Hub unplanned outage notification - SYSTEM_UNPLANNED_OUTAGE: 'systemUnplannedOutage', +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, + ], }; +function filterToNotificationTypes(types: string[]): string[] { + const resolved = types + .filter((t) => VALID_TYPES.includes(t)) + .flatMap((t) => NOTIFICATION_TYPE_MAP[t] ?? []); + return Array.from(new Set(resolved)); +} + export function withNotificationType(notificationTypes: string[]) { return { - where: { - notificationType: { - [Op.in]: filterStringArrayToNumberArray(notificationTypes), - }, + notificationType: { + [Op.in]: filterToNotificationTypes(notificationTypes), }, }; } export function withoutNotificationType(notificationTypes: string[]) { return { - where: { - notificationType: { - [Op.notIn]: filterStringArrayToNumberArray(notificationTypes), - }, + notificationType: { + [Op.notIn]: filterToNotificationTypes(notificationTypes), }, }; } diff --git a/src/scopes/notifications/userId.test.ts b/src/scopes/notifications/userId.test.ts new file mode 100644 index 0000000000..c6b16f3061 --- /dev/null +++ b/src/scopes/notifications/userId.test.ts @@ -0,0 +1,117 @@ +import faker from '@faker-js/faker'; +import { Op } from 'sequelize'; +import { NOTIFICATION_TYPES } from '../../constants'; +import db from '../../models'; +import { withUserId } from './userId'; + +const { Notification, User } = db; + +describe('notifications/userId scope', () => { + let user1; + let user2; + let user1Notification1; + let user1Notification2; + let user2Notification1; + let user2Notification2; + let allIds: number[]; + + beforeAll(async () => { + user1 = await User.create({ + id: faker.datatype.number({ min: 300000, max: 349999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + email: faker.internet.email(), + role: ['Specialist'], + lastLogin: new Date(), + }); + + user2 = await User.create({ + id: faker.datatype.number({ min: 350000, max: 399999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + email: faker.internet.email(), + role: ['Specialist'], + lastLogin: new Date(), + }); + + user1Notification1 = await Notification.create({ + userId: user1.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED, + }); + + user1Notification2 = await Notification.create({ + userId: user1.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_SUBMITTED, + }); + + user2Notification1 = await Notification.create({ + userId: user2.id, + type: NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED, + }); + + user2Notification2 = await Notification.create({ + userId: user2.id, + type: NOTIFICATION_TYPES.COLLAB_REPORT_SUBMITTED, + }); + + allIds = [ + user1Notification1.id, + user1Notification2.id, + user2Notification1.id, + user2Notification2.id, + ]; + }); + + afterAll(async () => { + await Notification.destroy({ where: { id: allIds } }); + await User.destroy({ where: { id: [user1.id, user2.id] } }); + await db.sequelize.close(); + }); + + it('returns only notifications for the specified user', async () => { + const scope = withUserId([String(user1.id)]); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(2); + expect(found.map((n) => n.id)).toEqual( + expect.arrayContaining([user1Notification1.id, user1Notification2.id]) + ); + }); + + it('returns notifications for multiple specified users', async () => { + const scope = withUserId([String(user1.id), String(user2.id)]); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(4); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining(allIds)); + }); + + it('excludes notifications for users not in the filter', async () => { + const scope = withUserId([String(user2.id)]); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found.map((n) => n.id)).not.toContain(user1Notification1.id); + expect(found.map((n) => n.id)).not.toContain(user1Notification2.id); + }); + + it('returns no notifications for an empty user id array', async () => { + const scope = withUserId([]); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(0); + }); + + it('filters out non-numeric string ids and returns no results for all-invalid input', async () => { + const scope = withUserId(['not-a-number', 'abc']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(0); + }); +}); diff --git a/src/scopes/notifications/userId.ts b/src/scopes/notifications/userId.ts index 02678a306b..58c4d41bf0 100644 --- a/src/scopes/notifications/userId.ts +++ b/src/scopes/notifications/userId.ts @@ -3,10 +3,8 @@ import { filterStringArrayToNumberArray } from '../utils'; export function withUserId(userIds: string[]) { return { - where: { - userId: { - [Op.in]: filterStringArrayToNumberArray(userIds), - }, + userId: { + [Op.in]: filterStringArrayToNumberArray(userIds), }, }; } From b1a20ff026b61e416f426024eb67801288f6402d Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Tue, 2 Jun 2026 11:17:38 -0400 Subject: [PATCH 08/23] Register scopes --- src/scopes/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/scopes/index.js b/src/scopes/index.js index c2fb2693d3..3bfd57f873 100644 --- a/src/scopes/index.js +++ b/src/scopes/index.js @@ -7,6 +7,7 @@ import { deliveredReviewFiltersToScopes as deliveredReview } from './deliveredRe import { goalsFiltersToScopes as goal } from './goals'; import { grantCitationFiltersToScopes as grantCitation } from './grantCitation'; import { grantsFiltersToScopes as grant } from './grants'; +import { notificationFiltersToScopes as notification } from './notifications'; import { sessionReportFiltersToScopes as sessionReport } from './sessionReports'; import { trainingReportsFiltersToScopes as trainingReport } from './trainingReports'; import { getValidTopicsSet } from './utils'; @@ -22,6 +23,7 @@ const models = { deliveredReview, citation, grantCitation, + notification, }; /** From 3b4ccbf26df2665862945c09dbfa81717bcf5ada Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Tue, 2 Jun 2026 11:32:05 -0400 Subject: [PATCH 09/23] Fix bugs, add integration test --- src/scopes/notifications/index.ts | 5 + .../notifications/notificationType.test.ts | 282 +++++++++++++++++- src/scopes/notifications/notificationType.ts | 4 +- src/scopes/notifications/userId.ts | 4 +- src/scopes/utils.ts | 4 - 5 files changed, 277 insertions(+), 22 deletions(-) diff --git a/src/scopes/notifications/index.ts b/src/scopes/notifications/index.ts index b8d02e13c9..4ca64351e1 100644 --- a/src/scopes/notifications/index.ts +++ b/src/scopes/notifications/index.ts @@ -1,5 +1,6 @@ import { createFiltersToScopes } from '../utils'; import { afterCreateDate, beforeCreateDate, withinCreateDate } from './createdAt'; +import { withNotificationType, withoutNotificationType } from './notificationType'; import { withUserId } from './userId'; export const topicToQuery = { @@ -9,6 +10,10 @@ export const topicToQuery = { win: (query: string[]) => withinCreateDate(query), in: (query: string[]) => withinCreateDate(query), }, + notificationType: { + in: (query: string[]) => withNotificationType(query), + nin: (query: string[]) => withoutNotificationType(query), + }, userId: { in: (query: string[]) => withUserId(query), }, diff --git a/src/scopes/notifications/notificationType.test.ts b/src/scopes/notifications/notificationType.test.ts index 126b565183..796ac997d9 100644 --- a/src/scopes/notifications/notificationType.test.ts +++ b/src/scopes/notifications/notificationType.test.ts @@ -1,16 +1,24 @@ +import faker from '@faker-js/faker'; import { Op } from 'sequelize'; import { NOTIFICATION_TYPES } from '../../constants'; +import db from '../../models'; import { NOTIFICATION_TYPE_MAP, withNotificationType, withoutNotificationType, } from './notificationType'; +const { Notification, User } = db; + describe('notifications/notificationType scope', () => { + afterAll(async () => { + await db.sequelize.close(); + }); + describe('withNotificationType', () => { it('maps activityReport to all activity report notification types', () => { const scope = withNotificationType(['activityReport']); - const types = scope.notificationType[Op.in]; + const types = scope.type[Op.in]; expect(types).toEqual( expect.arrayContaining([ @@ -27,7 +35,7 @@ describe('notifications/notificationType scope', () => { it('maps collabReport to all collaborative report notification types', () => { const scope = withNotificationType(['collabReport']); - const types = scope.notificationType[Op.in]; + const types = scope.type[Op.in]; expect(types).toEqual( expect.arrayContaining([ @@ -43,7 +51,7 @@ describe('notifications/notificationType scope', () => { it('maps trainingReport to all training report notification types', () => { const scope = withNotificationType(['trainingReport']); - const types = scope.notificationType[Op.in]; + const types = scope.type[Op.in]; expect(types).toEqual( expect.arrayContaining([ @@ -70,7 +78,7 @@ describe('notifications/notificationType scope', () => { it('maps systemRelated to system outage notification types', () => { const scope = withNotificationType(['systemRelated']); - const types = scope.notificationType[Op.in]; + const types = scope.type[Op.in]; expect(types).toEqual( expect.arrayContaining([ @@ -83,7 +91,7 @@ describe('notifications/notificationType scope', () => { it('maps other to communication log, monitoring, and group notification types', () => { const scope = withNotificationType(['other']); - const types = scope.notificationType[Op.in]; + const types = scope.type[Op.in]; expect(types).toEqual( expect.arrayContaining([ @@ -102,27 +110,25 @@ describe('notifications/notificationType scope', () => { const singleAR = withNotificationType(['activityReport']); const singleSR = withNotificationType(['systemRelated']); - const combinedTypes = combined.notificationType[Op.in]; - expect(combinedTypes).toHaveLength( - singleAR.notificationType[Op.in].length + singleSR.notificationType[Op.in].length - ); + const combinedTypes = combined.type[Op.in]; + expect(combinedTypes).toHaveLength(singleAR.type[Op.in].length + singleSR.type[Op.in].length); }); it('returns an empty Op.in array for an invalid category', () => { const scope = withNotificationType(['invalidCategory']); - expect(scope.notificationType[Op.in]).toHaveLength(0); + expect(scope.type[Op.in]).toHaveLength(0); }); it('returns an empty Op.in array for an empty array', () => { const scope = withNotificationType([]); - expect(scope.notificationType[Op.in]).toHaveLength(0); + expect(scope.type[Op.in]).toHaveLength(0); }); }); describe('withoutNotificationType', () => { it('maps systemRelated to an Op.notIn clause with system outage types', () => { const scope = withoutNotificationType(['systemRelated']); - const types = scope.notificationType[Op.notIn]; + const types = scope.type[Op.notIn]; expect(types).toEqual( expect.arrayContaining([ @@ -135,7 +141,7 @@ describe('notifications/notificationType scope', () => { it('excludes types for all provided categories', () => { const scope = withoutNotificationType(['activityReport', 'collabReport']); - const types = scope.notificationType[Op.notIn]; + const types = scope.type[Op.notIn]; expect(types).toEqual( expect.arrayContaining([ @@ -147,7 +153,7 @@ describe('notifications/notificationType scope', () => { it('returns an empty Op.notIn array for an invalid category', () => { const scope = withoutNotificationType(['invalidCategory']); - expect(scope.notificationType[Op.notIn]).toHaveLength(0); + expect(scope.type[Op.notIn]).toHaveLength(0); }); }); @@ -161,4 +167,252 @@ describe('notifications/notificationType scope', () => { expect(missing).toHaveLength(0); }); }); + + describe('integration — withNotificationType', () => { + let user; + let arNotification; + let collabNotification; + let trainingNotification; + let systemNotification; + let otherNotification; + let allIds: number[]; + + beforeAll(async () => { + user = await User.create({ + id: faker.datatype.number({ min: 400000, max: 449999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + email: faker.internet.email(), + role: ['Specialist'], + lastLogin: new Date(), + }); + + arNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED, + }); + collabNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED, + }); + trainingNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.TRAINING_REPORT_POC_ADDED, + }); + systemNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + }); + otherNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.MONITORING_GOAL_ADDED, + }); + + allIds = [ + arNotification.id, + collabNotification.id, + trainingNotification.id, + systemNotification.id, + otherNotification.id, + ]; + }); + + afterAll(async () => { + await Notification.destroy({ where: { id: allIds } }); + await User.destroy({ where: { id: user.id } }); + }); + + it('returns only activityReport notifications for the activityReport category', async () => { + const scope = withNotificationType(['activityReport']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(1); + expect(found[0].id).toBe(arNotification.id); + }); + + it('returns only collabReport notifications for the collabReport category', async () => { + const scope = withNotificationType(['collabReport']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(1); + expect(found[0].id).toBe(collabNotification.id); + }); + + it('returns only trainingReport notifications for the trainingReport category', async () => { + const scope = withNotificationType(['trainingReport']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(1); + expect(found[0].id).toBe(trainingNotification.id); + }); + + it('returns only systemRelated notifications for the systemRelated category', async () => { + const scope = withNotificationType(['systemRelated']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(1); + expect(found[0].id).toBe(systemNotification.id); + }); + + it('returns only other notifications for the other category', async () => { + const scope = withNotificationType(['other']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(1); + expect(found[0].id).toBe(otherNotification.id); + }); + + it('returns a union of matching notifications for multiple categories', async () => { + const scope = withNotificationType(['activityReport', 'systemRelated']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(2); + expect(found.map((n) => n.id)).toEqual( + expect.arrayContaining([arNotification.id, systemNotification.id]) + ); + }); + + it('returns no notifications for an empty category array', async () => { + const scope = withNotificationType([]); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(0); + }); + + it('returns no notifications for an invalid category', async () => { + const scope = withNotificationType(['invalidCategory']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found).toHaveLength(0); + }); + }); + + describe('integration — withoutNotificationType', () => { + let user; + let arNotification; + let collabNotification; + let trainingNotification; + let systemNotification; + let otherNotification; + let allIds: number[]; + + beforeAll(async () => { + user = await User.create({ + id: faker.datatype.number({ min: 450000, max: 499999 }), + name: faker.name.findName(), + hsesUsername: faker.internet.userName(), + hsesUserId: faker.datatype.uuid(), + email: faker.internet.email(), + role: ['Specialist'], + lastLogin: new Date(), + }); + + arNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_APPROVED, + }); + collabNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.COLLAB_REPORT_APPROVED, + }); + trainingNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.TRAINING_REPORT_POC_ADDED, + }); + systemNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + }); + otherNotification = await Notification.create({ + userId: user.id, + type: NOTIFICATION_TYPES.MONITORING_GOAL_ADDED, + }); + + allIds = [ + arNotification.id, + collabNotification.id, + trainingNotification.id, + systemNotification.id, + otherNotification.id, + ]; + }); + + afterAll(async () => { + await Notification.destroy({ where: { id: allIds } }); + await User.destroy({ where: { id: user.id } }); + }); + + it('excludes activityReport notifications, returning all other categories', async () => { + const scope = withoutNotificationType(['activityReport']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found.map((n) => n.id)).not.toContain(arNotification.id); + expect(found.map((n) => n.id)).toEqual( + expect.arrayContaining([ + collabNotification.id, + trainingNotification.id, + systemNotification.id, + otherNotification.id, + ]) + ); + }); + + it('excludes systemRelated notifications, returning all non-system notifications', async () => { + const scope = withoutNotificationType(['systemRelated']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found.map((n) => n.id)).not.toContain(systemNotification.id); + expect(found.map((n) => n.id)).toEqual( + expect.arrayContaining([ + arNotification.id, + collabNotification.id, + trainingNotification.id, + otherNotification.id, + ]) + ); + }); + + it('excludes multiple categories, returning only the remaining ones', async () => { + const scope = withoutNotificationType(['activityReport', 'collabReport']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found.map((n) => n.id)).not.toContain(arNotification.id); + expect(found.map((n) => n.id)).not.toContain(collabNotification.id); + expect(found.map((n) => n.id)).toEqual( + expect.arrayContaining([ + trainingNotification.id, + systemNotification.id, + otherNotification.id, + ]) + ); + }); + + it('returns all test notifications when given an empty category array', async () => { + const scope = withoutNotificationType([]); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining(allIds)); + }); + + it('returns all test notifications when given an invalid category', async () => { + const scope = withoutNotificationType(['invalidCategory']); + const found = await Notification.findAll({ + where: { [Op.and]: [scope, { id: allIds }] }, + }); + expect(found.map((n) => n.id)).toEqual(expect.arrayContaining(allIds)); + }); + }); }); diff --git a/src/scopes/notifications/notificationType.ts b/src/scopes/notifications/notificationType.ts index 29c9329fcf..8c21b91577 100644 --- a/src/scopes/notifications/notificationType.ts +++ b/src/scopes/notifications/notificationType.ts @@ -71,7 +71,7 @@ function filterToNotificationTypes(types: string[]): string[] { export function withNotificationType(notificationTypes: string[]) { return { - notificationType: { + type: { [Op.in]: filterToNotificationTypes(notificationTypes), }, }; @@ -79,7 +79,7 @@ export function withNotificationType(notificationTypes: string[]) { export function withoutNotificationType(notificationTypes: string[]) { return { - notificationType: { + type: { [Op.notIn]: filterToNotificationTypes(notificationTypes), }, }; diff --git a/src/scopes/notifications/userId.ts b/src/scopes/notifications/userId.ts index 58c4d41bf0..067622da26 100644 --- a/src/scopes/notifications/userId.ts +++ b/src/scopes/notifications/userId.ts @@ -1,10 +1,10 @@ import { Op } from 'sequelize'; -import { filterStringArrayToNumberArray } from '../utils'; +import { validatedIdArray } from '../utils'; export function withUserId(userIds: string[]) { return { userId: { - [Op.in]: filterStringArrayToNumberArray(userIds), + [Op.in]: validatedIdArray(userIds), }, }; } diff --git a/src/scopes/utils.ts b/src/scopes/utils.ts index 4dd1c46329..2a9e273b4e 100644 --- a/src/scopes/utils.ts +++ b/src/scopes/utils.ts @@ -268,7 +268,3 @@ export function filterToAllowedProgramTypes(programTypes: string[]): string[] { .flat(); return Array.from(new Set(allowedTypes)); } - -export function filterStringArrayToNumberArray(arr: string[]): number[] { - return arr.map((item) => Number(item)).filter((num) => !Number.isNaN(num)); -} From c3e9c0af50e95eb02f0e9719daae3f44cf91108c Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Wed, 3 Jun 2026 10:02:33 -0400 Subject: [PATCH 10/23] Add services and tests --- src/constants.js | 17 ++ src/services/notifications.test.js | 300 ++++++++++++++++++++++++++++ src/services/notifications.ts | 163 +++++++++++++++ src/services/types/notifications.ts | 31 +++ 4 files changed, 511 insertions(+) create mode 100644 src/services/notifications.test.js create mode 100644 src/services/notifications.ts create mode 100644 src/services/types/notifications.ts diff --git a/src/constants.js b/src/constants.js index 1c326bbd0b..b3d38a9f58 100644 --- a/src/constants.js +++ b/src/constants.js @@ -205,6 +205,22 @@ 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', + }, + [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, + }, +}; + const EMAIL_ACTIONS = { COLLABORATOR_ADDED: 'collaboratorAssigned', NEEDS_ACTION: 'changesRequested', @@ -358,6 +374,7 @@ module.exports = { RESOURCE_ACTIONS, USER_SETTINGS, NOTIFICATION_TYPES, + NOTIFICATION_CONFIGURATION, EMAIL_ACTIONS, S3_ACTIONS, EMAIL_DIGEST_FREQ, diff --git a/src/services/notifications.test.js b/src/services/notifications.test.js new file mode 100644 index 0000000000..9ddc2fdde4 --- /dev/null +++ b/src/services/notifications.test.js @@ -0,0 +1,300 @@ +import faker from '@faker-js/faker'; +import { NOTIFICATION_TYPES } from '../constants'; +import db from '../models'; +import { + createGlobalNotification, + createNotification, + deleteNotification, + getNotifications, + updateNotification, +} from './notifications'; + +const { Notification, User } = db; + +describe('Notification service', () => { + let user; + 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', + }); + + 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(), + }); + }); + + afterEach(async () => { + if (createdNotificationIds.length) { + await Notification.destroy({ where: { id: createdNotificationIds } }); + createdNotificationIds = []; + } + }); + + afterAll(async () => { + if (createdNotificationIds.length) { + await Notification.destroy({ where: { id: createdNotificationIds } }); + } + + await User.destroy({ where: { id: user.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'); + }); + + 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(); + }); + + 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}` + ); + }); + + 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('updateNotification', () => { + it('updates archivedAt, triggeredAt, and viewedAt fields', async () => { + const notification = await createTrackedNotification({ + archivedAt: null, + triggeredAt: null, + viewedAt: null, + }); + + const updatedNotification = await updateNotification(notification, { + archivedAt: '2026-01-15', + triggeredAt: '2026-01-16', + viewedAt: '2026-01-17', + }); + + expect(updatedNotification.archivedAt).toBe('2026-01-15'); + expect(updatedNotification.triggeredAt).toBe('2026-01-16'); + expect(updatedNotification.viewedAt).toBe('2026-01-17'); + }); + + it('does not update disallowed fields', async () => { + const notification = await createTrackedNotification({ + text: 'Original text', + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + }); + + await updateNotification(notification, { + archivedAt: '2026-02-01', + text: 'Updated text that should be ignored', + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + }); + + const found = await Notification.findByPk(notification.id); + + expect(found.archivedAt).toBe('2026-02-01'); + expect(found.text).toBe('Original text'); + expect(found.type).toBe(NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION); + }); + }); + + describe('deleteNotification', () => { + it('destroys notifications matching the given scopes and returns the count', async () => { + const matchingOne = await createTrackedNotification({ + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + }); + const matchingTwo = await createTrackedNotification({ + type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + }); + const otherNotification = await createTrackedNotification({ + type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + }); + + const deletedCount = await deleteNotification([ + { userId: user.id }, + { type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION }, + ]); + + expect(deletedCount).toBe(2); + + const remainingNotifications = await Notification.findAll({ + where: { id: [matchingOne.id, matchingTwo.id, otherNotification.id] }, + }); + + expect(remainingNotifications.map((notification) => notification.id)).toEqual([ + otherNotification.id, + ]); + }); + + it('returns 0 when no notifications match the scope', async () => { + await createTrackedNotification(); + + const deletedCount = await deleteNotification([ + { userId: user.id }, + { type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE }, + ]); + + expect(deletedCount).toBe(0); + }); + }); + + describe('getNotifications', () => { + it('returns notifications matching scopes using default pagination and sorting', async () => { + const oldest = await createTrackedNotification({ + entityId: 99011, + triggeredAt: '2026-01-01', + }); + const middle = await createTrackedNotification({ + entityId: 99012, + triggeredAt: '2026-01-02', + }); + const newest = await createTrackedNotification({ + entityId: 99013, + triggeredAt: '2026-01-03', + }); + + const notifications = await getNotifications([ + { userId: user.id }, + { id: [oldest.id, middle.id, newest.id] }, + ]); + + expect(notifications.map((notification) => notification.id)).toEqual([ + newest.id, + middle.id, + oldest.id, + ]); + }); + + it('respects limit and offset options', async () => { + const first = await createTrackedNotification({ + entityId: 99021, + triggeredAt: '2026-02-01', + }); + const second = await createTrackedNotification({ + entityId: 99022, + triggeredAt: '2026-02-02', + }); + const third = await createTrackedNotification({ + entityId: 99023, + triggeredAt: '2026-02-03', + }); + + const notifications = await getNotifications( + [{ userId: 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 custom sortBy and sortDirection options', async () => { + const first = await createTrackedNotification({ entityId: 99033, triggeredAt: '2026-03-01' }); + const second = await createTrackedNotification({ + entityId: 99031, + triggeredAt: '2026-03-02', + }); + const third = await createTrackedNotification({ entityId: 99032, triggeredAt: '2026-03-03' }); + + const notifications = await getNotifications( + [{ userId: user.id }, { id: [first.id, second.id, third.id] }], + { sortBy: 'entityId', sortDirection: 'ASC' } + ); + + expect(notifications.map((notification) => notification.entityId)).toEqual([ + 99031, 99032, 99033, + ]); + }); + }); +}); diff --git a/src/services/notifications.ts b/src/services/notifications.ts new file mode 100644 index 0000000000..141c1e1d25 --- /dev/null +++ b/src/services/notifications.ts @@ -0,0 +1,163 @@ +import { Op } from 'sequelize'; +import { NOTIFICATION_CONFIGURATION } from '../constants'; +import db from '../models'; +import type { + NotificationMetadata, + NotificationModel, + NotificationScope, + NotificationType, +} from './types/notifications'; + +const { Notification } = db; +const NOTIFICATION_PER_PAGE = 10; + +/** + * 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; + + return Notification.create({ + userId, + entityId, + type: notificationType, + text: notificationText, + link: notificationLink, + label: notificationLinkText, + }); +} + +/** + * 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; + + return Notification.create({ + type: notificationType, + text: notificationText, + link: notificationLink, + label: notificationLinkText, + }); +} + +/** + * Updates permitted timestamp fields on an existing notification. + * @param {NotificationModel} notification The existing notification instance to update. + * @param {Partial} updatedNotification The incoming notification field changes. + * @returns {Promise} The updated notification record. + */ +async function updateNotification( + notification: NotificationModel, + updatedNotification: Partial +): Promise { + // the handler will check the notification ID from the params, and pass in the existing notification + // or 404 if it doesn't exist + + const allowedFields: Array<'archivedAt' | 'triggeredAt' | 'viewedAt'> = [ + 'archivedAt', + 'triggeredAt', + 'viewedAt', + ]; + const fieldsToUpdate: Partial< + Pick + > = {}; + for (const field of allowedFields) { + if (field in updatedNotification) { + fieldsToUpdate[field] = updatedNotification[field]; + } + } + + return notification.update(fieldsToUpdate); +} + +/** + * Deletes notifications matching all provided scopes. + * @param {NotificationScope[]} scopes Query scopes combined with AND filtering. + * @returns {Promise} The number of deleted notifications. + */ +// Deletes a notification with the given scopes +// called either by the scheduled job or by a handler when +// a user action invalidates a notification (ex: a report that is "un-submitted") +async function deleteNotification(scopes: NotificationScope[]) { + return Notification.destroy({ + where: { + [Op.and]: scopes, + }, + }); +} + +/** + * Retrieves notifications matching the provided scopes with pagination and sorting. + * @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. + */ +// Retrieves notifications for a user, with sorting and pagination +async function getNotifications( + scopes: NotificationScope[], + { limit = NOTIFICATION_PER_PAGE, offset = 0, sortBy = 'triggeredAt', sortDirection = 'DESC' } = {} +) { + return Notification.findAll({ + where: { + [Op.and]: scopes, + }, + order: [[sortBy, sortDirection]], + limit, + offset, + }); +} + +export { + createGlobalNotification, + createNotification, + deleteNotification, + getNotifications, + updateNotification, +}; diff --git a/src/services/types/notifications.ts b/src/services/types/notifications.ts new file mode 100644 index 0000000000..8530153b58 --- /dev/null +++ b/src/services/types/notifications.ts @@ -0,0 +1,31 @@ +import type { Model } from 'sequelize'; +import type { NOTIFICATION_TYPES } from '../../constants'; + +interface NotificationScope { + id?: number | number[]; +} + +interface NotificationMetadata { + id: number | null; + recipientName: string | null; + userName: string | null; + date: string | null; +} + +type NotificationType = (typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES]; + +interface NotificationModel extends Model { + userId?: number; + entityId?: number; + type: NotificationType; + link?: string; + label?: string; + text?: string; + archivedAt?: Date; + triggeredAt?: Date; + viewedAt?: Date; + isGlobal?: boolean; + isInformational?: boolean; +} + +export type { NotificationMetadata, NotificationModel, NotificationScope, NotificationType }; From 51c56f61449decd2afac60d69fb4be65d9c62303 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Wed, 3 Jun 2026 10:06:52 -0400 Subject: [PATCH 11/23] Update model with other needed field --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 1 + specs/actionable-notifications/index.md | 4 +++- src/migrations/20260601000000-create-notifications-table.js | 4 ++++ src/models/notification.js | 4 ++++ src/models/tests/notification.test.js | 1 + 6 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index 60998d725d..aa95e697ee 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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDY454jXxxPWxrjt1BeGfDpNjdgOnNQ-r8Ov1ZuiQPeA6p7ZrxUcDWUSzHlJpCGgUuHZzfNYTtZWkvrRwodwd8HwbVKodiUny7CyMhOBXKHXCDzCvxt-juTY-u3NrsDbqxB0VPXItMM-5pVPolKU4CXNhEQJiw_gI-tq8k-Bfqkymk-Rdkx1wj6qBcaU1TUP5WwlC2mVNc1SEhV2Ww--Ghw-n9dKBWZcIZnUqQf5Lhgxi9JUN4O6kmpH7SPi1pV7eHdtDyx_Wb-mlfUkKV1IgXkNISv9BpthN1Dgncs9Rw7rekFNzweMoasz-BJFiXlVtShsVr3n1hr-QN7XyWrdCRr_6WS1xzAFdASLxNxAQddwFjBOlVAwPNvRJjwixhFt8wBV4Gbu5uob_Xe34kl6Dn7J-N4lPcCSe6NsBhu78IUE-DT-SdlBV8ESFh3q0yeZgdGd0Bd2jhCVYNrx99zmk2B4EFqF-8Ulg4Ih_utxu0FLJ0dPE--3S2rmyQiy75JJEc-Zr46q86i4ZL7apiSBELi4Kk0AeddDdIahhjR4s-9f4GC-D2Z9WHnbLAM6QrGfLq3k8_J88Zq8M_5vopEi4Ny9BoA6lEOHoHxJpZRRtu0NhKx1WtvIbTwzp1thNVYar7K6ojDyNSLkzgjnv3ONGQOG03lM8ZV7ff3qkOy9eh_2Hnt6VOLn0i6KnshuwR8xZo_v5mX1vPTclLkjDnlrJzStnl_lakY40rM4NV6irxVDVz7agDzd-zf7fm9ghTvc26oeYwiy9exzcXFVPOTixP163PoKCSuO_na082OVpBAiWfV9bRNtzBqEygrW2olX0HnBe6dRBYofxdu3eC7C3mpgHuXy0GK0SW-pe6S42RNZe0VvM6c13OGQUGFmRbl8-g632AqlmdtzDuYpQy0tl7o0Xg81hpw6cOp7nYOeZSPnzN3o7ri3-JPp9EcH4Yu_cFtPSm7lGHM6u6txN8RudVz11cUN9zpEjXWn_XBvJ2_CTcaExhmqu7ON7D01xNC6h8rohcaUvFOMKrsPW2UEAYZTu9nKUwsii_VhU4kmwkvNiD3kxkZhxyr__flOT0TwjA7N2GvFm9fmrR8-9k4dn12zHZVJ4B_4hIVUuXk-y4UuiAngZ9jnaHaPduIPvjxLfyB79ActIzrnUZO3oUG7e_Wu3KcooVpIWHEpn5b3Q46wuTjjbAiCRavUmiX49Qa2fbnANgiMMlhY8g1J8HlV9fuXR-PUpbwsI8W2KJgE1IKz5zfUpLwXaa8IPdZHLxTNI24lDqQUE_dBsfhl4xslFXrI-N62zDKg8jAgy1RINTFVOI-g0dDWNUDaV1Hl-XfPECgsAEWoTgBCr_7HByATRMxR6PU59RKzKjnJjF3tnnZPpQ2_nIO9f5AO12we4LHygHxcO8Ef3DFPaKiBBZvVdsYf1vOSgGJGAKMSMstyW8E4rXfVx9752QmIjj0-6OvKMzpYyNFLi0ci4h8JJA4vfxWN1bE1nu4dIqn0mGbVGgbVQCSJrzMc6ZIAL557J8F670RYtLC9e6ADq0fu0IFf99OX68EgCL1DCOp3PZkW981UW18uN77cFubZS981SW9nN4nD04g2gQkhMBk0DtYKrMm18wJOy5Aa4K0-WfFJtmFBovPi0I8g8XXLEmt9SLQ0kRXdOzKnXU641ecxQO9jbM8bRLCYmCtrNH9dljTsJxyBTUWsyfl2ndtXEog8md18-3ZwkXy9e5Ba4cG-o1903K18PlQ28kFRdyhK-b01o0U9wnMG6E05GicgRGYXTjuw0qmPqs8Dwbm3o0QHSoyKykoIOFsASS1h6QImAOqBBHIkjJG4W9N0yu2HGN51uv8kazHX39wa-0a0otbjA-7ZmkOA74kImQWFF9P0cG4e0-G1AgFHGmSqXQJkc44g3Pn2QX8g3okjAMN0c_VCWF0cQGMWIB0uiRm4g8Kas6rva-0jcF0nAWiS3ZmubSywJhfkg0kd6Qmodzd8mYJj1C7sFA4jNgE58FNhVx4sGJKEMXjWaNcbKXQ3aUNyr8JM4L5gheh1AYzGaK0cXGOI4p0bXcd2ZACahj1QhXDC4Jcam1O2KDhf8gDAycy8f0JG8a1B0qtmBWYb1v42K_68Y8KhmkF307qL0XsG4EW5G0iO5nepKdy2nmJ6YfoPhcccKafNr54896HSP_oFm2HmEZlVH-GIDW6RmnVc4Zu5F6ZSEBYcOpZ0okxJG-6199oe_C9wdqnCo2fa4YWEA-nXxGaMWClMyUuB93rDowes3Anb_1k4qyHvyWiqFM__9-8IK7wa4aGkHvtduX0n79WdNFbGypxqdO1Gmmw_59umSZ18s7co6twXF62cC4YWEAFstrnCk71V_4-m96ucD-wZylpKkcs-oCqfnZgGPncQp4MgWxEIt9zdpvt_znLq9ye_RGmbGlMLxb7-i8QN__UtVdZyNHGptUrul_MkRtQCIRkJXFvIO6xaeEyrsF6ah_tGv6x4QXfArNbR9JZFGi3qcswm4b7qu5Bo_NpnkDYCljpkS2a3ti71O_PHb9xLYPhnKafrckPkspS0WiutdOUO8_bClXlFszufiFMdsPwlNIbzk15T8Vx4PUh1pTeNwJpltQgMBR8SozBV7h9zyLM5ExUINHwoRMWHYQoQ-EsAzXaVQTDsUOip9m_QCoiaFdPhDgv2wcUxLmcIk2gt2qNgxqSTMvQt6pLxHnnhQAxrQ7VnpP9kHBdSrNdADdcgdABKQJsDUJ37kYgx6VNYckyo7vdKCRstNcvrcL3_yfrDvfxOpnaYpgx7fzfjLjwwy_VyjBc0Pi67DgSRGIotPQLOCP6rrnMPnj6EZAk7GIzsI-6A5DzyJl5gASyUJp5w7ex4SEipqOhW67_KQR8zDqMQimaKjFpOBOYmwQt1f7oKTuDFchiB9UDkpnottbrk7Rxl4s1mfJztqNlsRUL2Htvvc5JZG6-pcR4NwAuAhtZUBQj1x6ODFQITjQnsUE8TbB7lnASKouYIkpDHlof-TjczeEHbUvivZSr7Tx7dtK0mdEttRIv7Io9gyBJbpkWA6nAdEFIiUe7Bnt-HvAPvpnwARDfALcbxyryODOtsKhNSxk1SluUyDmwtsOpUzLuLDxw6f2DO1xtfnhHaljPL_nWilgepdZftBWcOgRMlXrQh5dnlkXMhDF2qiezixl9iCd1DwBAUXbKUoyAysiJm6ZDYwNX0_MsNaSk9U6ntEp9UkkJeFPCL0QCpiUulEOoVjqitG2o_hgWqFWcWdMYdDu0hS9_ZvE2qLItS6BLk0ayBfN5w3QBWiPVFMOTHtW-knsuqMNwKMvl5xc7btz5x2K_6WDl7PD2VCFDrbtShqL66vrcOdjE2Oy3ibK0xfn8MM0L2ATZKiF8IYApCaaszxalayshrPBedRSpoMg3lmP6NjWHPBqtfIJDEbPkeikc9b0mJMxJr7laTbCQ-lB6MRFakqmt7JkqmB9pmKw9QBj0DP_IkwLVZRyQai_dRM07_tcOzO8RVtVIVKErbAkdUILw-6bhFDj6qlgUEz-4AWlOBrt0D4qzUBMtkhAuTzD1Ml7d3hg0DdqXqwrcpzfvkPN9mE9V9dyPta97-g87PREzlkTxkecJUdPlff2gKLaR_QjiTJT5rbfLHd9IsbJJkQy-ivvBNGRVik4FobYbNqJjk--edEf6zttTRimOrffn5Erdbxmh7mG7PkooEUlarxQ_2W6oSaVSmcudnvcZ62TZi-TryyRhiVcu3eqhFsBZUgjxLxzQB2RlkP-ikdgQAE3Ushg5D4IK9Yf_N7JbvI8JsIxpk27e3QiRq-_z4-Y--jnVNHuSi6nf3Ct9fm7Q4MNkvJN5SQejrwF3PZXWR4IfKOco1XCsEbYWprX9Ur6GbtbIxOTGH3-c0n_uX3GENZkl76SLtxFDIHtWwUabQioUXAvQ_Bo4Arj3QJNUJMnVvWzPhuga2ttbfjHWBOBgY1TwsmimRcwvgr6C31ozSA9RhMRaKDejkHlMlLUlYtIsEVD6krSU8xhduPLLkfORTIy-iKLeVwY3mxGu9t3bUbEd7ARpfBgdZhArFtLxTfM_rVfmFqgCXNrBLZDt5IuRweFeEQlruzDcjQgbhSEkNqjANgY9hQC0ESLTLfuugjtyvYARzEaMkqNsXppIQPCUQMAkAm9lReLSVDPMRthyca6rgRdYfdaHP8hGINFMsGN3dSz8tMzgoXVDhsj50R0-YLx5kqzUUeOSiilbeIDtZifDZTXjAQFTSbLKx3dSnpQBkUDugoUTqwXwvxDwVecclkBBeTi2t1kQXwTmb55VUtWtWEHeH6W_IXIvr1KuaB3V-xpVQuWfB8fjjcrtBw8Ut_uwu-o_bw_K26c7iSFvRN88JnCdx3sEuMepbqms_pwD_kp-7b4-ewFdsXRWuFatPtbwTug4iy-NiZFVYMUVyHNC9jtmSmcdONzKuJ9JefcfeEPj5EzzOlaLFSo81fizSYoZ-tboAVfToALbxQAjem-bgTvwX1Y2YXAs0LNRyYpjsKjmPourgRzQ01dBSVlALNhvEA03KEY2grQQVGUcvxZFypvQGNpTMQxIqBOxf7qzEqv0ft8ocg1R2bQWOfYLnBtFWleZT4qbQiBsw-ruLxdkiW9w_C4dNwGlhkQTBaOPyYb_4Pz4TNpE8P3BaKoIRalbpJmsW9Ezj7fdEA6_QGYUVckYDq-Bd7h7ZgOGez2MI86Y-Mly_wJdBhMz_DeUqQjNMqhPnusZxpXfPutxeXblfOBzGhe6wzGz9HDbEZQGwgfIJn0OrKB1TRxfAaJR4hbjxevihal_qTTJaNCOlR5MYRqUQkGemPhDgryeog5IkUfuxrnrr_29IULbIdHXDqvUTj9qNFekNPFpkoYpTFNuGjpU71oTYei4eBlGaVMZ9zZxPPBFGdIeRvGTDNOenAcGZcKfza_p9uOiOyF9KGfavRr6x6G4IKSDAU8ouDSSutjTR1t7pEuJJO8tyUIyVJZlRSYbPXOteL7Ob5K5UjozAM_6bCx8Hbo2qXwFXbkEhZ1OGjovDLILjt7s9VWCKF29mDtcDrLo9_GcoF6-M6OQVt4djrtWvoMz8QwmR8lCn1S5NOWgQ45LSYcBJ70BlekPJclj0LyyUJGSxss0ycgQJIRelW5WVTGSUzy_3uay-TPtc8Ktu4DiBqk3KYNftyuCtrkcoiYuoRFS7DRjyxN6tjxDv1nSxtI_IUYW-m6oav7NzWj18-7_svkq39ZyrBwFdlG1NbljFv_4dhhWhAlpUOugZxDjM73x6WNmzx-FAINaNdi6FitAa-u3EwTDcshqtd4W_pXk_pMga1ySISsqxR6sd-WjdU6KSJ2iHFKPSU8hCRAxxlstpDfLSFctGjxLnrxMSRxCojg67Iek_7mxj8NeorLrn2QDx9gtPSBfFlYNabEBTjaeYwlPjBHRRDVqqJJRDL6b5NbRFP_17MHLELc_y7 \ No newline at end of file +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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDj5634NqK0BVEmkmzmuoTxcxWrrmKsw4sZrQOpfbQnCTWnCNGim43QRn7jpJcmdFUwRevsCkFCDb-alnHxrndD6jz9KzKaDyJVkOJcFPUJ-UBLeVm-Cpc0weSru6MyFbVS4xwx6pwTW1GSpuRhDU4Plj_tgF26Kg2dH8sTVr9VS84HR7qwNVONRDJtvXzMZR5ZIF0klCYmPNcHSEhZ4l75hXGQVW8IzWOqtg5WHp91yldDKYUrrTsKjkI2D2O8VfbE8s0vhZqOtwckT_mM_SNqgdAlbSL0tBfISebvxruWcqeph5jj3x177h5TOBSYTV0rjcyWtlx-LwFwcJWbwFDRdm-0QpcD--Z0E1z-X7poEBzxvaDJtzlcfjN_YSDhyiTt1NgrZxaT6lZ8My5yPJ_Wm1YNNm6uds_BcMiZ5VKZBwIby3aPF6lMw-EJtblq7Edrvx0EGHrJiJW5tWMbcFHSQyae-xNH1Y7Nw6F4VMrJ1K_cV_y07gHmRidFOfkXUuUDG-4IgSd3VHwplQ43I2HwXQPsA5dQsSAN05K3tdDfMKrZjaRLWsYO2U6nLam8u_gbB2TAuLgg1t4Fjb41wVBFWbvPXt2xw4Dv93tta8vOvevpLjxy4BrgTWmRufI-zUvmxqhlrIQZg2P6c_FEAsUbMvyneAeTC83XpgAnlZxKbxNCQ4qLxX8u_ZFiEuK67BehTzTTWSnylzYuGXyZApNwtMpunxhkexvN_toNL30Ah3hlxMQjhdlmBqL6wo_Utpqu4rLoys1JTKnDgV4s1_pTZlimEwTyWY1izA6EOcVuo1418evrbMGKhbczlQC406VrTH1fHPWeunq3NibXTLba01SMFcXenrOiW_08A0E0RPSJM21Thme0Jy5JN0XW8GF87uDopbVL1XXbUMu3__ciHPj84TtZz0Gb01rvv3JSTYuXCLOU8u-xXuAAs1_PiuatN8Y1OVpNy6kO3te8lFS3VyuaDyJ_wWmpNBa-zdMWqP_WXzfnRcEpI7TbyRYZeAZsa0zhY2LqUuLZMFSdiAAQ_Dm1B65HLly4ugFDRNMFhrlINOTHOjsMbsTdLrzkU__atjEmAyMr7gX0SjuKquQzWU4t6JuWbheXhfiMVmAqdtk8RllB7lBFiQeoRSc4R6RU76UhUrQGgooIfjxFTSNes0yda1wFuE0r9iidyqe4JiyHPGsX1kqdRRPIgJ79ENiByH2Mf0gJSKbwh5bhx5YAWKo4RtoQU8QFcNivUjaY80b4wZWKbFHVQNirUeP924cPuqLUtLqWWRpj57aFvozgQxnEzhpuTKlbnWlJLAYBIgl0MqbtJts4lgW9pO5tZP7mKR_hgMJZBjZJeCdNYqDVnqI_2dMrksncNXIMrFLBSKxJmzySOsSsWlyKc2QHIc0Gkg15KVAaUvc23gGpJsc5B2ou-NvzegGUM7Aa4q2b5d5jj_EY3XDOQN-rXnGci4hRGFXcEL5lSul5prR09h1Ao4qoXEQUu5mPJWSU19qjCGC49NqAfNsZ74zVLfXeqYbHHHqo3n9mEujrJ2Q1YZT0AU04ZwIIM8HY3gZ5GJJ6CmsIxg2I0Ne0IE5nnvZ-9Ot2I0N82SznGJG1AWgchgrYxW3TubDLi0IEasF1If150FeAJqzy3oykMR04YAY8OLJiDoN5MWBcuPsFLCONXX0Q9ksc2RPLY9MrJ8i3DzLqIPxxNTao_6tNeDlARmiHz_JigYC9mIFWu-heV2Q7ov19aFiWIG0r0I6RsWY8Ztv_8TFvG0SW7YUiLa1ZW1KB9gcq8eNRUEWDC6TDY3UfS0yW6aNCl5FBiac3zld70Qncai2cD2oqKhhKq182LmFE0aK5nGUEIBfFKOGoUfFW90CbvYIlXuyBc2XnBai6e3poMG9a1A0Fa0IgZqKC7D8MaxfX1AWsSGceIAWyhhIbbm9ltp83m9ca5e4YmEB6y1AY59DXjUPFZRPZmCIeB70uyE9NFEawwRgWBfnciCf_PoC8axGJ1zZoXBLwZXI3rwN_DDa4r3beRO95vfL8MWv7cPDI4rX5HQgwAmIelK9509eK64XCm9OPfmeoZ9AxGMguJJ14vfC0M0b3QwIAZIl9l2AG4q290ImDDy2u8fGUH0bFnY8Y5AyBZmm1z5G8Ta13e1K0B61SQCr9_0iS4negScQvffb9ALzHH22HaN6VyZy0aS3extqVa4ZO1cyCNvX8-1Jnet3YufcCumChkqqFXWIISgFp2UfzCJCWgP18e3YliOUq95e3Brl7k2oGzJSkgDWoiPVmRXDF4UV8BD3rl_oVY4b1-f194BaUTv-8GCHoO9rpvKFC-z9s0KCCElnIUC78mIDXviXj-eJnWfZ18e3YZzjzSJBXmN_nFi2Hk9ZVke_ByrBfjliZDASOwa6SPcin5geEpajoVPy-T__SLT2VAFsqC9KBrbUvH_h26b__tjtvu_5qKCztjUB_rhczsZ4cxauJ-Kc1kvA3lDTZnfA_zqEHkn6eQIjLvMoKupqB0z9jki19HzE1IylryyRZOZBxSxd0f0zx1mMFsKPIUrOcQyL9ATPhcRjit08BEDvs7c2FvJBuRpzlUAR3rfzcUhrqfVRWHNI7-n6NgmStQ5-ayxzsgbYso7ClItnwoVV5LXJktabqUicre4OciclZjYlOP7sdJTdcBCoSFsZCh93vsQpQkGkfdkrS9ahWgjmj5wkz77LkMjnirUqSSQsYkzMXtySsIRaIvtDLvoZPvgfoYr6azZNamnxegkndrufhlCX-Pr36zjrvkTPbG__ATJUQUsCyP8iwknwVQRLRUklFt_BIvW6R1XpQd6q4ijsMbM36HjTSLcSRHZeohXq4lTalXYXJVV4xnQYdF7aynUXwEn73hCz6Au1X_r6coFJT5chC95BJys2s8iEcjmQHyb7U3Jvgx2oNZRiySjzvTRXs-xnDWSAK_Tz5xzctbGaT-UPXKuq1livcn5-Yk2gzutYshGUnc3JsadRMiTdZY7PInxyId5Ck8ahipKRygVdRPlQ3aPNkREOtDHtUnvzr0C9pjzsqkHqiYQl2qvSxe2XiIfpZqh7g1oyT_aUIcUSyUYcpQIbPfU_DV63MDzbArtExWNB-7l3SEjzcCtlLU5JU-XgGZM0UzwSQqPBxMLVyOBBwgCvuwTou9cAcrhuTMgnPyRxeLgpJmjBAFRExoR39mJUYodePL7il2lDh4y1epOkbuGFrjbv7BYNXiTpioNhhaw3sJ5G6ZCx7kBpcCdxTBDq0ilwweD3u9e9refpU0At2Vu-JWj5Kjt1YrRW9F2wLnUWsYuB6Nprc7KTuFhiTkD5b-b5kRnUvXvT_HUmbFne3RnsJGdp3pTPTtAz5HXkTPc9xJWcF0x9L0EwSI5bW5GYdOrB3o4eYip99DlUvBvFDgzMIw9stCybgWxy6HbxO4MIzDwKapJfMRgBBfYPGC4rkqzHxv7PJ6lhonbcpvBjCDnqxjC2oSy5EYMYxG3MVqhkbNus_6fBFvsrW1_zvcFM26tztqdr3jPIhftabUlXfQppRHjBwdZlVX2eBs2zTm3HDFNYrjxgok7VJGLhnvmwwW3Pz8TEjPi_QURcLoS3YNoP_6Tv2H_gY1sMplRxdUxg9atfsRwQGgb5P6_shR7KtHTPQLKPoKjfKqxclFhEUIrq6txBX3yfOfLz4xRllg9pgHlTztMxC6DQQSHJjPvUyAny41sRiiZdhvDUslme1id97tC9k9yUPenWdOxFdTVF6wx7vk0wDApzYutghUrU_MYmcxxcVhBfwcYZWtjgwXJH4b2OgVrnqvUKY4zakyxWXw0sh6zFl_HFellhSNrqU7B1iQGpDoQS1sX5bxkKrnN6gBTUZmsOuO6n4gL69iWOJDZfOeCzOINjHa9TvKks7K4G_fWCV-8Gq3buxhnnd5T-ppKaTuEdf9MhCdeIkMloyX2jRGsartariN-OFMQ-Af0jzvQRKO2s2weWNUjiBC6vkkQjHZ0mSlN2YMwrcv53QBRaRrhrNhujqjZdpHhjN7YEwv-6LLRgM6tKlFh55Q7-eWyEqE2TmvNfJfnocywIwfuwojJzrUtQLlzNwS3zAZ8LzIrOpTnKk6-g3w3chzUFJPhMgfQt3hbzBIbweYQsZ03d5NLQUEAhT_EOYc_Jf5hj5zeSyqccJ7cbYhYi2Rsw5N7pMLczw_9f1jQcvugPv4MIAq4bprja5mvtFIDrlQieNpQzhHG6mFebUnRjFNdg67BBBvQ4ZTuxAJOtORIcZtN9LLEmvtCSsYxdZUAiddTEeUkUpUdw9fhxYow7R0jmRceUdS9HHNtjuDu3aQ4HeFqeKkTGLE92mt_kytsk8AIoARRPjTo-Y7j_-EkFilvUlr0XfXx73-Mro24yJ9-mzZk5gCvTCDly-ZVxi_XvHFgEZvzeMuE3vDsTvUdUAXBFFbx8ptubdd_4Lp2RTy7C9fs5_LE4oKwAPgQ3cRHJlVMBv5JtCY0QRFN8ie_jvSYdwNSYbPUsYhQCFfQdUUeGOWeeIjW5Ls_8ixTbBS6SkDQc_MW0Pot7xobLw-JYW0r3eWgjMcdq7fkUup_C-Ma5ytLckqj2sEwHzFJjEGAToCfgWMmfMe6AObSIzpuBw8tHD9Mh2zkljU5Uvxh82Ulp19r-aBwxcdIv66V8fVn6VH7LypY6Gov5CacvBvSqyDe2JlRHwPpYXlsa8ddvheZTFYvnwnuwc4AFGbaY1elbh_F-avowrlVpQ7j6hLrjAsSUDe-yuQMUD-w8PRwM2_KAw1klKFIKJPJesaEggKayG6DL2mNM-wIf4snAvRUwERAvB_z7NKv5p6BsnLecz7chaAC6QpQjVACgXKhdgUEzSTTVmYKdbPKfqOJTENdRIT5pwBbsJyxieitJr-4BStXmSdOgB1A2xq97reoVO-sMIpq9qg6-K7JLsACIfa8vbAVPFyoU6B6F3oL4APEMzHkna14b73IdYCk3N7EDxNMmTnypk4qs2D_7al7quxst8fMOMDw5Hs9HL1NhSlIblnfJEo4PSWj8UZuPRZgumM4BSkJLKbRTnzYNu353mYS3TvZTLSYVq9iZnlbXc6dzn9xTTuESblI6ki6oBpCGN1Ls8AcX1LN8fYqnm2xwBcKvhxG5VF7aq7EzjWF9gcaqcwBu1O7tK77lVFm-9FFdMTvY5D-13R2zBWr8bwT_E3DzRfih8kCcpt1pMxVErnjxUpUGSNEzqlqdeeFi1ifEHr_OBGIFX_zkRj0oO_DI-Zvxq0LvRxJ-Vn9wwuAohytcEAe-pRLXm-ne5yFU_Zoabv5vx1ZxDofFk0pkdJPjgzDvn8FyuRlyrgf0V74dDjEsnjf_eBPtXb74mh4Jr6N7YAp6ok-xzjypQLN3vjqBUrSTUrd6-pChQXXqgBlnyExI5wCjLTSGcZUoQjsN2wJxubv9JYtRPA8khsRIqMspNzD4qspLHfHLvMpsVmHraLJbPl_1m00 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 89fe990a28..c5b6272142 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1060,6 +1060,7 @@ class Notifications{ * type : enum * updatedAt : timestamp with time zone archivedAt : date + displayId : varchar(255) entityId : integer label : text link : text diff --git a/specs/actionable-notifications/index.md b/specs/actionable-notifications/index.md index 7ed51fcb5c..44257f7a07 100644 --- a/specs/actionable-notifications/index.md +++ b/specs/actionable-notifications/index.md @@ -35,6 +35,7 @@ Points: 5 - type: NOTIFICATION_TYPE[enum] - link: computed link - label: label for link +- displayId: displayed in the second column of the UI; it's a whole report ID like `R01-AR-1234` - text: computed message (see notification configuration, next section) - archivedAt: Date, nullable - viewedAt: Date, nullable @@ -71,7 +72,8 @@ const NOTIFICATION_CONFIGURATION = { // whether or not we display primary button style or outline button style ("view" vs "take action") actionable: true, linkFn: ({ id }) => `/activity-reports/${id}`, - linkText: () => 'View AR', + linkText: (metadata) => 'View AR', + displayId: (metadata) => `${metadata.displayId}`, }, }; ``` diff --git a/src/migrations/20260601000000-create-notifications-table.js b/src/migrations/20260601000000-create-notifications-table.js index 9c2c96648d..b090f554e6 100644 --- a/src/migrations/20260601000000-create-notifications-table.js +++ b/src/migrations/20260601000000-create-notifications-table.js @@ -74,6 +74,10 @@ module.exports = { type: Sequelize.TEXT, allowNull: true, }, + displayId: { + type: Sequelize.STRING, + allowNull: true, + }, label: { type: Sequelize.TEXT, allowNull: true, diff --git a/src/models/notification.js b/src/models/notification.js index 5721f53dde..ef89b33169 100644 --- a/src/models/notification.js +++ b/src/models/notification.js @@ -40,6 +40,10 @@ export default (sequelize, DataTypes) => { type: DataTypes.TEXT, allowNull: true, }, + displayId: { + type: DataTypes.STRING, + allowNull: true, + }, archivedAt: { type: DataTypes.DATEONLY, allowNull: true, diff --git a/src/models/tests/notification.test.js b/src/models/tests/notification.test.js index c62fd14498..b7556e140b 100644 --- a/src/models/tests/notification.test.js +++ b/src/models/tests/notification.test.js @@ -31,6 +31,7 @@ describe('Notification model', () => { link: '/activity-reports/42/review', label: 'Activity Report #42', text: 'Changes were requested.', + displayId: 'AR-42', }); expect(notification.id).toBeDefined(); From bfc17c4263e86d6f4580e36e27c7de1acab016ca Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Wed, 3 Jun 2026 10:12:37 -0400 Subject: [PATCH 12/23] Accomodate missing column in services --- src/constants.js | 2 ++ src/services/notifications.test.js | 4 ++++ src/services/notifications.ts | 10 ++++++++++ src/services/types/notifications.ts | 10 ++++++---- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/constants.js b/src/constants.js index b3d38a9f58..1ef4659a0c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -212,12 +212,14 @@ const NOTIFICATION_CONFIGURATION = { 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, }, }; diff --git a/src/services/notifications.test.js b/src/services/notifications.test.js index 9ddc2fdde4..3931f62b72 100644 --- a/src/services/notifications.test.js +++ b/src/services/notifications.test.js @@ -20,6 +20,7 @@ describe('Notification service', () => { recipientName: faker.company.companyName(), userName: faker.name.findName(), date: '01/15/2026', + displayId: `R01-AR-${id}`, }); const outageMetadata = { @@ -93,6 +94,7 @@ describe('Notification service', () => { ); 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 () => { @@ -112,6 +114,7 @@ describe('Notification service', () => { ); expect(notification.link).toBeNull(); expect(notification.label).toBeNull(); + expect(notification.displayId).toBeNull(); }); it('throws an error when the notification type has no configuration', async () => { @@ -139,6 +142,7 @@ describe('Notification service', () => { 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 () => { diff --git a/src/services/notifications.ts b/src/services/notifications.ts index 141c1e1d25..f4db17fcbc 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -40,6 +40,10 @@ async function createNotification( ? notificationConfig.linkText() : undefined; + const displayId = notificationConfig.displayId + ? notificationConfig.displayId(metadata) + : undefined; + return Notification.create({ userId, entityId, @@ -47,6 +51,7 @@ async function createNotification( text: notificationText, link: notificationLink, label: notificationLinkText, + displayId, }); } @@ -75,11 +80,16 @@ async function createGlobalNotification( ? notificationConfig.linkText() : undefined; + const displayId = notificationConfig.displayId + ? notificationConfig.displayId(metadata) + : undefined; + return Notification.create({ type: notificationType, text: notificationText, link: notificationLink, label: notificationLinkText, + displayId, }); } diff --git a/src/services/types/notifications.ts b/src/services/types/notifications.ts index 8530153b58..637e01d147 100644 --- a/src/services/types/notifications.ts +++ b/src/services/types/notifications.ts @@ -6,10 +6,11 @@ interface NotificationScope { } interface NotificationMetadata { - id: number | null; - recipientName: string | null; - userName: string | null; - date: string | null; + id: number | undefined; + recipientName: string | undefined; + userName: string | undefined; + date: string | undefined; + displayId: string | undefined; } type NotificationType = (typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES]; @@ -20,6 +21,7 @@ interface NotificationModel extends Model { type: NotificationType; link?: string; label?: string; + displayId?: string; text?: string; archivedAt?: Date; triggeredAt?: Date; From 0d71b6bd488d6a5782c3366386e33c4f9b7ccc5b Mon Sep 17 00:00:00 2001 From: thewatermethod Date: Wed, 3 Jun 2026 11:15:59 -0400 Subject: [PATCH 13/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/types/notifications.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/services/types/notifications.ts b/src/services/types/notifications.ts index 637e01d147..b45c10be37 100644 --- a/src/services/types/notifications.ts +++ b/src/services/types/notifications.ts @@ -16,16 +16,17 @@ interface NotificationMetadata { type NotificationType = (typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES]; interface NotificationModel extends Model { - userId?: number; - entityId?: number; + id: number; + userId: number | null; + entityId: number | null; type: NotificationType; - link?: string; - label?: string; - displayId?: string; - text?: string; - archivedAt?: Date; - triggeredAt?: Date; - viewedAt?: Date; + link: string | null; + label: string | null; + displayId: string | null; + text: string | null; + archivedAt: string | null; + triggeredAt: string | null; + viewedAt: string | null; isGlobal?: boolean; isInformational?: boolean; } From 194a776549b95494204422e40f6089073859f830 Mon Sep 17 00:00:00 2001 From: thewatermethod Date: Wed, 3 Jun 2026 11:16:19 -0400 Subject: [PATCH 14/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/types/notifications.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/types/notifications.ts b/src/services/types/notifications.ts index b45c10be37..86915ff7c9 100644 --- a/src/services/types/notifications.ts +++ b/src/services/types/notifications.ts @@ -1,9 +1,6 @@ -import type { Model } from 'sequelize'; -import type { NOTIFICATION_TYPES } from '../../constants'; +import type { Model, WhereOptions } from 'sequelize'; -interface NotificationScope { - id?: number | number[]; -} +type NotificationScope = WhereOptions; interface NotificationMetadata { id: number | undefined; @@ -13,7 +10,8 @@ interface NotificationMetadata { displayId: string | undefined; } -type NotificationType = (typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES]; +type NotificationType = + (typeof import('../../constants').NOTIFICATION_TYPES)[keyof typeof import('../../constants').NOTIFICATION_TYPES]; interface NotificationModel extends Model { id: number; From a68e39b330d99b7b8ff3b3147e2df1060221d2a1 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Wed, 3 Jun 2026 11:19:32 -0400 Subject: [PATCH 15/23] Add notification configuration tests --- src/notificationConfiguration.test.js | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/notificationConfiguration.test.js 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(); + }); + }); +}); From d9c6a39e5a3fceb86cbf857ab7385af3567df1fd Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Fri, 5 Jun 2026 09:31:16 -0400 Subject: [PATCH 16/23] fix: replace generic deleteNotification(scopes) with targeted delete helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteNotification(notificationId) now accepts a single ID, throws when falsy — eliminates the empty-scopes full-table-delete risk - adds deleteNotificationsByEntityAndType(entityId, notificationType) for event-driven stale-notification cleanup; throws when either arg is falsy - updates and expands tests to cover both new signatures and their guards - updates spec doc with typed signatures and safety-guard notes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- specs/actionable-notifications/index.md | 9 +-- src/services/notifications.test.js | 84 +++++++++++++++++++------ src/services/notifications.ts | 46 ++++++++++---- 3 files changed, 106 insertions(+), 33 deletions(-) diff --git a/specs/actionable-notifications/index.md b/specs/actionable-notifications/index.md index 44257f7a07..67c517b0c3 100644 --- a/specs/actionable-notifications/index.md +++ b/specs/actionable-notifications/index.md @@ -197,12 +197,13 @@ await Notification.update( ``` -```deleteNotification(notificationId)``` -Deletes a notification with the given ID -should not be called via handlers, only programmatically by the scheduled job (#6) +```deleteNotification(notificationId: number)``` +Deletes a single notification by ID. Throws if `notificationId` is falsy. +Should not be called via handlers — use programmatically only (e.g. the scheduled cleanup job in Ticket #6). -```deleteNotificationsByEntityAndType(entityId, notificationType)``` +```deleteNotificationsByEntityAndType(entityId: number, notificationType: NotificationType)``` Deletes all notifications for a given entity and type. Used to invalidate stale notifications when a state change makes them no longer actionable (see [Notification Lifecycle](#notification-lifecycle--stale-notification-cleanup), below). +Throws if either `entityId` or `notificationType` is falsy. Should not be called via handlers — call it inline in the same service function that performs the state change. ```getNotifications(scopes)``` diff --git a/src/services/notifications.test.js b/src/services/notifications.test.js index 3931f62b72..138892f75c 100644 --- a/src/services/notifications.test.js +++ b/src/services/notifications.test.js @@ -5,6 +5,7 @@ import { createGlobalNotification, createNotification, deleteNotification, + deleteNotificationsByEntityAndType, getNotifications, updateNotification, } from './notifications'; @@ -194,43 +195,90 @@ describe('Notification service', () => { }); describe('deleteNotification', () => { - it('destroys notifications matching the given scopes and returns the count', async () => { + 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('returns 0 when no notification matches the given ID', async () => { + const deletedCount = await deleteNotification(0); + + expect(deletedCount).toBe(0); + }); + + it('throws when notificationId is falsy', async () => { + 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 otherNotification = await createTrackedNotification({ + const differentType = await createTrackedNotification({ + entityId, type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, }); - const deletedCount = await deleteNotification([ - { userId: user.id }, - { type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION }, - ]); + const deletedCount = await deleteNotificationsByEntityAndType( + entityId, + NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION + ); expect(deletedCount).toBe(2); - const remainingNotifications = await Notification.findAll({ - where: { id: [matchingOne.id, matchingTwo.id, otherNotification.id] }, + const remaining = await Notification.findAll({ + where: { id: [matchingOne.id, matchingTwo.id, differentType.id] }, }); - - expect(remainingNotifications.map((notification) => notification.id)).toEqual([ - otherNotification.id, - ]); + expect(remaining.map((n) => n.id)).toEqual([differentType.id]); }); - it('returns 0 when no notifications match the scope', async () => { - await createTrackedNotification(); + 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 deleteNotification([ - { userId: user.id }, - { 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', () => { diff --git a/src/services/notifications.ts b/src/services/notifications.ts index f4db17fcbc..510b878711 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -124,19 +124,42 @@ async function updateNotification( } /** - * Deletes notifications matching all provided scopes. - * @param {NotificationScope[]} scopes Query scopes combined with AND filtering. + * 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. */ -// Deletes a notification with the given scopes -// called either by the scheduled job or by a handler when -// a user action invalidates a notification (ex: a report that is "un-submitted") -async function deleteNotification(scopes: NotificationScope[]) { - return Notification.destroy({ - where: { - [Op.and]: scopes, - }, - }); +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 } }); } /** @@ -168,6 +191,7 @@ export { createGlobalNotification, createNotification, deleteNotification, + deleteNotificationsByEntityAndType, getNotifications, updateNotification, }; From 42b970cea6e4377c7aeb3bfcef084c7a2e949655 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Fri, 5 Jun 2026 09:52:33 -0400 Subject: [PATCH 17/23] Fix bad test --- src/services/notifications.test.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/services/notifications.test.js b/src/services/notifications.test.js index 138892f75c..d29c5e4dab 100644 --- a/src/services/notifications.test.js +++ b/src/services/notifications.test.js @@ -205,13 +205,8 @@ describe('Notification service', () => { expect(found).toBeNull(); }); - it('returns 0 when no notification matches the given ID', async () => { - const deletedCount = await deleteNotification(0); - - expect(deletedCount).toBe(0); - }); - 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'); }); From 64bf0c5b9d0a5a03992605f1253ca4d5e2d478e3 Mon Sep 17 00:00:00 2001 From: thewatermethod Date: Mon, 8 Jun 2026 09:05:54 -0400 Subject: [PATCH 18/23] [TTAHUB-5387] Add notification handlers (#3674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cap getNotifications limit at 100 and add comprehensive tests - Fix Math.max -> Math.min bug in getNotifications limit calculation - Add handler unit tests (src/routes/notifications/handlers.test.ts) - Add policy unit tests (src/policies/notifications.test.ts) - Fix and expand service tests for sort field/direction validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add param middleware * Updates from code review * refactor: separate per-user notification state into NotificationUserStates table The Notifications table previously stored viewedAt and archivedAt directly on the notification row. Global notifications (userId=null) could not track whether different users had independently viewed or archived them — a single row cannot hold state for multiple users. Changes: - Migration: creates NotificationUserStates (notificationId, userId, viewedAt, archivedAt, UNIQUE on notificationId+userId with FK cascades), backfills existing per-user state, and drops viewedAt/archivedAt from Notifications - New model: NotificationUserState with belongsTo Notification+User - Notification model: removed archivedAt/viewedAt/isInformational, added hasMany(NotificationUserState, { as: 'userStates' }) - Service: updateNotification → updateNotificationState(notificationId, userId, { viewedAt?, archivedAt? }) — upserts per-user state row; getNotifications(userId, scopes, options) — LEFT JOINs state, filters archived, returns NotificationWithState[] - Types: added NotificationUserStateModel and NotificationWithState - Policy: added isGlobalNotification(); canUpdateNotification() now allows admin, owner, or global notification (any user can set their own state) - Handlers: getNotificationsHandler passes userId as first arg; updateNotificationHandler calls updateNotificationState - Spec: updated to document new table, service signatures, cleanup logic - Tests: 121/121 passing across 10 suites Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update to remove findOrCreate * Update seeder * Define relation in both directions * Update test * Map scopes to types directly to prevent drift * Updates from code review --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 28 +- src/constants.js | 6 + src/middleware/checkIdParamMiddleware.js | 4 + src/middleware/checkIdParamMiddleware.test.js | 51 +++ ...1000002-create-notification-user-states.js | 135 ++++++++ src/models/notification.js | 18 +- src/models/notificationUserState.js | 57 ++++ src/models/tests/notification.test.js | 39 ++- src/models/user.js | 5 + src/policies/notifications.test.ts | 81 +++++ src/policies/notifications.ts | 44 +++ src/routes/apiDirectory.js | 2 + src/routes/notifications/handlers.test.ts | 299 ++++++++++++++++++ src/routes/notifications/handlers.ts | 111 +++++++ src/routes/notifications/index.ts | 26 ++ src/scopes/notifications/notificationType.ts | 76 ++--- src/seeders/20260601000000-notifications.js | 10 - src/services/notifications.test.js | 257 +++++++++++---- src/services/notifications.ts | 119 +++++-- src/services/types/notifications.ts | 28 +- 21 files changed, 1200 insertions(+), 198 deletions(-) create mode 100644 src/migrations/20260601000002-create-notification-user-states.js create mode 100644 src/models/notificationUserState.js create mode 100644 src/policies/notifications.test.ts create mode 100644 src/policies/notifications.ts create mode 100644 src/routes/notifications/handlers.test.ts create mode 100644 src/routes/notifications/handlers.ts create mode 100644 src/routes/notifications/index.ts diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index aa95e697ee..f54099b2ab 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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDj5634NqK0BVEmkmzmuoTxcxWrrmKsw4sZrQOpfbQnCTWnCNGim43QRn7jpJcmdFUwRevsCkFCDb-alnHxrndD6jz9KzKaDyJVkOJcFPUJ-UBLeVm-Cpc0weSru6MyFbVS4xwx6pwTW1GSpuRhDU4Plj_tgF26Kg2dH8sTVr9VS84HR7qwNVONRDJtvXzMZR5ZIF0klCYmPNcHSEhZ4l75hXGQVW8IzWOqtg5WHp91yldDKYUrrTsKjkI2D2O8VfbE8s0vhZqOtwckT_mM_SNqgdAlbSL0tBfISebvxruWcqeph5jj3x177h5TOBSYTV0rjcyWtlx-LwFwcJWbwFDRdm-0QpcD--Z0E1z-X7poEBzxvaDJtzlcfjN_YSDhyiTt1NgrZxaT6lZ8My5yPJ_Wm1YNNm6uds_BcMiZ5VKZBwIby3aPF6lMw-EJtblq7Edrvx0EGHrJiJW5tWMbcFHSQyae-xNH1Y7Nw6F4VMrJ1K_cV_y07gHmRidFOfkXUuUDG-4IgSd3VHwplQ43I2HwXQPsA5dQsSAN05K3tdDfMKrZjaRLWsYO2U6nLam8u_gbB2TAuLgg1t4Fjb41wVBFWbvPXt2xw4Dv93tta8vOvevpLjxy4BrgTWmRufI-zUvmxqhlrIQZg2P6c_FEAsUbMvyneAeTC83XpgAnlZxKbxNCQ4qLxX8u_ZFiEuK67BehTzTTWSnylzYuGXyZApNwtMpunxhkexvN_toNL30Ah3hlxMQjhdlmBqL6wo_Utpqu4rLoys1JTKnDgV4s1_pTZlimEwTyWY1izA6EOcVuo1418evrbMGKhbczlQC406VrTH1fHPWeunq3NibXTLba01SMFcXenrOiW_08A0E0RPSJM21Thme0Jy5JN0XW8GF87uDopbVL1XXbUMu3__ciHPj84TtZz0Gb01rvv3JSTYuXCLOU8u-xXuAAs1_PiuatN8Y1OVpNy6kO3te8lFS3VyuaDyJ_wWmpNBa-zdMWqP_WXzfnRcEpI7TbyRYZeAZsa0zhY2LqUuLZMFSdiAAQ_Dm1B65HLly4ugFDRNMFhrlINOTHOjsMbsTdLrzkU__atjEmAyMr7gX0SjuKquQzWU4t6JuWbheXhfiMVmAqdtk8RllB7lBFiQeoRSc4R6RU76UhUrQGgooIfjxFTSNes0yda1wFuE0r9iidyqe4JiyHPGsX1kqdRRPIgJ79ENiByH2Mf0gJSKbwh5bhx5YAWKo4RtoQU8QFcNivUjaY80b4wZWKbFHVQNirUeP924cPuqLUtLqWWRpj57aFvozgQxnEzhpuTKlbnWlJLAYBIgl0MqbtJts4lgW9pO5tZP7mKR_hgMJZBjZJeCdNYqDVnqI_2dMrksncNXIMrFLBSKxJmzySOsSsWlyKc2QHIc0Gkg15KVAaUvc23gGpJsc5B2ou-NvzegGUM7Aa4q2b5d5jj_EY3XDOQN-rXnGci4hRGFXcEL5lSul5prR09h1Ao4qoXEQUu5mPJWSU19qjCGC49NqAfNsZ74zVLfXeqYbHHHqo3n9mEujrJ2Q1YZT0AU04ZwIIM8HY3gZ5GJJ6CmsIxg2I0Ne0IE5nnvZ-9Ot2I0N82SznGJG1AWgchgrYxW3TubDLi0IEasF1If150FeAJqzy3oykMR04YAY8OLJiDoN5MWBcuPsFLCONXX0Q9ksc2RPLY9MrJ8i3DzLqIPxxNTao_6tNeDlARmiHz_JigYC9mIFWu-heV2Q7ov19aFiWIG0r0I6RsWY8Ztv_8TFvG0SW7YUiLa1ZW1KB9gcq8eNRUEWDC6TDY3UfS0yW6aNCl5FBiac3zld70Qncai2cD2oqKhhKq182LmFE0aK5nGUEIBfFKOGoUfFW90CbvYIlXuyBc2XnBai6e3poMG9a1A0Fa0IgZqKC7D8MaxfX1AWsSGceIAWyhhIbbm9ltp83m9ca5e4YmEB6y1AY59DXjUPFZRPZmCIeB70uyE9NFEawwRgWBfnciCf_PoC8axGJ1zZoXBLwZXI3rwN_DDa4r3beRO95vfL8MWv7cPDI4rX5HQgwAmIelK9509eK64XCm9OPfmeoZ9AxGMguJJ14vfC0M0b3QwIAZIl9l2AG4q290ImDDy2u8fGUH0bFnY8Y5AyBZmm1z5G8Ta13e1K0B61SQCr9_0iS4negScQvffb9ALzHH22HaN6VyZy0aS3extqVa4ZO1cyCNvX8-1Jnet3YufcCumChkqqFXWIISgFp2UfzCJCWgP18e3YliOUq95e3Brl7k2oGzJSkgDWoiPVmRXDF4UV8BD3rl_oVY4b1-f194BaUTv-8GCHoO9rpvKFC-z9s0KCCElnIUC78mIDXviXj-eJnWfZ18e3YZzjzSJBXmN_nFi2Hk9ZVke_ByrBfjliZDASOwa6SPcin5geEpajoVPy-T__SLT2VAFsqC9KBrbUvH_h26b__tjtvu_5qKCztjUB_rhczsZ4cxauJ-Kc1kvA3lDTZnfA_zqEHkn6eQIjLvMoKupqB0z9jki19HzE1IylryyRZOZBxSxd0f0zx1mMFsKPIUrOcQyL9ATPhcRjit08BEDvs7c2FvJBuRpzlUAR3rfzcUhrqfVRWHNI7-n6NgmStQ5-ayxzsgbYso7ClItnwoVV5LXJktabqUicre4OciclZjYlOP7sdJTdcBCoSFsZCh93vsQpQkGkfdkrS9ahWgjmj5wkz77LkMjnirUqSSQsYkzMXtySsIRaIvtDLvoZPvgfoYr6azZNamnxegkndrufhlCX-Pr36zjrvkTPbG__ATJUQUsCyP8iwknwVQRLRUklFt_BIvW6R1XpQd6q4ijsMbM36HjTSLcSRHZeohXq4lTalXYXJVV4xnQYdF7aynUXwEn73hCz6Au1X_r6coFJT5chC95BJys2s8iEcjmQHyb7U3Jvgx2oNZRiySjzvTRXs-xnDWSAK_Tz5xzctbGaT-UPXKuq1livcn5-Yk2gzutYshGUnc3JsadRMiTdZY7PInxyId5Ck8ahipKRygVdRPlQ3aPNkREOtDHtUnvzr0C9pjzsqkHqiYQl2qvSxe2XiIfpZqh7g1oyT_aUIcUSyUYcpQIbPfU_DV63MDzbArtExWNB-7l3SEjzcCtlLU5JU-XgGZM0UzwSQqPBxMLVyOBBwgCvuwTou9cAcrhuTMgnPyRxeLgpJmjBAFRExoR39mJUYodePL7il2lDh4y1epOkbuGFrjbv7BYNXiTpioNhhaw3sJ5G6ZCx7kBpcCdxTBDq0ilwweD3u9e9refpU0At2Vu-JWj5Kjt1YrRW9F2wLnUWsYuB6Nprc7KTuFhiTkD5b-b5kRnUvXvT_HUmbFne3RnsJGdp3pTPTtAz5HXkTPc9xJWcF0x9L0EwSI5bW5GYdOrB3o4eYip99DlUvBvFDgzMIw9stCybgWxy6HbxO4MIzDwKapJfMRgBBfYPGC4rkqzHxv7PJ6lhonbcpvBjCDnqxjC2oSy5EYMYxG3MVqhkbNus_6fBFvsrW1_zvcFM26tztqdr3jPIhftabUlXfQppRHjBwdZlVX2eBs2zTm3HDFNYrjxgok7VJGLhnvmwwW3Pz8TEjPi_QURcLoS3YNoP_6Tv2H_gY1sMplRxdUxg9atfsRwQGgb5P6_shR7KtHTPQLKPoKjfKqxclFhEUIrq6txBX3yfOfLz4xRllg9pgHlTztMxC6DQQSHJjPvUyAny41sRiiZdhvDUslme1id97tC9k9yUPenWdOxFdTVF6wx7vk0wDApzYutghUrU_MYmcxxcVhBfwcYZWtjgwXJH4b2OgVrnqvUKY4zakyxWXw0sh6zFl_HFellhSNrqU7B1iQGpDoQS1sX5bxkKrnN6gBTUZmsOuO6n4gL69iWOJDZfOeCzOINjHa9TvKks7K4G_fWCV-8Gq3buxhnnd5T-ppKaTuEdf9MhCdeIkMloyX2jRGsartariN-OFMQ-Af0jzvQRKO2s2weWNUjiBC6vkkQjHZ0mSlN2YMwrcv53QBRaRrhrNhujqjZdpHhjN7YEwv-6LLRgM6tKlFh55Q7-eWyEqE2TmvNfJfnocywIwfuwojJzrUtQLlzNwS3zAZ8LzIrOpTnKk6-g3w3chzUFJPhMgfQt3hbzBIbweYQsZ03d5NLQUEAhT_EOYc_Jf5hj5zeSyqccJ7cbYhYi2Rsw5N7pMLczw_9f1jQcvugPv4MIAq4bprja5mvtFIDrlQieNpQzhHG6mFebUnRjFNdg67BBBvQ4ZTuxAJOtORIcZtN9LLEmvtCSsYxdZUAiddTEeUkUpUdw9fhxYow7R0jmRceUdS9HHNtjuDu3aQ4HeFqeKkTGLE92mt_kytsk8AIoARRPjTo-Y7j_-EkFilvUlr0XfXx73-Mro24yJ9-mzZk5gCvTCDly-ZVxi_XvHFgEZvzeMuE3vDsTvUdUAXBFFbx8ptubdd_4Lp2RTy7C9fs5_LE4oKwAPgQ3cRHJlVMBv5JtCY0QRFN8ie_jvSYdwNSYbPUsYhQCFfQdUUeGOWeeIjW5Ls_8ixTbBS6SkDQc_MW0Pot7xobLw-JYW0r3eWgjMcdq7fkUup_C-Ma5ytLckqj2sEwHzFJjEGAToCfgWMmfMe6AObSIzpuBw8tHD9Mh2zkljU5Uvxh82Ulp19r-aBwxcdIv66V8fVn6VH7LypY6Gov5CacvBvSqyDe2JlRHwPpYXlsa8ddvheZTFYvnwnuwc4AFGbaY1elbh_F-avowrlVpQ7j6hLrjAsSUDe-yuQMUD-w8PRwM2_KAw1klKFIKJPJesaEggKayG6DL2mNM-wIf4snAvRUwERAvB_z7NKv5p6BsnLecz7chaAC6QpQjVACgXKhdgUEzSTTVmYKdbPKfqOJTENdRIT5pwBbsJyxieitJr-4BStXmSdOgB1A2xq97reoVO-sMIpq9qg6-K7JLsACIfa8vbAVPFyoU6B6F3oL4APEMzHkna14b73IdYCk3N7EDxNMmTnypk4qs2D_7al7quxst8fMOMDw5Hs9HL1NhSlIblnfJEo4PSWj8UZuPRZgumM4BSkJLKbRTnzYNu353mYS3TvZTLSYVq9iZnlbXc6dzn9xTTuESblI6ki6oBpCGN1Ls8AcX1LN8fYqnm2xwBcKvhxG5VF7aq7EzjWF9gcaqcwBu1O7tK77lVFm-9FFdMTvY5D-13R2zBWr8bwT_E3DzRfih8kCcpt1pMxVErnjxUpUGSNEzqlqdeeFi1ifEHr_OBGIFX_zkRj0oO_DI-Zvxq0LvRxJ-Vn9wwuAohytcEAe-pRLXm-ne5yFU_Zoabv5vx1ZxDofFk0pkdJPjgzDvn8FyuRlyrgf0V74dDjEsnjf_eBPtXb74mh4Jr6N7YAp6ok-xzjypQLN3vjqBUrSTUrd6-pChQXXqgBlnyExI5wCjLTSGcZUoQjsN2wJxubv9JYtRPA8khsRIqMspNzD4qspLHfHLvMpsVmHraLJbPl_1m00 \ No newline at end of file +xLrjRzsubVwkNo6u7wODRefskbrWZUwYZkFC19iNmtQcQ8l2e2NwNZQIg9AKC-vi__k2f4IH52MHagwJJFd9lYJ5S_ZmxV3mdCC_KWO8QyeYITDlKl20KPxM1DyLiAJf9yGEAQZs1SpJZE1FDBs7a2LfIZ-YgWO4b6c1AiWUQvXYmWxOoCeAG6dwKvAcQVe2anoISrnWalxYTx_xpxF_-esMVdD0sSSAKff-cwJflmGhv_nhI9EqhUSiO-W1iSShcA4QmOhHdvBqMOhoz3H55ODxIDBJz0zxM4426Cp_9qdt15JijLdWwkBLnTN5k_EBw_JaEvvFJtwDyYJ5x039HSKGUvpuuhqgXM3xuRCKACvgJARedHa5rVSPWPpXCvQUfWoLa39GrX9x_ZFz9wvWUffM-DClVyNBDtNkN_vVIMBZnucVrJN13v3CWxY-VGs5PEAh3nIIfy4YpM41vqedQOKP_uR17rcIKWPOGzFJ55PDEORcEIuAS8S9OFZV2z4HF5wX0Zu53lUeW1DV2JP2SF1j_rYxlyEWU8LWaLz1MkEABW21L-4D12-i6hZ7eBY1PLq01WKJbmINzUJwv_xYE92bfCMadLf__TiKYbUGuePaJmeWQMbEVAtOEWv4idBH5TsI8s7Y_ucVjnDGtHgV_vIa5IWbxY8z4PJk2Kzig_IlxG__z_pvL3UDz-Hy3sA2zgW2nKpF6NsrTkNO1z3Qje-p0bDutT0QLlavhlXKK9kDddS50SLLj_6aiCfV0h4lSI99YgXe6ImUBtB486_A7SO51FoJUr1GKUQACL03LGaV4FtE9dfEvQcDxW6Gx038Bx_z_Uz_fOsPxhkHIVl-xkzGC7IE6608LpupDslBJlI4ggxlUO9L6-o1eBZGQzGfQL-6ReptgCfHjHSrTtMqQJTS0EvJ1tAdIdpNb7UYYLtH4bU8OOAg_IjPrRNeXdTwHpqp-uqlLBojBs617Y1vR8T5d_c0u2Uh0WeV847iqMI9Cb3bWEGsgs52LOl1TJUS_4O9RiYIxT7PmPDJNdtHpsT1UshCIUO-fmIbk5ucIcq1qvScN2aEbyi6My3o5Ke9G-G6ee8k9B22_s7Wak07bCDyH5gOgzKXCWm_cUdg6roZxFFqC-Ea9xwNh7sM2U0lKla0CMpwMk4ABw9wtAC_8E-vfidWvxUBfJ7LmdF9zFUGm5u26nMcQ5Dpe5fnGLObvfkYgKqxbTPGkGkuHBo6mjTrtcvOZW8ZGsdv4Xe-6hx21NDydRmuV_E_vohx0uLvae8yHros1CKxpBZ6CfC3YMkIAjS7Qh5rBwp-w-dCCZhVkmMWx8F4yCMZdqOfHFT4u7vQkhyI1HOl0LpMfLAgx-l_JVZYy2pbgb2xecyJRUotqz-Spy3MIRSpsofbZWyrhu7KmSwDthxO3IfXoxX98GD5rd0jJdvjg2kxaTWlQHnWJAvmqIz0UNkEU0lP0UU3klE6T2Ud76Qe1CKlVxVxxQzSKw7Nx0oFgoKVaAB8tockxlZGg_ODsDkDuHyNS0yoEHnDNfppIQDQYUFL-_jBG1ZAKCtL-_hHnfN_khg4XEfTxoxt0oAi0KLXdA5gi8SSSjYsYwSOfEscrGJl2IZvE75b4zSuand_Q21bObnQUmg_lp6mfirTYIWVpjPe41cCGRDh621xo2vnpKSGduqEya8OScOmu1ZpFET1ujMTKbqpGsrE_Zg3zh1v3XZSO_8uUJxHIwBbSaPByhPfa7xqB-_HgcEpM3iCeK6ctB83mSt-eFgddzot7IjCrpTe-KhiQ7DifOUGQFBRNEz-lpkHZd8EGSwl_DJd-mRbeChWRTPsYIA6AiGGA6vpo20fKMMoqkLMWF3rvG4ESgIMuxPRdaok-q9Sss7s3T_KkepsQrq_nWimIDa8OsqcLn3UShmQ8TcD8zEM7xQZpPXiXYepR0K1dzpzpJEiZfOqx9wLQ-ImOpgrpwqvW5MolcFqKrHSSRMNX-ss9rO-BpsVzl6atgKlDZYC26BdE1_kVbkBYrupmWrCmAtg6aosll_R61kl6rIzOIy10tmhFp9icKcoATNE-EKC8RyhatKuw3U5djU3dtCuEN6I4lcnaV9jhPJRHEtTW3es0DSrp10e46N71e4gIb_mckF6YC6QFicYmYczX0oW8l26AU3YK14wnsND8NaPBCSvKJgAcY3yy1-ogHrZ0nmzDdbtqCGfF6yofWxOwjmDVGO_pGYg3H5LFvBZvgw0QKrW9ZGXmvFt0T1kUzLbu0EWOglHqn0LGbOdtkbPxGsaANW9cpcjSicTIBLEu39dvcmySf9beQDQsc5n7KUuUbElmLlLQPX_gU6F9w_F17l7lJtGsH8AXraqIb3J6pop_caLTlm_W0BbhoOH-w4jilGKxXo0pxW-fGPxmE3xwYqaUsYem1bfu96C13MGoxAfK2Ocv1kyzxslpQpl9hSTi-WNT4DhinPWdBiD1AGZEBdFT4mzxOt-W6aFJ8wniUM0WLxl6jx5VfOQisl7AfiAomAcvkfwokZxwaDTOE2qVZhkl7_PAPQkXExcs5PFkKbkubEBiIUwNjMKaAqoowcxcbIvjH_2AX_HZma1eSiRwmcl7WZ4eYJhTBs2qTiCQtSelz_zXyeGLm6k3EJ8Q6L_NJZhqaprNuO9WHapnJu_eHCtBCFQX9lKORQPiCgTwQBJx1pHdSQSxhlOL6oAL5F_ELIMgh5myG0h9eMoIrHWzWx8YxTpM358nEhfCJoWLSl4RBC9gkbM6PGdQZ1PV18JVvEtTsx9AlG6bJFigcgqR_mFvfquhjtbyVE2TyFFFrnTNRoxkRrv_VRY-kRixQLhLyIVAXDnznas01e7sU4rQ8spGSC6bz4zYDxYfQTim4N8Q_HFQ3Rm8tnqZ8ZEg8ZpC39ewAAJizOwwyxk26kv9KaSKqRW1-nGrw3CEcFDggCSnx4Gu_QlpJchGDN75o3xg6QGWNQmyqq8L9_FmGPNPvqHo9M-cuiSAsMrCqwe-qq8XCUq1NE3f9y7JB12JmfUTcHh7NBcN72SJZSyKQeSpm-WsdlqMiYitzfLPiKXmx9cgk2dQxUX9IxoaPmQadjCIey9qbhYA1jCjw34CuGkRwZGD6QKQndmd5p7lgh39EVRkYa2r895RQCqxLJzTUADSTIxvyqtx5yFgEh9uBgpGEfCkeHgKTab2Hih9wwzoNW7mv7HnJP17sFdurwdEe49NSgNul5LcFawWPbRflMrUUPZjKOFohcwPhvBZc50gyeXN5bNKi_ujT11d_GjfvOxcPxy5kVe7ZsnWOcua6mldMR6WyjWKjWv3PG7VgONo-U8r91U0W8h5kBh6XB6ucd4fPf03SN5ybJBwWy815wA3frXNn5SNfU0C7g0aDqmS5a8ZhIxpIbVMCBSxY5hVMH5rnnRDxpz-5Wkegdx6Ckui4-tj7vy-JBBck0jqEwkrvN_h16LFiXlKFLnsmWw_akFsnNw6CP0WhyhM1SWWsNhdx7wAijPwKONMqTLHrbyUtQ_rTAvqJ06397f-NT1Gqf-XQhzTr_Kb5HDrBSX4-WXkk7YtDFSRbVTJ5nt1G2ggW75gqQeSD_YYXs6VaevSiaBxJ1sPVKx_VYIi8VSfwO-e4p2Do1h5p3r-MAGGxT6M-JrbfONfJJmcopah0XLmiv8RDop3T8BMuwdhV_G74k3OawcT8Eh2J5wrF3UBGvfqm0tzMVHVtF2asi5RkgVemQSjsjayN4SzeDMr31CZwJM43GslFrQiFdoayHbSrUWpF3tFW02ypyXiUQPVWwloyx3mVVkJbuIlfesxdgj2eKRkkgNTEbf-ithbnVlRbw_UdtnyflJqnEXwx2uRk_bJ_nH4hPfrJtkRpeDrGA16IRSRrsQWhP5Q7Q09HWERAsn7Xw2_aPn4kbPbCCjhl2dYfibdJ6kAT5zK3l_v9hW358wNi2_avPs4AjVJVQHXVwMyow7zyA1hrVgYYjuM86x0ewe5EvPVGqPGzNU-CQQmdm4vSq2HJKVDcy1_QWPAxD-kDQOialPPQZQS-jVdNM5r5k02aXCKxyxVcdEt7R7siPk6o8ljNLGMm_aWe4unTXsri_Il-CxXtUrC87dYkhLeY21j2cOyeccJSQHqnPC7R6AmOfxaFh0nWZQx_c1lNL3KANqIMlLgIPlxmSPwlIbsOe8zQT-V-6_RLnB6EwRmec4YWGc8jzUDsK9F4qWXkTfrrQkdVjFVdsYVNFIXmrJmSriI1LpKbFIrT9zTHAq_mYbsdbGwGzU6Fl7u0EK4epBzsGFAlHFEy21DBNpr4MMM5fJNSlWBeDgZ7qFLPIH9fl-mXtVRKRF_J985_3MZZldBJRQOV16HqfRSJTKr7SdQiX2E5_BTxAWCMpvToVz9ZcnVxzrXzVhQkk-FmapcNB5_BCkJhw4CSvz_MS7Jsiboe-b4EHlf6l_skkPpX_TS-XQ8IbNZkJl4ML-ncDR3ENm8IZvUvmOYK-u5jyRF1cnBJ__4FRra7FPb1lpSHYSmM7piE7WqpjtGx5vEDQEfJUVRfUNK7ACYR7mNF-rZEHDHk3MWyuLzlP9XjnrUyFyT4A9AnxkQ4qTUzf0RJ_-oeJX9noRFxjbyQZhOLm8L_NDawuRyHWIZo6E4-k6gvnzOZx-6YUF5srplFLGgm_rNvasIvRZeJa_-qx_uZ5uR7JbPlb_YeLRwuqD9uur5fvgtlnyP1szFLkBEWEiegGJt3C-NRB1WhXfht9FdjRw83HQrjVIjOv0JBf6IXkkRdVxCyP0TYXDc0Al1cG76Z9Ym8WRHcHoZ47Z7BpjIhVuSPhbQrs8qc5JTG5Y_ExRUhHenIpvyWu2TZ1YlrdKnHTUD7wTaMoNnOJuqk8vX5jFMOojjO8UQUSnJQ_wfi0VHMp74D-sglrcgklQ8b-NJzi0ZA4_6sOkVxQP3uKlsw2CtGLFyOA8zvZ2naje_EVD4bEDp15VinzxcA7xDgWNdxNUGR0hxSEfGQWL-m82FdCx8DGzKY456IvIw0bEQBRrGVTjqoBBd1pHkW2kzoRWu3rexdfu1_iWhzmOjt-yHtTELwPA3g2qP_2UtZfwsUjCFnyhwGMUub-A6-wDvODlf6_iYElZOZl4T1xBtkdpRDMsu1-MGlUxNV8WsXQU_YkUbm-TJSUEWwA7jLlqUDfYjzzxH2ZhWb_xb7rTNN97665eFL_w7MRN_C3hnqVRDgzvvCapB-hDCY_ipJ4lxClmg2_qcFEpjYRr2u8-aeyNnMkLcR9YzfBR3ud8ttwgjB3DW6PuSgE-vlbVtZxReoDzoUGhMHKirXuSxVgCXrwW6Smgj8NEbvTRPxX1R3_v2DewItTuVo_N-rbL77Gwwic5npMOnVnsPTG4tgCV7MzBthkIk-dfTj5h-yBbg_fbUS7RojJAZOnwNCtdcJIUycS8IAXVuqIGwy-rb4svq9B9nmaRYAmqxcpqpUaf_mqo_kBS0o2FgDhh0EmzryfgAY3ebNpJxe8Gu_0pvZ2pxRcsTBzsY6ketbOmSzeN-5xW_56vWdHHz9o9JfVPXg09F7orMJChx6ATL8vBWAebbyYaC9vVQwFHIGJKt2WX0sROrIx5JzLSeX8w1x5R1k5XsRmVa0vvhk5BU1hQztaBN-g8zixYvXwyOdto5koRed3kTVP0xzilfQOZI9RcWojsgpULEw-X43grcCY-Bi8XJgvqG6LA61qKFl8u7X8CYv2sBcZTLuVDSPpxlqWO0kzmXsorxdNSOLTTfV-xJwuR0b3bT-EvLTC-_ysdWxdPzhUlRdjotZrKvTrGySJ0adS5jWnvlpYEVOyiOl2iW7xH0Oun47pY0prBIqYfV6fSsKF8cFTTS8NE6aE5LkWRTikBTWPG7k5obaRAjKBdFm22W3W6cU54l0LQ_rWC_F4umCRYBZo1-3SkvNteeYjlBC5__ZL9N1tBU1Rl7o0XA8TRpw6c9p7pIHTOupZxk7WVhe73cpYTjC-85XyjV_qvX_UW3qjnD_xGHNXF_g2YLCkJxsTQ3Hd-25sd5kOwDFjsNnk1EmYFUG0Ek8HNHxXMrOzo1mefvsPa2UEAYZTuBnKUssiitVhU0knxGvxqD3sxkZhxyr__rkqx0hnRKUg4nshXJJXhs04JSPFYYERIDT9Xlk1MaXvm1TyvG_uPPaKQ9zo6I8QhuJQAjxeX2qtIKjauFfTN8w1udWDwkCE0v4dwZmOKY3tU0aeRmYtvRllCpX99foNCZaH2Dw2wfoFNgiNMFa69g1J89lV9fmZa-PUpbwsImWEKNgE1oK15zfUpLwXWaFYPdZHbvTJI2DFLoRUV_dBsfh_47shFnrI-N62zrKg8jBAu1NIGTFSuIEg1dDaNUDaV1Hd-g9XAqcs9UWojCBmL_7HByATRKxR6HUv8RKzKjnJj7JtnnZHpQ2_nIOHf4gOH2we8rH0gIxaO8Hf2DFM6LCJBZPVZsYf2vOOgGZG9KMyMEtmn8U8rXPVzHdD4QmAjk0_wOnKNzp2yRFLi16i2h8ZJ94vnxdd15E6nu8dIqnWmGbVGgbVQCMIxxqMa1aX5HTGyIFob1Euj5J4QnoYUW6S0advI4Q89Y7eZrGZJ54ocPtu4o0OeWkC4HzwZM1Ot4I0R8EiPoGYG1AWicwgr2tY3zr7DvW5Il0ttHIe2D00eAlqTiBny-SO3aZoWORdLi0oN9QZoMmRs0rCONbY0g9esscOPLk8MvJBApD0LKUOxRVVaGtctJWElENpCDw-ZiiWFfuYFXO_jeJ0PLYz2ve8i0cG154L69oWYqF-vVBuGHO1iWBXUCPe1pW0KhThc48hNhHFWj04T3g3QHu1S07MkvMAUNHBCNtMEE8tZD1M5CQ9benNN9W-G8ZWES28eDYWyyaNKUaoX4rKV7Y1f3r15V1puNC6w01aisy1p4QGPa290Be0YAWDKS5D7RSSSGd7mZ48JKR5GULrs5fnH_pmAJuHc59e8YmNBMu0AIABDXXCP_gGR3uCIuN70eqB9dFEawwOiGRpnciAflHoCueuNJDyZok8LgZzIZyv7Z2Dayv358JQBLnnLFYWv7gIDI8qX5TUg8ApY8ZKBL4He524XSq8O9zmaIl8AhSKguZI3KnnC7Y35ZIuGgZZlvd2A08q2r0dWSRvvmHGXiY2AVZ6144LuR7ZWZoAWGpG2dG4e0cC3eqNg5uzjn61NCoX5S2nmj1YpqUPe7gLeYpw7K4J6HeOT8BdWCG3YnUG0d4j8IYHAH6XiQEHhvvUGK5nLi8ZuEF69uyR29OOS6aTc7nSQ5opntEa6nl70V4IcGoQ2Oe5YFraVKH6e31vdZgRcNZ15r-SmkAA9h1TErqtD_qVL_emna1sYRw8NR3S4e2fUNdWW5I6o8L9lpKTaJ6IYSEr2XT_aH05x19ZsFVdt3v0Gb4UC9Z2As3Ym-K6-Tezm9Mt78aP3Z28e5YZ35-eZBXmNzqFr4Hk9pIjKC1XnaRWatwHdbE8CIJEC94c46YWz-Ur9-lpvNtznLu3iuwuk0Afsx82oZnMazB-__TlpvwA85gI0ywL-xT9xLC147lvzAxFZEwMdwPudxUIVFYSHgw5ljSRxC-bf2Xfsm6IN5e2o3tNE-_l5Ov9Cl6zlKSe1FS3S8lLJcfvK7J7jFPVqDCtij5qAHeabZkUh0-ZFlQlEspyeidEacPzjpfMwtF1cI7sHZ3rhkfYXuGFO0wvKaTrOXlhscFp9joUMS2U_F29ltI8GofduxeXyZO-qUTwUOip9m_O2vkI75cVB6EfkfWTSwCIxGfjGD5wuT97jyZQZPgxeKmqTbDwC1_SSsQMapzsCbboZvzefIcr6KviNSuJxefjH7v_fDl6nUHr2Mra_fwSfLSyZgbJUgUsCqT9CDf9-lzbqwyhQ_z_Y71OU6zOIDiRUiqbFqtmaQwSOMqJHZ23R2OVEsWs9BrHuSprKhAGuUpp9x6qeQikXHKmjXblGwTxC7P39sunDg7JBZqc2Q8lE5bJQfrj7wBIvcr3INa0TKClSvRQXsoun9aNAKvF1suWB3ohoszEq13MzmNREJgI_8hWSVBEAWy7x8NnAMsaxu_34AQmZEOZYAPOHAt5ZecvJ_lIqVMdCod0Ijvxa2-fcn-kGOZYPwRj1YfH6jgHjo9dp36WIPtVksV00bOd_PizBuPHx4lsqaQIwwSL_9NQmxwDilRlHlNWDVMUmycu_jjC7B2I3vxW2I7kyViQrPhpKJlwPBhoA6SSTEvS5zLJQSk7KgeMV6-w5Qaq_BQohTt1urmbO9_HGIcChZoJXNcr2QOmOiVMz87wsoiZrbJjjT3WnLxgYQZqZ5WAXDhFlZNeUUscN9cFlu6ohDgn5fRrerMa6Dw0-Gyz5QQrOkjVgCWKuAPmkfJMAXarIDcyRGaCtk06v_MJnJRZGZz_ZnFkZzngQ7GrgLhwupXapJtjPDp8TBApNsko55XmJVgS4YZ6TkD0o04hHleAvew9WPiPayjr0CbzMzs2MoyhDEHvQgJtm9BFcG0kjqtXuJTLBzTH5zOgL3H1OjVSSsG4L-RA-CfHjwYpH3iTaS9dcN_5OE60kqeva3Q_eDg5lHl4ow9jP8_Jjnr12XTp1zxrGRsLrqhsLl3aRIixMqtQ3f9xswWk1zWhMSGyGNBzQtDfRNJdiWsjnwW5tbZfmORrZnvgT-tIpa8hLeKJ-pB-JbEJFBjJVpims-zqigkSjuKmzuD5fHUIljktXDTgkibAgCvBMratxEfVpEUQrq6_xDX3yFOzDz4wxeVg9BgpVxBgTteE9RgVXNwtrGeTbuO7Ct9P6UFrctJkD1asimFqXcuZrvMdA2Dhl-TnzyRZhVoGUCRWNxLrsx7DZznh4pjqDCzMNJyEYYg3jOjGgqab2OgTrHvXUmv0QoVTzXGw0sh6zFVt9FWllhTNyg21l8sD8Pkvqk8x0XPFw7DMDXcY_7a_DcB41iH4JKnF4RsPinCXOJpX9w_62D-c2VL-XJn-ifXzm22XTL1DdjDkjpqDzrRlHKzA6d4okL6vU9XQgjTpCTf7DY_d1vfouMA0x9rV9H9ZV5HmurmepQsI-Rid520vEkaR0rQrsAwfLkflOkpRbmxrTQlkapbLYHS7S_3AgCbBzRgMMy-J7GtL4chqXmJkBQgSwiSedlILLkFMLhlkhshIT-g_N0Hh11aFeE4CvSc9QnYvjL5RYKCPlgDN7TkAiQdyLFwCwnW4VoPOsiGQrNWq-RnCxeH3N30uNiJflnh5pMtIifTUhZBNs2xsntYGzcjrQbHzMbJqw5NzBYPdzgnFfXrOoK1gjqZTrQyxbGEyaroWxFL5wfItKNBk1lYlHWDx8aaRPcKwQq2oYgsH-ZSPU2lXjqAx5SrtMkXUhfpcPbzHj-_XqDUiNkqlgjMy1kthrDNTHlJsmROLBKNNk4bsjUBWTX5FGOa5gLTTzwe8wEy64xvjqbnvm2MKbIR-XR-LUHDh_fvsArjDrUfoC4NUucApdm8VmEdhZsEvNepbcIs3I6DeHBA7b4XzxEaFnSGaDlUx-rh2ZqLqNoj-9z1LSvsT8S05tGs_3uBhx-7ggKgAIWcjJOJFQwTvwGF8Q4ncQRV5QHFddvbBaiidRq9vCsLzhvcMRqvmqFH4b50D6qUqPiMx1d1p8ZMjhqgEcTiv-S9RUdASkrjGq85sff9r2spa8ClxFd3jWC5LZjhSajEaUN9-gIwvmDxB6RW5iQzN3eGIk9NQa6T4R8gazXblvJcVnPKwAlFhE05AbhwpVFatgDCnJv3ewevxl7R5m0cFEIx89cIjNzsLQGyxqnkdTxyPzSSAfsOuFFNxsCIkyTZ_LeGJIHs4OIjzbvoUf3UtybjHjcy_rTHivuNMx32dQoeTs5A6rJ_L2jG7geyh4beGpUnKLT-NXTOuglsJb8tglK2gcT2UhreMxqFecjt0c_ntks-kfYBMYNUTsAMtkIiIOHgfTaSzejow6qrIn2xah4u7ISuogrE0ARIbzrylgY-foxD9zjQktQ5V4C4NXmSf8kZ1HAxqZ7zfgXXvwYqYeJpf9wGTLVoSiTIapmMcga_HV6atCc8Dc9-frPpfgDcCamej_rOw3DFK9Dg3hnhsfkPpTgNU4WMKaBSz1Ed2JseICxYLr919UNRKjIVDyGKDYGQn3Ras5bf-9hQHUKR5XIRDfrSODYzWNOFu30cUZjsNznSkV43lWfdChCTC7YGEUTwFKrhJ6k6VIhumGt1GshobXnCdmhjfXK9uFN6GpLoYEvUD9gywCGE1TPIVxzbi4jw3eZrFspOh3FtxfFCjyWWm4O2DCgLkZwCsh7xqVtvQCSLroSo2usHy6HxYTcJqu3fdvRedUmVW8kOcIy_4K9fbmnHnOkWiK_72-fZxtJMjNyz29VlcajLU5pb_PkyP9TtIg3XzYGRyUzrZc9Ptgr757twLJVSG7TksoRLxEBgtSpxj_I6kZ1eHNPjsfcjvLonVCzcOwk29F_BhQfSVBuiQAxuSsR4lhgyPDrDPsRdUvQ9e5BMLKCvnHj_3-NIIltzgBD24qx-M5FYxMoVT4ltXSsZPPINT7kBGMsJRtPL1mdQtIYDggd2Jv8wpofYoI_mS0 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index c5b6272142..8fa4e4ab9f 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1053,20 +1053,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{ @@ -2537,6 +2545,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 @@ -3065,6 +3087,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 @@ -3114,6 +3137,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 1ef4659a0c..dff3f87a19 100644 --- a/src/constants.js +++ b/src/constants.js @@ -223,6 +223,11 @@ const NOTIFICATION_CONFIGURATION = { }, }; +const ADMIN_BROADCASTABLE_NOTIFICATION_TYPES = [ + NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + NOTIFICATION_TYPES.SYSTEM_UNPLANNED_OUTAGE, +]; + const EMAIL_ACTIONS = { COLLABORATOR_ADDED: 'collaboratorAssigned', NEEDS_ACTION: 'changesRequested', @@ -377,6 +382,7 @@ module.exports = { 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/20260601000002-create-notification-user-states.js b/src/migrations/20260601000002-create-notification-user-states.js new file mode 100644 index 0000000000..10e33db8f2 --- /dev/null +++ b/src/migrations/20260601000002-create-notification-user-states.js @@ -0,0 +1,135 @@ +const { prepMigration, removeTables } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + await queryInterface.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', + }, + viewedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + archivedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }, + { transaction } + ); + + await queryInterface.addIndex('NotificationUserStates', ['notificationId', 'userId'], { + unique: true, + transaction, + }); + + await queryInterface.sequelize.query( + /* sql */ ` + INSERT INTO "NotificationUserStates" ( + "notificationId", + "userId", + "viewedAt", + "archivedAt", + "createdAt", + "updatedAt" + ) + SELECT + n.id, + n."userId", + n."viewedAt", + n."archivedAt", + n."createdAt", + n."updatedAt" + FROM "Notifications" n + WHERE n."userId" IS NOT NULL + AND ( + n."viewedAt" IS NOT NULL + OR n."archivedAt" IS NOT NULL + ); + `, + { transaction } + ); + + await queryInterface.removeColumn('Notifications', 'viewedAt', { transaction }); + await queryInterface.removeColumn('Notifications', 'archivedAt', { transaction }); + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + await queryInterface.addColumn( + 'Notifications', + 'archivedAt', + { + type: Sequelize.DATEONLY, + allowNull: true, + }, + { transaction } + ); + + await queryInterface.addColumn( + 'Notifications', + 'viewedAt', + { + type: Sequelize.DATEONLY, + allowNull: true, + }, + { transaction } + ); + + await queryInterface.sequelize.query( + /* sql */ ` + UPDATE "Notifications" n + SET + "archivedAt" = nus."archivedAt", + "viewedAt" = nus."viewedAt" + FROM "NotificationUserStates" nus + WHERE n.id = nus."notificationId" + AND n."userId" = nus."userId" + AND n."userId" IS NOT NULL; + `, + { transaction } + ); + + await removeTables(queryInterface, transaction, ['NotificationUserStates']); + }); + }, +}; 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/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/20260601000000-notifications.js b/src/seeders/20260601000000-notifications.js index 7cc043b4cf..5660168dd5 100644 --- a/src/seeders/20260601000000-notifications.js +++ b/src/seeders/20260601000000-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 index d29c5e4dab..f6ba5bc7ff 100644 --- a/src/services/notifications.test.js +++ b/src/services/notifications.test.js @@ -7,13 +7,14 @@ import { deleteNotification, deleteNotificationsByEntityAndType, getNotifications, - updateNotification, + updateNotificationState, } from './notifications'; -const { Notification, User } = db; +const { Notification, NotificationUserState, User } = db; describe('Notification service', () => { let user; + let otherUser; let createdNotificationIds = []; const activityMetadata = (id = faker.datatype.number({ min: 99001, max: 99999 })) => ({ @@ -56,10 +57,19 @@ describe('Notification service', () => { 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 = []; } @@ -67,10 +77,11 @@ describe('Notification service', () => { afterAll(async () => { if (createdNotificationIds.length) { + await NotificationUserState.destroy({ where: { notificationId: createdNotificationIds } }); await Notification.destroy({ where: { id: createdNotificationIds } }); } - await User.destroy({ where: { id: user.id } }); + await User.destroy({ where: { id: [user.id, otherUser.id] } }); await db.sequelize.close(); }); @@ -155,42 +166,70 @@ describe('Notification service', () => { }); }); - describe('updateNotification', () => { - it('updates archivedAt, triggeredAt, and viewedAt fields', async () => { - const notification = await createTrackedNotification({ + 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, - triggeredAt: null, - viewedAt: null, }); - const updatedNotification = await updateNotification(notification, { - archivedAt: '2026-01-15', - triggeredAt: '2026-01-16', - viewedAt: '2026-01-17', + const state = await updateNotificationState(notification.id, user.id, { + archivedAt: '2026-01-20', }); - expect(updatedNotification.archivedAt).toBe('2026-01-15'); - expect(updatedNotification.triggeredAt).toBe('2026-01-16'); - expect(updatedNotification.viewedAt).toBe('2026-01-17'); + expect(state.viewedAt).toBe('2026-01-15'); + expect(state.archivedAt).toBe('2026-01-20'); }); - it('does not update disallowed fields', async () => { - const notification = await createTrackedNotification({ - text: 'Original text', - type: NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION, + 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', }); - await updateNotification(notification, { - archivedAt: '2026-02-01', - text: 'Updated text that should be ignored', - type: NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE, + const state = await NotificationUserState.findOne({ + where: { notificationId: notification.id, userId: user.id }, }); + const foundNotification = await Notification.findByPk(notification.id); - const found = 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 }); - expect(found.archivedAt).toBe('2026-02-01'); - expect(found.text).toBe('Original text'); - expect(found.type).toBe(NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION); + 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); }); }); @@ -277,48 +316,84 @@ describe('Notification service', () => { }); describe('getNotifications', () => { - it('returns notifications matching scopes using default pagination and sorting', async () => { - const oldest = await createTrackedNotification({ - entityId: 99011, - triggeredAt: '2026-01-01', + 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 middle = await createTrackedNotification({ - entityId: 99012, - triggeredAt: '2026-01-02', + + 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 newest = await createTrackedNotification({ - entityId: 99013, - triggeredAt: '2026-01-03', + + 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([ - { userId: user.id }, - { id: [oldest.id, middle.id, newest.id] }, + const notifications = await getNotifications(user.id, [ + { id: [withoutState.id, withOpenState.id] }, ]); expect(notifications.map((notification) => notification.id)).toEqual([ - newest.id, - middle.id, - oldest.id, + withOpenState.id, + withoutState.id, ]); }); - it('respects limit and offset options', async () => { - const first = await createTrackedNotification({ - entityId: 99021, - triggeredAt: '2026-02-01', - }); - const second = await createTrackedNotification({ - entityId: 99022, - triggeredAt: '2026-02-02', - }); - const third = await createTrackedNotification({ - entityId: 99023, - triggeredAt: '2026-02-03', - }); + 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( - [{ userId: user.id }, { id: [first.id, second.id, third.id] }], + user.id, + [{ id: [first.id, second.id, third.id] }], { limit: 1, offset: 1 } ); @@ -326,22 +401,68 @@ describe('Notification service', () => { expect(notifications[0].id).toBe(second.id); }); - it('respects custom sortBy and sortDirection options', async () => { - const first = await createTrackedNotification({ entityId: 99033, triggeredAt: '2026-03-01' }); - const second = await createTrackedNotification({ - entityId: 99031, - triggeredAt: '2026-03-02', - }); - const third = await createTrackedNotification({ entityId: 99032, triggeredAt: '2026-03-03' }); + 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( - [{ userId: user.id }, { id: [first.id, second.id, third.id] }], - { sortBy: 'entityId', sortDirection: 'ASC' } + user.id, + [{ id: [first.id, second.id, third.id] }], + { sortBy: 'triggeredAt', sortDirection: 'ASC' } ); - expect(notifications.map((notification) => notification.entityId)).toEqual([ - 99031, 99032, 99033, + 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 index 510b878711..586f601ced 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -6,11 +6,18 @@ import type { NotificationModel, NotificationScope, NotificationType, + NotificationUserStateModel, + NotificationWithState, } from './types/notifications'; -const { Notification } = db; +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. @@ -94,33 +101,40 @@ async function createGlobalNotification( } /** - * Updates permitted timestamp fields on an existing notification. - * @param {NotificationModel} notification The existing notification instance to update. - * @param {Partial} updatedNotification The incoming notification field changes. - * @returns {Promise} The updated notification record. + * 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 updateNotification( - notification: NotificationModel, - updatedNotification: Partial -): Promise { - // the handler will check the notification ID from the params, and pass in the existing notification - // or 404 if it doesn't exist - - const allowedFields: Array<'archivedAt' | 'triggeredAt' | 'viewedAt'> = [ - 'archivedAt', - 'triggeredAt', - 'viewedAt', - ]; - const fieldsToUpdate: Partial< - Pick - > = {}; +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 updatedNotification) { - fieldsToUpdate[field] = updatedNotification[field]; + if (field in updates) { + fieldsToUpdate[field] = updates[field]; } } - return notification.update(fieldsToUpdate); + 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; } /** @@ -164,26 +178,69 @@ async function deleteNotificationsByEntityAndType( /** * 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. + * @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' } = {} -) { - return Notification.findAll({ +): 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]: scopes, + [Op.and]: [ + { + [Op.or]: [{ userId }, { userId: null }], + }, + ...scopes, + db.sequelize.literal('("userStates"."archivedAt" IS NULL OR "userStates"."id" IS NULL)'), + ], }, - order: [[sortBy, sortDirection]], - limit, - offset, + 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; }); } @@ -193,5 +250,5 @@ export { deleteNotification, deleteNotificationsByEntityAndType, getNotifications, - updateNotification, + updateNotificationState, }; diff --git a/src/services/types/notifications.ts b/src/services/types/notifications.ts index 86915ff7c9..cc8dac572d 100644 --- a/src/services/types/notifications.ts +++ b/src/services/types/notifications.ts @@ -11,7 +11,7 @@ interface NotificationMetadata { } type NotificationType = - (typeof import('../../constants').NOTIFICATION_TYPES)[keyof typeof import('../../constants').NOTIFICATION_TYPES]; + typeof import('../../constants').NOTIFICATION_TYPES[keyof typeof import('../../constants').NOTIFICATION_TYPES]; interface NotificationModel extends Model { id: number; @@ -22,11 +22,29 @@ interface NotificationModel extends Model { label: string | null; displayId: string | null; text: string | null; - archivedAt: string | null; triggeredAt: string | null; - viewedAt: string | null; isGlobal?: boolean; - isInformational?: boolean; } -export type { NotificationMetadata, NotificationModel, NotificationScope, NotificationType }; +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, +}; From 2bcf54a4bd1a5a8f03258f441b436cbaef5c119b Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 8 Jun 2026 09:10:09 -0400 Subject: [PATCH 19/23] Regenerate Logical Data Model --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index d1613a38f2..f54099b2ab 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_TeqN7Ph5sM4A3JBbbUuARtLxtJpUvRrLLcAblCFArsD7ck4eF8T4KjuNP_M5t8efp0K71h_pqvxi65Q3EuMtUTebcXch442Xl2yWWgL1jLqNbre3mzUK1zdAabkDMBKzshRj2NDjXzmtVr5H6-xMf7-C56AHie69j9bSGtdAy6Y7P3IFJbk-teyqOROOkqsy50P_y_Sqpd8wMFDoUbMjalsEKskVM7i0wsTmnkYaghhZQoyDssnChNv2Up_XuLE_I5UiTHWIpSrpqTx-rnSMW6U479k9MTGacQz__hOnDq0sgsR2NW06-bHyPjjncsLHwP_noXb3ObSao7CoRmi3hmSyvdBmuIOdys4Y5jbRAROAw6b371k3w07C4IaJPiK6WcjCNlERuCMBmG9zaKU6SNa86K1bvmnHnSQWPdUFgm25v6It7UL4wYfeW_l0ViwaTOmCSF3O5Tz38ARneSgOFsFhS3KC6Fyq9EbWYgdaanyrT2RC3WRdGXFHFtdj0rVPgoi1xGCLMOwOXAeGi9TwPMUqDf2ru2PivWxYidKXrJk0oP-9iF72IPR6ZMjfXSHr7l7hJf12iwhJCl_JmnwFNPw9wnxqzCAib50wJQ5JWG6_mnFfd45JrVm0bAbwD8lP3MsJkATmx09znVSe1x023x-ktaEoWfG9df857CX8qGAwgfaQvc91ly2xqlZPtlXlTTkoYNz0DhLkr2EFSTI0WYk7eFgCgzRGt-hrA1s1oZ8sj00nqUjVuBWwpr9XSEnRPL3WMC0UkhgUClgy_74eClDPFnvttBvLYwKheRevjzLJOx4O-TIhsHDUhXXAoLMPvTLUZAhVsGrWN0_en8I2elA6U2Eg1I2mgiapNAofwcsFZ3kNts__G6U8gm9U1F1cjx6yAPohDLl_bc12OCrli2nCwSSDoR4ouKHrccmdhmfikEitD4TrfpUckYst5faYrzPzzPQaU2nSFi6PIABj49MRlWBnuEuSrWqIidW730pfKHcQRor1FjzhYF4B1hkAJcFZRTNldbwnYRr1vXNsa1lic_62kn77ZBe-V5u8RVlxXwkhYtStjpUkt5zStPsylVRiY_bJRYFlFi17GUDZshq4FEEyrh7SPtO4OB5u6oWPSXBn6_uHs0p_2Hy-8K4P5l0u3WuREEZaRhNFkx8wmbZkIn35b17x0ZdKDofqprgGwodCSYp5k_zgSiqfKVtm1yezw1bbeHplVn43r-HniS7scZ42kpDrSv3WfYvrnOjrlOY0yfYsO7oJpF6235NjI2B8ZM-gGC-M2utMwuObIvNY-1_KkeDTYPltQhZ8B3XcEDLCDFrtx3IrnMGpa722vnwGAc22j9OwsmVGBIZn3wFBw2arhHlcMWS39TUohEamAlgtR8i11LD7OA6qxrN2TU6CTzUvvyotxrpsgEpRue2oGb4cNK2rgkoHXO-N5jJCPLp0S6LtiCWmnTZxkTQeNcDYbV2uUNuMPhnUOgMNQN9idFjPhTA-Shskck-GO1hIw7CArSoNrZ5yBxSr9tzRKSISpaz_Y0jsZ9pOm4HUIxQNJn7Zm6KmAEwUUyY3Cz6B-794Qqaj0OCrYV5rZmbWyZRXq8D10SROyrLRr1mGYBqNxph2l2AwkAq0OtKF8RXYuB4MDZBlDCLzOmlpk8KjzP4NN75itlFxuc2-YwViOnxYmItUqVdpviilQuCtGxYxNbV-i4PK-o6zG_N7T23hXIu_V5VeOna2YVbVmK88SLgr_nkAhB2_HbIwsZggESkeTsFrEIwT6mn0mHAONtqKDAVaLgdVVVL5JKZVHt8PieFlenejpJtExNQkPkEmA0DHr0yfNZL3XliKrs_Vzb0haanVQOUpBwdRwuILX1xbFpM6FSWbVZwrRmDRdYq4E_HfjaTTPMbvKqi1lj92N4Ag5dP7Ok2SRP1Ps74zR_w6fAlMAUIjb6boDYD63XlT-SSYO1hWvV1RzDsSykbRWvl1pr83pTWqiFfx3VjIA6eVfaTGQWiUAjrypDbz-ahXixagKM_xUUq1WyISarZpRp-5rfJmSGk_kv2NXgt7ZhjUwKBoXgtb9bvrctxn-UV7kvlMhrnSllpazFH6w3gVMxRtyIJXAmZQjliVTJQUbMY0mnuJxhKjJqBO8pVUm0ls1RJGsm4Cmt2XE8Zs9Shp55VvKSxEaCyOr9QOFgl9FFX2SGqg72uHtqjmEOeTNettaeB_cl2lWlN3WwvIwuWeUrk2kX0CgXNjsk8QCeNfdVUDCOJw2YcQ6efeFcxU0VZICbLatN0-OialPPQZUSUjVi-eQgRS0bP3Ofdvt_DAS-UsEZOtTDKIUQ-kWjnx81GDnYd7jh9zbVyTt7kzgOGBF5TMBHK42QLCoxHDDcvmZPooO9c8rWpNt80s1ZH6qs_43U-k6e4kPazQXgITlvmSPw_Hbs8e8zQTvVn6-RLnB6EwRqek4ZWGc8jzUDrK50aqW-kTftrPkdFjFVdrYV7FIXGtJmSrSI7NbfAP4KL5VdKJjFqAfTXvL-aCGnk4n-02b1EFgFTc16ltJ3N0WJUq2TP4LbjPKrpAuIu-CjU-XcZBIvEDV-EExJUYvNqRf0jwwSUU2nKOt3DxWI6a9gw6YlplHGnR2QIhVoOBMiENVdNIRvCZ-_TOVNazHKz-Vn9bC-MB-EHSdNy8Ovxx-auEdjHBbnr88YZVIjN_fzOpoHmySkav8oeKZ-Nj4MPZns1f3ENu8olezZep49roBxwqwMR4jF_yG3lMGyzcKM_En7vp1O_Emuk0pEtT5iRa_rewbDnzlLv1GSeo9CVDS_xMCv5L6uDg3pXVszac6t7Lxm_nqGeal7kuW9LKzOw2s7tzbml0JZisVtR9urFKmBOIB-kP9rurq34d74C-9TSLLpZqn7t-DayUBjZd1UgZ5X_glJ9ibo_7Gd9zzft_n63qsLBd9-Z_5mctqneeJnnaBJpLlVZwoZjcUjKMT7bP9qedkcP0kMM51NBLNkITFQtqG6othQ-bQJo0cKoDb35U_k_qPOo3VLpPCaLS3iWCj6J5YnFKZCdd6e7KENdUbc_qupVorBaRfiAgw035-z-qyMhHWbdnv1m4t677OB5BnXMUDNsyastan8RwqU0xnrbDEnPPQGG-qyvYcLrrJu8yYjcECRrlTVZFMTMsHhykdhGT6tvyDCvy_syI7mfUjLyRjWgTuGIHuJ67h9JJ-ysQEgKRkYQ_PZJtCq7qRr6jTH-lGPiuoF-7sF4WzDj5634NqK0BVEmkmzmuoTxcxWrrmKsw4sZrQOpfbQnCTWnCNGim43QRn7jpJcmdFUwRevsCkFCDb-alnHxrndD6jz9KzKaDyJVkOJcFPUJ-UBLeVm-Cpc0weSru6MyFbVS4xwx6pwTW1GSpuRhDU4Plj_tgF26Kg2dH8sTVr9VS84HR7qwNVONRDJtvXzMZR5ZIF0klCYmPNcHSEhZ4l75hXGQVW8IzWOqtg5WHp91yldDKYUrrTsKjkI2D2O8VfbE8s0vhZqOtwckT_mM_SNqgdAlbSL0tBfISebvxruWcqeph5jj3x177h5TOBSYTV0rjcyWtlx-LwFwcJWbwFDRdm-0QpcD--Z0E1z-X7poEBzxvaDJtzlcfjN_YSDhyiTt1NgrZxaT6lZ8My5yPJ_Wm1YNNm6uds_BcMiZ5VKZBwIby3aPF6lMw-EJtblq7Edrvx0EGHrJiJW5tWMbcFHSQyae-xNH1Y7Nw6F4VMrJ1K_cV_y07gHmRidFOfkXUuUDG-4IgSd3VHwplQ43I2HwXQPsA5dQsSAN05K3tdDfMKrZjaRLWsYO2U6nLam8u_gbB2TAuLgg1t4Fjb41wVBFWbvPXt2xw4Dv93tta8vOvevpLjxy4BrgTWmRufI-zUvmxqhlrIQZg2P6c_FEAsUbMvyneAeTC83XpgAnlZxKbxNCQ4qLxX8u_ZFiEuK67BehTzTTWSnylzYuGXyZApNwtMpunxhkexvN_toNL30Ah3hlxMQjhdlmBqL6wo_Utpqu4rLoys1JTKnDgV4s1_pTZlimEwTyWY1izA6EOcVuo1418evrbMGKhbczlQC406VrTH1fHPWeunq3NibXTLba01SMFcXenrOiW_08A0E0RPSJM21Thme0Jy5JN0XW8GF87uDopbVL1XXbUMu3__ciHPj84TtZz0Gb01rvv3JSTYuXCLOU8u-xXuAAs1_PiuatN8Y1OVpNy6kO3te8lFS3VyuaDyJ_wWmpNBa-zdMWqP_WXzfnRcEpI7TbyRYZeAZsa0zhY2LqUuLZMFSdiAAQ_Dm1B65HLly4ugFDRNMFhrlINOTHOjsMbsTdLrzkU__atjEmAyMr7gX0SjuKquQzWU4t6JuWbheXhfiMVmAqdtk8RllB7lBFiQeoRSc4R6RU76UhUrQGgooIfjxFTSNes0yda1wFuE0r9iidyqe4JiyHPGsX1kqdRRPIgJ79ENiByH2Mf0gJSKbwh5bhx5YAWKo4RtoQU8QFcNivUjaY80b4wZWKbFHVQNirUeP924cPuqLUtLqWWRpj57aFvozgQxnEzhpuTKlbnWlJLAYBIgl0MqbtJts4lgW9pO5tZP7mKR_hgMJZBjZJeCdNYqDVnqI_2dMrksncNXIMrFLBSKxJmzySOsSsWlyKc2QHIc0Gkg15KVAaUvc23gGpJsc5B2ou-NvzegGUM7Aa4q2b5d5jj_EY3XDOQN-rXnGci4hRGFXcEL5lSul5prR09h1Ao4qoXEQUu5mPJWSU19qjCGC49NqAfNsZ74zVLfXeqYbHHHqo3n9mEujrJ2Q1YZT0AU04ZwIIM8HY3gZ5GJJ6CmsIxg2I0Ne0IE5nnvZ-9Ot2I0N82SznGJG1AWgchgrYxW3TubDLi0IEasF1If150FeAJqzy3oykMR04YAY8OLJiDoN5MWBcuPsFLCONXX0Q9ksc2RPLY9MrJ8i3DzLqIPxxNTao_6tNeDlARmiHz_JigYC9mIFWu-heV2Q7ov19aFiWIG0r0I6RsWY8Ztv_8TFvG0SW7YUiLa1ZW1KB9gcq8eNRUEWDC6TDY3UfS0yW6aNCl5FBiac3zld70Qncai2cD2oqKhhKq182LmFE0aK5nGUEIBfFKOGoUfFW90CbvYIlXuyBc2XnBai6e3poMG9a1A0Fa0IgZqKC7D8MaxfX1AWsSGceIAWyhhIbbm9ltp83m9ca5e4YmEB6y1AY59DXjUPFZRPZmCIeB70uyE9NFEawwRgWBfnciCf_PoC8axGJ1zZoXBLwZXI3rwN_DDa4r3beRO95vfL8MWv7cPDI4rX5HQgwAmIelK9509eK64XCm9OPfmeoZ9AxGMguJJ14vfC0M0b3QwIAZIl9l2AG4q290ImDDy2u8fGUH0bFnY8Y5AyBZmm1z5G8Ta13e1K0B61SQCr9_0iS4negScQvffb9ALzHH22HaN6VyZy0aS3extqVa4ZO1cyCNvX8-1Jnet3YufcCumChkqqFXWIISgFp2UfzCJCWgP18e3YliOUq95e3Brl7k2oGzJSkgDWoiPVmRXDF4UV8BD3rl_oVY4b1-f194BaUTv-8GCHoO9rpvKFC-z9s0KCCElnIUC78mIDXviXj-eJnWfZ18e3YZzjzSJBXmN_nFi2Hk9ZVke_ByrBfjliZDASOwa6SPcin5geEpajoVPy-T__SLT2VAFsqC9KBrbUvH_h26b__tjtvu_5qKCztjUB_rhczsZ4cxauJ-Kc1kvA3lDTZnfA_zqEHkn6eQIjLvMoKupqB0z9jki19HzE1IylryyRZOZBxSxd0f0zx1mMFsKPIUrOcQyL9ATPhcRjit08BEDvs7c2FvJBuRpzlUAR3rfzcUhrqfVRWHNI7-n6NgmStQ5-ayxzsgbYso7ClItnwoVV5LXJktabqUicre4OciclZjYlOP7sdJTdcBCoSFsZCh93vsQpQkGkfdkrS9ahWgjmj5wkz77LkMjnirUqSSQsYkzMXtySsIRaIvtDLvoZPvgfoYr6azZNamnxegkndrufhlCX-Pr36zjrvkTPbG__ATJUQUsCyP8iwknwVQRLRUklFt_BIvW6R1XpQd6q4ijsMbM36HjTSLcSRHZeohXq4lTalXYXJVV4xnQYdF7aynUXwEn73hCz6Au1X_r6coFJT5chC95BJys2s8iEcjmQHyb7U3Jvgx2oNZRiySjzvTRXs-xnDWSAK_Tz5xzctbGaT-UPXKuq1livcn5-Yk2gzutYshGUnc3JsadRMiTdZY7PInxyId5Ck8ahipKRygVdRPlQ3aPNkREOtDHtUnvzr0C9pjzsqkHqiYQl2qvSxe2XiIfpZqh7g1oyT_aUIcUSyUYcpQIbPfU_DV63MDzbArtExWNB-7l3SEjzcCtlLU5JU-XgGZM0UzwSQqPBxMLVyOBBwgCvuwTou9cAcrhuTMgnPyRxeLgpJmjBAFRExoR39mJUYodePL7il2lDh4y1epOkbuGFrjbv7BYNXiTpioNhhaw3sJ5G6ZCx7kBpcCdxTBDq0ilwweD3u9e9refpU0At2Vu-JWj5Kjt1YrRW9F2wLnUWsYuB6Nprc7KTuFhiTkD5b-b5kRnUvXvT_HUmbFne3RnsJGdp3pTPTtAz5HXkTPc9xJWcF0x9L0EwSI5bW5GYdOrB3o4eYip99DlUvBvFDgzMIw9stCybgWxy6HbxO4MIzDwKapJfMRgBBfYPGC4rkqzHxv7PJ6lhonbcpvBjCDnqxjC2oSy5EYMYxG3MVqhkbNus_6fBFvsrW1_zvcFM26tztqdr3jPIhftabUlXfQppRHjBwdZlVX2eBs2zTm3HDFNYrjxgok7VJGLhnvmwwW3Pz8TEjPi_QURcLoS3YNoP_6Tv2H_gY1sMplRxdUxg9atfsRwQGgb5P6_shR7KtHTPQLKPoKjfKqxclFhEUIrq6txBX3yfOfLz4xRllg9pgHlTztMxC6DQQSHJjPvUyAny41sRiiZdhvDUslme1id97tC9k9yUPenWdOxFdTVF6wx7vk0wDApzYutghUrU_MYmcxxcVhBfwcYZWtjgwXJH4b2OgVrnqvUKY4zakyxWXw0sh6zFl_HFellhSNrqU7B1iQGpDoQS1sX5bxkKrnN6gBTUZmsOuO6n4gL69iWOJDZfOeCzOINjHa9TvKks7K4G_fWCV-8Gq3buxhnnd5T-ppKaTuEdf9MhCdeIkMloyX2jRGsartariN-OFMQ-Af0jzvQRKO2s2weWNUjiBC6vkkQjHZ0mSlN2YMwrcv53QBRaRrhrNhujqjZdpHhjN7YEwv-6LLRgM6tKlFh55Q7-eWyEqE2TmvNfJfnocywIwfuwojJzrUtQLlzNwS3zAZ8LzIrOpTnKk6-g3w3chzUFJPhMgfQt3hbzBIbweYQsZ03d5NLQUEAhT_EOYc_Jf5hj5zeSyqccJ7cbYhYi2Rsw5N7pMLczw_9f1jQcvugPv4MIAq4bprja5mvtFIDrlQieNpQzhHG6mFebUnRjFNdg67BBBvQ4ZTuxAJOtORIcZtN9LLEmvtCSsYxdZUAiddTEeUkUpUdw9fhxYow7R0jmRceUdS9HHNtjuDu3aQ4HeFqeKkTGLE92mt_kytsk8AIoARRPjTo-Y7j_-EkFilvUlr0XfXx73-Mro24yJ9-mzZk5gCvTCDly-ZVxi_XvHFgEZvzeMuE3vDsTvUdUAXBFFbx8ptubdd_4Lp2RTy7C9fs5_LE4oKwAPgQ3cRHJlVMBv5JtCY0QRFN8ie_jvSYdwNSYbPUsYhQCFfQdUUeGOWeeIjW5Ls_8ixTbBS6SkDQc_MW0Pot7xobLw-JYW0r3eWgjMcdq7fkUup_C-Ma5ytLckqj2sEwHzFJjEGAToCfgWMmfMe6AObSIzpuBw8tHD9Mh2zkljU5Uvxh82Ulp19r-aBwxcdIv66V8fVn6VH7LypY6Gov5CacvBvSqyDe2JlRHwPpYXlsa8ddvheZTFYvnwnuwc4AFGbaY1elbh_F-avowrlVpQ7j6hLrjAsSUDe-yuQMUD-w8PRwM2_KAw1klKFIKJPJesaEggKayG6DL2mNM-wIf4snAvRUwERAvB_z7NKv5p6BsnLecz7chaAC6QpQjVACgXKhdgUEzSTTVmYKdbPKfqOJTENdRIT5pwBbsJyxieitJr-4BStXmSdOgB1A2xq97reoVO-sMIpq9qg6-K7JLsACIfa8vbAVPFyoU6B6F3oL4APEMzHkna14b73IdYCk3N7EDxNMmTnypk4qs2D_7al7quxst8fMOMDw5Hs9HL1NhSlIblnfJEo4PSWj8UZuPRZgumM4BSkJLKbRTnzYNu353mYS3TvZTLSYVq9iZnlbXc6dzn9xTTuESblI6ki6oBpCGN1Ls8AcX1LN8fYqnm2xwBcKvhxG5VF7aq7EzjWF9gcaqcwBu1O7tK77lVFm-9FFdMTvY5D-13R2zBWr8bwT_E3DzRfih8kCcpt1pMxVErnjxUpUGSNEzqlqdeeFi1ifEHr_OBGIFX_zkRj0oO_DI-Zvxq0LvRxJ-Vn9wwuAohytcEAe-pRLXm-ne5yFU_Zoabv5vx1ZxDofFk0pkdJPjgzDvn8FyuRlyrgf0V74dDjEsnjf_eBPtXb74mh4Jr6N7YAp6ok-xzjypQLN3vjqBUrSTUrd6-pChQXXqgBlnyExI5wCjLTSGcZUoQjsN2wJxubv9JYtRPA8khsRIqMspNzD4qspLHfHLvMpsVmHraLJbPl_1m00 +xLrjRzsubVwkNo6u7wODRefskbrWZUwYZkFC19iNmtQcQ8l2e2NwNZQIg9AKC-vi__k2f4IH52MHagwJJFd9lYJ5S_ZmxV3mdCC_KWO8QyeYITDlKl20KPxM1DyLiAJf9yGEAQZs1SpJZE1FDBs7a2LfIZ-YgWO4b6c1AiWUQvXYmWxOoCeAG6dwKvAcQVe2anoISrnWalxYTx_xpxF_-esMVdD0sSSAKff-cwJflmGhv_nhI9EqhUSiO-W1iSShcA4QmOhHdvBqMOhoz3H55ODxIDBJz0zxM4426Cp_9qdt15JijLdWwkBLnTN5k_EBw_JaEvvFJtwDyYJ5x039HSKGUvpuuhqgXM3xuRCKACvgJARedHa5rVSPWPpXCvQUfWoLa39GrX9x_ZFz9wvWUffM-DClVyNBDtNkN_vVIMBZnucVrJN13v3CWxY-VGs5PEAh3nIIfy4YpM41vqedQOKP_uR17rcIKWPOGzFJ55PDEORcEIuAS8S9OFZV2z4HF5wX0Zu53lUeW1DV2JP2SF1j_rYxlyEWU8LWaLz1MkEABW21L-4D12-i6hZ7eBY1PLq01WKJbmINzUJwv_xYE92bfCMadLf__TiKYbUGuePaJmeWQMbEVAtOEWv4idBH5TsI8s7Y_ucVjnDGtHgV_vIa5IWbxY8z4PJk2Kzig_IlxG__z_pvL3UDz-Hy3sA2zgW2nKpF6NsrTkNO1z3Qje-p0bDutT0QLlavhlXKK9kDddS50SLLj_6aiCfV0h4lSI99YgXe6ImUBtB486_A7SO51FoJUr1GKUQACL03LGaV4FtE9dfEvQcDxW6Gx038Bx_z_Uz_fOsPxhkHIVl-xkzGC7IE6608LpupDslBJlI4ggxlUO9L6-o1eBZGQzGfQL-6ReptgCfHjHSrTtMqQJTS0EvJ1tAdIdpNb7UYYLtH4bU8OOAg_IjPrRNeXdTwHpqp-uqlLBojBs617Y1vR8T5d_c0u2Uh0WeV847iqMI9Cb3bWEGsgs52LOl1TJUS_4O9RiYIxT7PmPDJNdtHpsT1UshCIUO-fmIbk5ucIcq1qvScN2aEbyi6My3o5Ke9G-G6ee8k9B22_s7Wak07bCDyH5gOgzKXCWm_cUdg6roZxFFqC-Ea9xwNh7sM2U0lKla0CMpwMk4ABw9wtAC_8E-vfidWvxUBfJ7LmdF9zFUGm5u26nMcQ5Dpe5fnGLObvfkYgKqxbTPGkGkuHBo6mjTrtcvOZW8ZGsdv4Xe-6hx21NDydRmuV_E_vohx0uLvae8yHros1CKxpBZ6CfC3YMkIAjS7Qh5rBwp-w-dCCZhVkmMWx8F4yCMZdqOfHFT4u7vQkhyI1HOl0LpMfLAgx-l_JVZYy2pbgb2xecyJRUotqz-Spy3MIRSpsofbZWyrhu7KmSwDthxO3IfXoxX98GD5rd0jJdvjg2kxaTWlQHnWJAvmqIz0UNkEU0lP0UU3klE6T2Ud76Qe1CKlVxVxxQzSKw7Nx0oFgoKVaAB8tockxlZGg_ODsDkDuHyNS0yoEHnDNfppIQDQYUFL-_jBG1ZAKCtL-_hHnfN_khg4XEfTxoxt0oAi0KLXdA5gi8SSSjYsYwSOfEscrGJl2IZvE75b4zSuand_Q21bObnQUmg_lp6mfirTYIWVpjPe41cCGRDh621xo2vnpKSGduqEya8OScOmu1ZpFET1ujMTKbqpGsrE_Zg3zh1v3XZSO_8uUJxHIwBbSaPByhPfa7xqB-_HgcEpM3iCeK6ctB83mSt-eFgddzot7IjCrpTe-KhiQ7DifOUGQFBRNEz-lpkHZd8EGSwl_DJd-mRbeChWRTPsYIA6AiGGA6vpo20fKMMoqkLMWF3rvG4ESgIMuxPRdaok-q9Sss7s3T_KkepsQrq_nWimIDa8OsqcLn3UShmQ8TcD8zEM7xQZpPXiXYepR0K1dzpzpJEiZfOqx9wLQ-ImOpgrpwqvW5MolcFqKrHSSRMNX-ss9rO-BpsVzl6atgKlDZYC26BdE1_kVbkBYrupmWrCmAtg6aosll_R61kl6rIzOIy10tmhFp9icKcoATNE-EKC8RyhatKuw3U5djU3dtCuEN6I4lcnaV9jhPJRHEtTW3es0DSrp10e46N71e4gIb_mckF6YC6QFicYmYczX0oW8l26AU3YK14wnsND8NaPBCSvKJgAcY3yy1-ogHrZ0nmzDdbtqCGfF6yofWxOwjmDVGO_pGYg3H5LFvBZvgw0QKrW9ZGXmvFt0T1kUzLbu0EWOglHqn0LGbOdtkbPxGsaANW9cpcjSicTIBLEu39dvcmySf9beQDQsc5n7KUuUbElmLlLQPX_gU6F9w_F17l7lJtGsH8AXraqIb3J6pop_caLTlm_W0BbhoOH-w4jilGKxXo0pxW-fGPxmE3xwYqaUsYem1bfu96C13MGoxAfK2Ocv1kyzxslpQpl9hSTi-WNT4DhinPWdBiD1AGZEBdFT4mzxOt-W6aFJ8wniUM0WLxl6jx5VfOQisl7AfiAomAcvkfwokZxwaDTOE2qVZhkl7_PAPQkXExcs5PFkKbkubEBiIUwNjMKaAqoowcxcbIvjH_2AX_HZma1eSiRwmcl7WZ4eYJhTBs2qTiCQtSelz_zXyeGLm6k3EJ8Q6L_NJZhqaprNuO9WHapnJu_eHCtBCFQX9lKORQPiCgTwQBJx1pHdSQSxhlOL6oAL5F_ELIMgh5myG0h9eMoIrHWzWx8YxTpM358nEhfCJoWLSl4RBC9gkbM6PGdQZ1PV18JVvEtTsx9AlG6bJFigcgqR_mFvfquhjtbyVE2TyFFFrnTNRoxkRrv_VRY-kRixQLhLyIVAXDnznas01e7sU4rQ8spGSC6bz4zYDxYfQTim4N8Q_HFQ3Rm8tnqZ8ZEg8ZpC39ewAAJizOwwyxk26kv9KaSKqRW1-nGrw3CEcFDggCSnx4Gu_QlpJchGDN75o3xg6QGWNQmyqq8L9_FmGPNPvqHo9M-cuiSAsMrCqwe-qq8XCUq1NE3f9y7JB12JmfUTcHh7NBcN72SJZSyKQeSpm-WsdlqMiYitzfLPiKXmx9cgk2dQxUX9IxoaPmQadjCIey9qbhYA1jCjw34CuGkRwZGD6QKQndmd5p7lgh39EVRkYa2r895RQCqxLJzTUADSTIxvyqtx5yFgEh9uBgpGEfCkeHgKTab2Hih9wwzoNW7mv7HnJP17sFdurwdEe49NSgNul5LcFawWPbRflMrUUPZjKOFohcwPhvBZc50gyeXN5bNKi_ujT11d_GjfvOxcPxy5kVe7ZsnWOcua6mldMR6WyjWKjWv3PG7VgONo-U8r91U0W8h5kBh6XB6ucd4fPf03SN5ybJBwWy815wA3frXNn5SNfU0C7g0aDqmS5a8ZhIxpIbVMCBSxY5hVMH5rnnRDxpz-5Wkegdx6Ckui4-tj7vy-JBBck0jqEwkrvN_h16LFiXlKFLnsmWw_akFsnNw6CP0WhyhM1SWWsNhdx7wAijPwKONMqTLHrbyUtQ_rTAvqJ06397f-NT1Gqf-XQhzTr_Kb5HDrBSX4-WXkk7YtDFSRbVTJ5nt1G2ggW75gqQeSD_YYXs6VaevSiaBxJ1sPVKx_VYIi8VSfwO-e4p2Do1h5p3r-MAGGxT6M-JrbfONfJJmcopah0XLmiv8RDop3T8BMuwdhV_G74k3OawcT8Eh2J5wrF3UBGvfqm0tzMVHVtF2asi5RkgVemQSjsjayN4SzeDMr31CZwJM43GslFrQiFdoayHbSrUWpF3tFW02ypyXiUQPVWwloyx3mVVkJbuIlfesxdgj2eKRkkgNTEbf-ithbnVlRbw_UdtnyflJqnEXwx2uRk_bJ_nH4hPfrJtkRpeDrGA16IRSRrsQWhP5Q7Q09HWERAsn7Xw2_aPn4kbPbCCjhl2dYfibdJ6kAT5zK3l_v9hW358wNi2_avPs4AjVJVQHXVwMyow7zyA1hrVgYYjuM86x0ewe5EvPVGqPGzNU-CQQmdm4vSq2HJKVDcy1_QWPAxD-kDQOialPPQZQS-jVdNM5r5k02aXCKxyxVcdEt7R7siPk6o8ljNLGMm_aWe4unTXsri_Il-CxXtUrC87dYkhLeY21j2cOyeccJSQHqnPC7R6AmOfxaFh0nWZQx_c1lNL3KANqIMlLgIPlxmSPwlIbsOe8zQT-V-6_RLnB6EwRmec4YWGc8jzUDsK9F4qWXkTfrrQkdVjFVdsYVNFIXmrJmSriI1LpKbFIrT9zTHAq_mYbsdbGwGzU6Fl7u0EK4epBzsGFAlHFEy21DBNpr4MMM5fJNSlWBeDgZ7qFLPIH9fl-mXtVRKRF_J985_3MZZldBJRQOV16HqfRSJTKr7SdQiX2E5_BTxAWCMpvToVz9ZcnVxzrXzVhQkk-FmapcNB5_BCkJhw4CSvz_MS7Jsiboe-b4EHlf6l_skkPpX_TS-XQ8IbNZkJl4ML-ncDR3ENm8IZvUvmOYK-u5jyRF1cnBJ__4FRra7FPb1lpSHYSmM7piE7WqpjtGx5vEDQEfJUVRfUNK7ACYR7mNF-rZEHDHk3MWyuLzlP9XjnrUyFyT4A9AnxkQ4qTUzf0RJ_-oeJX9noRFxjbyQZhOLm8L_NDawuRyHWIZo6E4-k6gvnzOZx-6YUF5srplFLGgm_rNvasIvRZeJa_-qx_uZ5uR7JbPlb_YeLRwuqD9uur5fvgtlnyP1szFLkBEWEiegGJt3C-NRB1WhXfht9FdjRw83HQrjVIjOv0JBf6IXkkRdVxCyP0TYXDc0Al1cG76Z9Ym8WRHcHoZ47Z7BpjIhVuSPhbQrs8qc5JTG5Y_ExRUhHenIpvyWu2TZ1YlrdKnHTUD7wTaMoNnOJuqk8vX5jFMOojjO8UQUSnJQ_wfi0VHMp74D-sglrcgklQ8b-NJzi0ZA4_6sOkVxQP3uKlsw2CtGLFyOA8zvZ2naje_EVD4bEDp15VinzxcA7xDgWNdxNUGR0hxSEfGQWL-m82FdCx8DGzKY456IvIw0bEQBRrGVTjqoBBd1pHkW2kzoRWu3rexdfu1_iWhzmOjt-yHtTELwPA3g2qP_2UtZfwsUjCFnyhwGMUub-A6-wDvODlf6_iYElZOZl4T1xBtkdpRDMsu1-MGlUxNV8WsXQU_YkUbm-TJSUEWwA7jLlqUDfYjzzxH2ZhWb_xb7rTNN97665eFL_w7MRN_C3hnqVRDgzvvCapB-hDCY_ipJ4lxClmg2_qcFEpjYRr2u8-aeyNnMkLcR9YzfBR3ud8ttwgjB3DW6PuSgE-vlbVtZxReoDzoUGhMHKirXuSxVgCXrwW6Smgj8NEbvTRPxX1R3_v2DewItTuVo_N-rbL77Gwwic5npMOnVnsPTG4tgCV7MzBthkIk-dfTj5h-yBbg_fbUS7RojJAZOnwNCtdcJIUycS8IAXVuqIGwy-rb4svq9B9nmaRYAmqxcpqpUaf_mqo_kBS0o2FgDhh0EmzryfgAY3ebNpJxe8Gu_0pvZ2pxRcsTBzsY6ketbOmSzeN-5xW_56vWdHHz9o9JfVPXg09F7orMJChx6ATL8vBWAebbyYaC9vVQwFHIGJKt2WX0sROrIx5JzLSeX8w1x5R1k5XsRmVa0vvhk5BU1hQztaBN-g8zixYvXwyOdto5koRed3kTVP0xzilfQOZI9RcWojsgpULEw-X43grcCY-Bi8XJgvqG6LA61qKFl8u7X8CYv2sBcZTLuVDSPpxlqWO0kzmXsorxdNSOLTTfV-xJwuR0b3bT-EvLTC-_ysdWxdPzhUlRdjotZrKvTrGySJ0adS5jWnvlpYEVOyiOl2iW7xH0Oun47pY0prBIqYfV6fSsKF8cFTTS8NE6aE5LkWRTikBTWPG7k5obaRAjKBdFm22W3W6cU54l0LQ_rWC_F4umCRYBZo1-3SkvNteeYjlBC5__ZL9N1tBU1Rl7o0XA8TRpw6c9p7pIHTOupZxk7WVhe73cpYTjC-85XyjV_qvX_UW3qjnD_xGHNXF_g2YLCkJxsTQ3Hd-25sd5kOwDFjsNnk1EmYFUG0Ek8HNHxXMrOzo1mefvsPa2UEAYZTuBnKUssiitVhU0knxGvxqD3sxkZhxyr__rkqx0hnRKUg4nshXJJXhs04JSPFYYERIDT9Xlk1MaXvm1TyvG_uPPaKQ9zo6I8QhuJQAjxeX2qtIKjauFfTN8w1udWDwkCE0v4dwZmOKY3tU0aeRmYtvRllCpX99foNCZaH2Dw2wfoFNgiNMFa69g1J89lV9fmZa-PUpbwsImWEKNgE1oK15zfUpLwXWaFYPdZHbvTJI2DFLoRUV_dBsfh_47shFnrI-N62zrKg8jBAu1NIGTFSuIEg1dDaNUDaV1Hd-g9XAqcs9UWojCBmL_7HByATRKxR6HUv8RKzKjnJj7JtnnZHpQ2_nIOHf4gOH2we8rH0gIxaO8Hf2DFM6LCJBZPVZsYf2vOOgGZG9KMyMEtmn8U8rXPVzHdD4QmAjk0_wOnKNzp2yRFLi16i2h8ZJ94vnxdd15E6nu8dIqnWmGbVGgbVQCMIxxqMa1aX5HTGyIFob1Euj5J4QnoYUW6S0advI4Q89Y7eZrGZJ54ocPtu4o0OeWkC4HzwZM1Ot4I0R8EiPoGYG1AWicwgr2tY3zr7DvW5Il0ttHIe2D00eAlqTiBny-SO3aZoWORdLi0oN9QZoMmRs0rCONbY0g9esscOPLk8MvJBApD0LKUOxRVVaGtctJWElENpCDw-ZiiWFfuYFXO_jeJ0PLYz2ve8i0cG154L69oWYqF-vVBuGHO1iWBXUCPe1pW0KhThc48hNhHFWj04T3g3QHu1S07MkvMAUNHBCNtMEE8tZD1M5CQ9benNN9W-G8ZWES28eDYWyyaNKUaoX4rKV7Y1f3r15V1puNC6w01aisy1p4QGPa290Be0YAWDKS5D7RSSSGd7mZ48JKR5GULrs5fnH_pmAJuHc59e8YmNBMu0AIABDXXCP_gGR3uCIuN70eqB9dFEawwOiGRpnciAflHoCueuNJDyZok8LgZzIZyv7Z2Dayv358JQBLnnLFYWv7gIDI8qX5TUg8ApY8ZKBL4He524XSq8O9zmaIl8AhSKguZI3KnnC7Y35ZIuGgZZlvd2A08q2r0dWSRvvmHGXiY2AVZ6144LuR7ZWZoAWGpG2dG4e0cC3eqNg5uzjn61NCoX5S2nmj1YpqUPe7gLeYpw7K4J6HeOT8BdWCG3YnUG0d4j8IYHAH6XiQEHhvvUGK5nLi8ZuEF69uyR29OOS6aTc7nSQ5opntEa6nl70V4IcGoQ2Oe5YFraVKH6e31vdZgRcNZ15r-SmkAA9h1TErqtD_qVL_emna1sYRw8NR3S4e2fUNdWW5I6o8L9lpKTaJ6IYSEr2XT_aH05x19ZsFVdt3v0Gb4UC9Z2As3Ym-K6-Tezm9Mt78aP3Z28e5YZ35-eZBXmNzqFr4Hk9pIjKC1XnaRWatwHdbE8CIJEC94c46YWz-Ur9-lpvNtznLu3iuwuk0Afsx82oZnMazB-__TlpvwA85gI0ywL-xT9xLC147lvzAxFZEwMdwPudxUIVFYSHgw5ljSRxC-bf2Xfsm6IN5e2o3tNE-_l5Ov9Cl6zlKSe1FS3S8lLJcfvK7J7jFPVqDCtij5qAHeabZkUh0-ZFlQlEspyeidEacPzjpfMwtF1cI7sHZ3rhkfYXuGFO0wvKaTrOXlhscFp9joUMS2U_F29ltI8GofduxeXyZO-qUTwUOip9m_O2vkI75cVB6EfkfWTSwCIxGfjGD5wuT97jyZQZPgxeKmqTbDwC1_SSsQMapzsCbboZvzefIcr6KviNSuJxefjH7v_fDl6nUHr2Mra_fwSfLSyZgbJUgUsCqT9CDf9-lzbqwyhQ_z_Y71OU6zOIDiRUiqbFqtmaQwSOMqJHZ23R2OVEsWs9BrHuSprKhAGuUpp9x6qeQikXHKmjXblGwTxC7P39sunDg7JBZqc2Q8lE5bJQfrj7wBIvcr3INa0TKClSvRQXsoun9aNAKvF1suWB3ohoszEq13MzmNREJgI_8hWSVBEAWy7x8NnAMsaxu_34AQmZEOZYAPOHAt5ZecvJ_lIqVMdCod0Ijvxa2-fcn-kGOZYPwRj1YfH6jgHjo9dp36WIPtVksV00bOd_PizBuPHx4lsqaQIwwSL_9NQmxwDilRlHlNWDVMUmycu_jjC7B2I3vxW2I7kyViQrPhpKJlwPBhoA6SSTEvS5zLJQSk7KgeMV6-w5Qaq_BQohTt1urmbO9_HGIcChZoJXNcr2QOmOiVMz87wsoiZrbJjjT3WnLxgYQZqZ5WAXDhFlZNeUUscN9cFlu6ohDgn5fRrerMa6Dw0-Gyz5QQrOkjVgCWKuAPmkfJMAXarIDcyRGaCtk06v_MJnJRZGZz_ZnFkZzngQ7GrgLhwupXapJtjPDp8TBApNsko55XmJVgS4YZ6TkD0o04hHleAvew9WPiPayjr0CbzMzs2MoyhDEHvQgJtm9BFcG0kjqtXuJTLBzTH5zOgL3H1OjVSSsG4L-RA-CfHjwYpH3iTaS9dcN_5OE60kqeva3Q_eDg5lHl4ow9jP8_Jjnr12XTp1zxrGRsLrqhsLl3aRIixMqtQ3f9xswWk1zWhMSGyGNBzQtDfRNJdiWsjnwW5tbZfmORrZnvgT-tIpa8hLeKJ-pB-JbEJFBjJVpims-zqigkSjuKmzuD5fHUIljktXDTgkibAgCvBMratxEfVpEUQrq6_xDX3yFOzDz4wxeVg9BgpVxBgTteE9RgVXNwtrGeTbuO7Ct9P6UFrctJkD1asimFqXcuZrvMdA2Dhl-TnzyRZhVoGUCRWNxLrsx7DZznh4pjqDCzMNJyEYYg3jOjGgqab2OgTrHvXUmv0QoVTzXGw0sh6zFVt9FWllhTNyg21l8sD8Pkvqk8x0XPFw7DMDXcY_7a_DcB41iH4JKnF4RsPinCXOJpX9w_62D-c2VL-XJn-ifXzm22XTL1DdjDkjpqDzrRlHKzA6d4okL6vU9XQgjTpCTf7DY_d1vfouMA0x9rV9H9ZV5HmurmepQsI-Rid520vEkaR0rQrsAwfLkflOkpRbmxrTQlkapbLYHS7S_3AgCbBzRgMMy-J7GtL4chqXmJkBQgSwiSedlILLkFMLhlkhshIT-g_N0Hh11aFeE4CvSc9QnYvjL5RYKCPlgDN7TkAiQdyLFwCwnW4VoPOsiGQrNWq-RnCxeH3N30uNiJflnh5pMtIifTUhZBNs2xsntYGzcjrQbHzMbJqw5NzBYPdzgnFfXrOoK1gjqZTrQyxbGEyaroWxFL5wfItKNBk1lYlHWDx8aaRPcKwQq2oYgsH-ZSPU2lXjqAx5SrtMkXUhfpcPbzHj-_XqDUiNkqlgjMy1kthrDNTHlJsmROLBKNNk4bsjUBWTX5FGOa5gLTTzwe8wEy64xvjqbnvm2MKbIR-XR-LUHDh_fvsArjDrUfoC4NUucApdm8VmEdhZsEvNepbcIs3I6DeHBA7b4XzxEaFnSGaDlUx-rh2ZqLqNoj-9z1LSvsT8S05tGs_3uBhx-7ggKgAIWcjJOJFQwTvwGF8Q4ncQRV5QHFddvbBaiidRq9vCsLzhvcMRqvmqFH4b50D6qUqPiMx1d1p8ZMjhqgEcTiv-S9RUdASkrjGq85sff9r2spa8ClxFd3jWC5LZjhSajEaUN9-gIwvmDxB6RW5iQzN3eGIk9NQa6T4R8gazXblvJcVnPKwAlFhE05AbhwpVFatgDCnJv3ewevxl7R5m0cFEIx89cIjNzsLQGyxqnkdTxyPzSSAfsOuFFNxsCIkyTZ_LeGJIHs4OIjzbvoUf3UtybjHjcy_rTHivuNMx32dQoeTs5A6rJ_L2jG7geyh4beGpUnKLT-NXTOuglsJb8tglK2gcT2UhreMxqFecjt0c_ntks-kfYBMYNUTsAMtkIiIOHgfTaSzejow6qrIn2xah4u7ISuogrE0ARIbzrylgY-foxD9zjQktQ5V4C4NXmSf8kZ1HAxqZ7zfgXXvwYqYeJpf9wGTLVoSiTIapmMcga_HV6atCc8Dc9-frPpfgDcCamej_rOw3DFK9Dg3hnhsfkPpTgNU4WMKaBSz1Ed2JseICxYLr919UNRKjIVDyGKDYGQn3Ras5bf-9hQHUKR5XIRDfrSODYzWNOFu30cUZjsNznSkV43lWfdChCTC7YGEUTwFKrhJ6k6VIhumGt1GshobXnCdmhjfXK9uFN6GpLoYEvUD9gywCGE1TPIVxzbi4jw3eZrFspOh3FtxfFCjyWWm4O2DCgLkZwCsh7xqVtvQCSLroSo2usHy6HxYTcJqu3fdvRedUmVW8kOcIy_4K9fbmnHnOkWiK_72-fZxtJMjNyz29VlcajLU5pb_PkyP9TtIg3XzYGRyUzrZc9Ptgr757twLJVSG7TksoRLxEBgtSpxj_I6kZ1eHNPjsfcjvLonVCzcOwk29F_BhQfSVBuiQAxuSsR4lhgyPDrDPsRdUvQ9e5BMLKCvnHj_3-NIIltzgBD24qx-M5FYxMoVT4ltXSsZPPINT7kBGMsJRtPL1mdQtIYDggd2Jv8wpofYoI_mS0 \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 8077cd29e2..8fa4e4ab9f 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1053,20 +1053,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{ @@ -2537,6 +2545,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 @@ -3115,6 +3137,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 From 58ca47fdbe51a5c881bcc42a2527daec013693cb Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 8 Jun 2026 09:59:45 -0400 Subject: [PATCH 20/23] Condense down notifications table --- ...260608135105-create-notifications-table.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/migrations/20260608135105-create-notifications-table.js b/src/migrations/20260608135105-create-notifications-table.js index 861e6a2fdd..f91b3fb4b5 100644 --- a/src/migrations/20260608135105-create-notifications-table.js +++ b/src/migrations/20260608135105-create-notifications-table.js @@ -110,6 +110,49 @@ module.exports = { { 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, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }, + { 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 From 35d7ce2e2224d00508ec61eb9049c2b9025ceb7d Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 8 Jun 2026 10:00:03 -0400 Subject: [PATCH 21/23] Remove extraneous migration --- ...1000002-create-notification-user-states.js | 135 ------------------ 1 file changed, 135 deletions(-) delete mode 100644 src/migrations/20260601000002-create-notification-user-states.js diff --git a/src/migrations/20260601000002-create-notification-user-states.js b/src/migrations/20260601000002-create-notification-user-states.js deleted file mode 100644 index 10e33db8f2..0000000000 --- a/src/migrations/20260601000002-create-notification-user-states.js +++ /dev/null @@ -1,135 +0,0 @@ -const { prepMigration, removeTables } = require('../lib/migration'); - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.sequelize.transaction(async (transaction) => { - const sessionSig = __filename; - await prepMigration(queryInterface, transaction, sessionSig); - - await queryInterface.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', - }, - viewedAt: { - type: Sequelize.DATEONLY, - allowNull: true, - }, - archivedAt: { - type: Sequelize.DATEONLY, - allowNull: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - }, - }, - { transaction } - ); - - await queryInterface.addIndex('NotificationUserStates', ['notificationId', 'userId'], { - unique: true, - transaction, - }); - - await queryInterface.sequelize.query( - /* sql */ ` - INSERT INTO "NotificationUserStates" ( - "notificationId", - "userId", - "viewedAt", - "archivedAt", - "createdAt", - "updatedAt" - ) - SELECT - n.id, - n."userId", - n."viewedAt", - n."archivedAt", - n."createdAt", - n."updatedAt" - FROM "Notifications" n - WHERE n."userId" IS NOT NULL - AND ( - n."viewedAt" IS NOT NULL - OR n."archivedAt" IS NOT NULL - ); - `, - { transaction } - ); - - await queryInterface.removeColumn('Notifications', 'viewedAt', { transaction }); - await queryInterface.removeColumn('Notifications', 'archivedAt', { transaction }); - }); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.sequelize.transaction(async (transaction) => { - const sessionSig = __filename; - await prepMigration(queryInterface, transaction, sessionSig); - - await queryInterface.addColumn( - 'Notifications', - 'archivedAt', - { - type: Sequelize.DATEONLY, - allowNull: true, - }, - { transaction } - ); - - await queryInterface.addColumn( - 'Notifications', - 'viewedAt', - { - type: Sequelize.DATEONLY, - allowNull: true, - }, - { transaction } - ); - - await queryInterface.sequelize.query( - /* sql */ ` - UPDATE "Notifications" n - SET - "archivedAt" = nus."archivedAt", - "viewedAt" = nus."viewedAt" - FROM "NotificationUserStates" nus - WHERE n.id = nus."notificationId" - AND n."userId" = nus."userId" - AND n."userId" IS NOT NULL; - `, - { transaction } - ); - - await removeTables(queryInterface, transaction, ['NotificationUserStates']); - }); - }, -}; From a0be75d834dc449533f122c91d872bee79d9b642 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 8 Jun 2026 10:47:26 -0400 Subject: [PATCH 22/23] Fix LDM bugs --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 2 ++ .../20260608135105-create-notifications-table.js | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index af1eacfd15..62abd86913 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 +xLrRR-IuiNxlNw7ZF6mowCr9ThC7Y944yNeycy6PYs5xDaY2mA0bkfiPIUoGb6UTFVdl1oGbYZT9fAJsp6puP3T9iOeVRyLYLV6Fb072cghSPDaN6NZ0gCWE1D-LiABP1yJs6QXt9IoodE0FDBk7a6soZBv7zG4GK6KbgY5xFC0C4xR7HbPU0agp7pTPbcNVOb90SevBJBAVVVtr_vpzzbTQ-iqUvUzhI6dsrIRB_YrOEUTVGxh9sdhESeOU47kyWZaw85Wp-kCcUvQX8ZlDKCtW3fBiDFlT3jQG00QBFsoo7G4rUsaMkBfuSN5ryURyuZex-OxdqvDVecAJORQ7vA9cY3sEVF5MbKAm_V1P1XHd1qp6w9qP1TNtEO6SuJEMdMOCLP0oK1qazlndzYzSm-mqg_67d_-2bsyEHNZvVsysDdwS-DdXGF039DCWBczVGo4P-UeTXUHfiCWAMCAfqfkinen_m-0FR9DH1bX3izCCraqrXEOvBajmXmbW-3yBqG4yNg0IFWKEzwY48ry9Dc9m26t_KBk_mw1yWM1PN45wm3MT0GAl8XW8NbWrS8T1UGEhGmaO54nS4bmTHjS__nV7WImZBIRjrFhRtyTGl88KDoH_KW19ItFaQyNMSI2IbOkkw9OS2HF_J_oy7O7grlZynqrMWqhYBj8tWj8zuObjbU6r_Ux_llnoh9k6UtAw1p51ErI3SgBdZBvQkdBi8cYjsyTvYIcyRkW1riKvhlbKK9kDddS50SLrj_5ai2wU0x4lSIAR357HCbW-NXI8GTwKEunB2FWdzw2aeimLO-00L2LyGFGxc-WvbQSskWT0yZqWF__gcsz-eOsPxhk1IVlsxXzGC7IE6608ro4pDs_BZlI4ggxlUO9L6-o1eBZGQzGfQL_6Reptg2vGlHCrTtMqQJTS0EvJ7RbJ9RvfgZlHnAveXMl4C47rxeMiwbfqmnkzevwP_SONAjxMvx143v0yjiEYptn0y8DL0OKFa23sQB946QWBG0gRrJYXgiNWkXjEOQC4DsJEzkZjuD4fhxxevxEWFJRc97CVKu9INCyJfTQ0wLCJhXI7ysM3DM3v3AK45pb7LD2515RoFmnS4lo02bWCI0jJjNf41lUJYQw-mGUKVvn-PfpqoFUIjOyo0S95gX0WtAOl5QxmBAh7FVe3yfPhcWn-VZrR6bCjF9TC_maIs4jWWy8KRGuSg5PSa5M9-QPeQjDEfJLKxW8-4Q-XyEMXQ3SiJu61eJH_2OqV3P_XWZcyJbwSl_hVSvNzZi9Yau6YG5os1EKRpBZ6CfS3YMkIQjy7Qh7rBwpXw-d4CZhTkoMWx9t4yDl7C8rIZEw9mDsuT7yb2inU0hYiIwLKtzV_g_Z5uLd6LQ5sHLzjjB7VJdzpFWDR9ztERAkKEJxKl0PI1pirUljYDwY2BUCcXHoKUSErECMreQxiHcA_f760CRd2PRy05UqvuZLaUrmuTHSDwKvE1SnH1SgVVxtzzIzyKw7NxBZ7rOeFI57a7nJNTtperRi6xEp6y8zBk0Ev78ucBzxvf56jnV7gxVqb80pbwC3L-_hHnfV_ihw4XEfTxo_tFIAi0MLfdA4EO0SvvB1j5qynIDjDEn2y8w3YuyINJNpZJ6Ryiu6KYN5fxIdy_iR0cZPt5Q9yE5kZGMGm1iskOO3i8Bl41pw2-6pqa1V2a3U30S-OvpaF4g_kbEgR6Mfp2jSPj8VDSy3W3fF7qVE9NXBDbpDQahTDW_IZVtMF5XsRnTfX20qovPRj2MvsUzK__UI-pTjcz_Evp-lOPBekGijN8acROoj4XBHJt1Rwq0YxB-ae5n0K_3fGVRvhK07g7Bfyenen1ChR0Z98AbIRh7IvDI1yxNc0JexKWDnsIvV9TL-8QqBCVkCRNfVHVgthpdW11YaROApDvCh3MuvN8wI-OMJQycuxsepHPgWoRYi8-E3lRvvXTRIaOVUiN3_TZkbKGhVcu5R9-cxIJrzn_TLUBBPRebWLWtI0siUZEfc-c7DG4SJESU7kZ6kBaa4pmWzCn8th4apMlXjS6PkWsrMzOSy10toRFp9ikKcogTIk-UaCeR3hb6KuwBU8WLU77tCuUN6IqlgnaGfjhPJfHEtTW3es0DS7c2DG8igE3G9Lb3xXDSUF4OC7-YIB2gVq73A0oiZRfuXBHKNe75S73idpQ3dEYz9HL0ZnYe6OJUqO6-3WaIguWqPEw4MJCtNuLcTo63BuQKxGmH1HzUCwRkvAc1q8p8MMn3sLxm7jjbTT1ju3AhOQFGrH8MHrufsUrTvEceANiAVJAfVSYLJhT2vp9ixD0wTS5ZkgfOqMrt_4gpxr6eBLe8Pfc1h-UFHgJR1tt9i5dIsXZ9X4em87UeCdrZ-jmex_1r2YuiKeOj-tHUQUqBq3y1dNjHp03Z3ujduDoGuQwd566dWa0q8DvAfgQfIB2VaAxqBlSdFhkz5jnopw2TtUsgn56Ewk0m7f28xkCnrJJtlRxp0TWCanDhO2CD3hPEExECoAOtBjM6HlOLd77BWgdpBwjdvtA37mMaOTTzu_xnHBLq9tSsoBgC8aD_AfnTWJNRrgIiXMcUNLNOsgtDeFOLqCwCU4WD3b3VL4K0z4Ob6HPNfWmUXjZhNxbM-slr4Pufh0bu4y6QtihmjdNPgf_iin8J1dfjWN9dJYXkLOct2ZEims4zQ5DrrqcfqZkjEOqtqNcufDaMhgFrrbgHeB5m-mPb8ekqGbPc-0l9GxXpM3HAoU0SC3MhMCp3QRe9fkjK5vf0QJnISn_pTvU-ENh69lLpd3lge6-oRyODw9uzez7pyl13Vy_DtLrSMRczkRbwylhc_ENb_wTaNyeJOHzvzX8w3na7MlI0yuTnhcN5Jt80QBbu6o0HSXhz6_eDd0z_3HCo8wWoFEmy2WeRDEpbhhhFky8wpbbYHnJ1c17x1ZtOPowumrgOxo7CTY3Dy_DyUj4jNllmNvUpM33BIZ7NDYeFfupZUuFkiCHAxqtLpaM2cB7NLYNNDY83ocdPWV9FqyO88LUr88iZ5RwgmpvPxZTSZXYLBbUBu7zIwWrs9c_TfkD0iE6PQbKmq_NFkDC75P3EGS8Bd7f0gO8AsLZhBFz0jAF4Feybo5fZ6ZVCj0uEGwzbKT9WNVraqHu21Or3PAquOrN2VU64UzUzvympPsZ-gU33vqracTkPTGBIgR9M5_vSMrDoVlO3WokjWc6688VTphL2ymiKlvN3m-2nDWBp1JoxI-DazyhDPefvolQwQxv1Z6jQeSmhLr9VMCNmljzKdVScgvckd9ht61xj4J6vY82ydsqcbYFFYCAOMTawCy23Ezc3z7f8Rq4X1OinW_s9WG5qyZBXq8r52SBK-rh7wFGEIbgEie-uBWwhe2XD4zXEo6WSjISAJTROxvoHBcTnrPwZyhkkROkk7rnyTy4KtTnrZ6XNswfVNZovTPrWHlXdPtkQxy9usez4z-XUhtwqRN2Lzw_6xInp064lnTmKC9SbYrlXkBhxAybQvqiNLKTPJ5Ts3tMosT6Wr3m12PNdmNDQJaLwZUVVT9JKdLH7qRCe5sqOqNvvxcThlkPkAoAm1GrmqeNpH2XViQrspl-Z0Lo2SljCFObzNlzCDBmWvoVvl37kKIFeUiNS3Kvuj13lqQRP7NMrfULDB0RxIGjo9K2piZiN5FDaWlR3cboWlHrogYXDoudhi-1NQHjzQLdh-K4UIYzMEQQhvJp0k0Rjmr8h8F6ldxHIzjok7cy55qxPpmwBg4cyDFKTlEspMmq7aK2r6hTnocHrAX21ehthJDsEZvgUMokIjHRlYTxm61nP-HM5zDdugNLUTD4NfFA2yaNumRahrKXUADQlKBmUmq--BbyuitDozVlBnu_iNfwOdGgrXSjpO09-4e2Jku-Xxtjwg66eF071FkFo_DJjiYDD_32hW3MsjiWATYk06UH7eIvNZhBloXvYPGPunhPNHVsCvF-a1m3YaTBX3UINCxY1rUZFQHXVwQyww0zy9Pi5SWY-j-M8Ex4WweB-xPuHeoXkgTyOqrXVa8AfePId4-R5u3-bupZ6RzS3rWoYvbbw5wnosDTTOLKgy1AY4npGhl-0KzyziuQqA4RVGyrwv2RJsG2mRYn6JRT3_B_e7lF5xUmjoUAyKNYvq4qgPaoZURDZb7JbimTSHg1clkhHe26oDej-FAWTRbGPNI9wrLfvgylSzbh8EPPfSYLAtwNKdujd4jORXlIYyIIkmJ4NYlcwe2WUOMxjdTjvLhEeQJNvvetvpquSjKa3KRKaicxMhflZg9sd-40kyyglIx88p3Om42IWd6rLko0pNwbnreGPhr1UezAomjjwvba1cd6cFVGpLbfCd7l_37cPlGgJ-FqZkyjkFV1OkDTZY-mP7ILjmD5VNTYHgo44xNyfqiQ0nR_jr9lqcExD-l_M7-kjgwxq_YpBDyiGUjIvfFOOppk0AJWwUrIEL7O0YALpACmDAheCxtFN3eMg4n5uxaFo7AmWpLrXZAyKDGylSuCXAVS2s-DtWoOcn-_24SwetdirytvgCR2C9ZNx7YuDCxTqMnsHrhnzBHZxShI2WvniIOUQx_N4RoAYDmxK7d2x_x94FkkhtDVZgXn1UNTx2cZZrjeBOVVsR2y1EEpP_SidZKzJ0jX8lwvadNZNGCISSGpubrnLNEFZ4VVusJnuksES5wgCM7-izCcoNByH2X7tsd__OnUcnqvIRvVug5M-kD5IUEDHQUQjxyT6GTlJrhYpeELadIYUwPa2vPOK5SjLUvrr1hVH0RcUjpwLfFq2TT8wKDLxzh_nbZ8DzN9aoHLmF4jI2A675kz2VF63JEmUkkTBCVoPc_riKe7LPL5o36xxwjnvCMp99Fxo1W1puEcqLTBEDPuzSRDNTU_8bFBNP4lFMKnTXIY-k1TXxJzAfl2ln1n96CyQEhss_6kgujyddvppbWkBvpcFbyvoO-57vpJXcx2vxY9PFWCOMD5zdupvivfXgv9vPdF_OmG_Tj034-QuC3RjVQXsE3K2ls2GINvdP0g7iiGmeoNAJG4xpGhEll7jgcEPOv_uKeRU3llyXnsLHr9kADrhlidk0ggt01oo-ot-Ny0UtJdnNV_ERhN_ts3pqcl_jlaVC75V0LqKC9V4zeGEU9QsxlVGVxsBJShhT_ZqZtgLTMJptcyKntlMQnh3fFUoBmwIssvxrSzTEnt9xXAV-fk27VM348ZFIL7P53N8nTJAUnx9rUpnPjLU5XENI_upcFnQqHwK041RvmrYNxQrLgmLiknuu3euVrc_Mmt6otvtj4AEk2VuUKVLbVSkSSOVmzNVeDPjVy41x6Hzkse7aKo3Cl6imoBnpCCIySol2WB_YGyxUs9lKBWZwIZnV5R97fj6BsajiqYSZdOUgzkCs0vdXqexxc-P_UFjkZ8_tRv0jYDIoN7cRj-eo7Rg0Pp2ksXTvNgLi7FIUseNmWRfdrFRo_b-lzf7eGUfq7vSBZ6ynYVlio3WJUenyyxsxU-vAxwUbt6spxmkUh-sb50R0LlBKV6VNmNCWJSZpbBn2GM5_BaI3V7sif6tCX9gCFDpOGMMdS0UgBqrF-6-JyDRu7G1vHlTC1s0MkbVLOGDGh-TxV1I76uMV8YMVTitRbVmaI7w1wMyBCQLyd_1ONZyezfOiYw4tqkl4s18tW4QfXcrXXP-l92bm1LC-vs2QbyVnS6urF8g3cHWKPCCFETYbXwkyALT0xYFqo20zB5_ouTinp5P_2izcXptk4KaUqTs0tz-05wrEmOD-KfNUlimTwttufDHr1iZGVdB5RlQhSUGs5qAd5X0vrMuvnlAKEbJ5Xr2byv77i4eoB4BSiQDzdXyrn0WvyaZ14tYkxRBKkVzo9M5tl_wTFhXi2K1rs2xjMqpx-RQo7kSdsjo-I1tBUFTGLt53nnCASTnNM3Fa_Euv_ZonYyAo4OH4BZZ4GGk8ZFSigIAbyXbtPQCYO_rt0Xyv2H8Lxw1jsoujsHba18NpcHWgsdWvu00G1SGmoGvm42xJXyI7uutc03SM2U0BnRrZAUzT5brvPWl_uQvAupfOnDju-G49G0TUUGqrFOkAJBjZ7SVPnyJvU0zsRE9rqo8WM7yr__Rg0zw0FbN4t_D17V4z-hw9qovFlHreD6NuFVQSMvZiqXtPV6u4y2ezf0FQuWbT7k5RLZt9x2YddPc09uugALtWd5HxhQopz-juIx7hZekmqExkwEllBt_xUxJi2l5jHweJ7Z-5DE6lO7XDna-A9CDCrqcC7uLUIxd4DttX37XgcJXedtAv9Xj3XTfEtkd4DtMILihlyKbwDWFBB0j3z7GQas6J_R429sECjeBGXtBhmji_ihADqMi8KIYHq0wfxEdEfMcpf4vo8Ie5iT9zyYa2cVprxtYef0a3fDHgOE2golvwzG2M39CpqfAbghvP6cDEEldtrrxDN_IM-gpqVKlboWVLMAo7Igl8MK2Vfxj59we6Ss1TusHy46VwecahIxPjw3Aqmn3NyS4lmXrjJjiPbuKbjJrIt5EqyFV7AD7DeB_59WcaKfW4BgWIL2vMbN4mGkH2DlQ6LyFBZvRcsIb3veKeGZKBKcyMwBqOalCRmij-eJcYDe9NsmN2iqk8UXvUjdarWZO2LS1h5ASsz89WIF1OyINeQ0aRe2bhrIZk6R9V2X9I3L594JOF4Ns_Wtb89es6CqWbu0Y3f9vKW6e6eDr9DC8t1P4OBI03P02LmlE38UnJBuYO1P0DaFQEP0980LLLJjtO1h_0kgTa2G4gtuQ589O0SW9BItmFBy-Ul0Y0f8XfMM0tBSLM1khXbOEUOml320aJLjC4sox0IjwYGuMfSLqIPxxNTau_ttNeDlARmCT_yJigYC9mIFXO-jeV2QAY_19aFiWIG1L0I6RsWYCS7pkMJYYW1P0F4zOh93702e6JLDeLGkcuT0QSDwB07zIu1v0EeSoqNy-oIOFxITC9f6AUnA8m9BHkjj3O5W9J0yO2JGB51uP4lajPZ39sa-WW0INiKA-7ZmUSA7agGmweDF9T0cW0f0UG3AA7Ye8ARGz9sJ26K1iyWD0aL2vMNLMV1cVJFW_0aQ0QXIR1OiBm6g8GasMnuaU7FpF4mA0aU3pmgbCmvJxfkgWga6wyndDhBmIJk1C7q9qHPka8DH-hH-x1oWcmQiZJ49l5Aeoe49SyZkGcf8QJIMXM5LLga9e5A20qY96HE2DE46qL9Ng6rM2MS9d1AWoa0fBJHHKALvjqKJWcWGO0w0JpDjo2O4aGMHCal9XAY18yD3_n117H8Hg0J02LWR62qIFtYiOqKx6e2fGIE1OuMOpRP74sZb6pHfn1AoDZ8i3qa5pm6GBgOTC2vXAH2eaI2neP6ldP-2Gd5TGtBX8yNdpHkB5nIoA5XP7PfeV71aiwTVc0y3ymdP3fa4YWMAFQJzGaLWOhpE7CoDG-2ARa-ny6LJ6I3SBfk3ln_LE_E60FvFlac1i5sGm2gq-L505898XiYzTPyGiP49WdNFbJuJKuIi0U0QT-JV_i02K55mcW4euID1vllvMlt2DVITYnXv6AKGB50SL-eJxXmNFmFrITi93OlKCbWmaNYilaYEwCKvqYQOMms6w46oav-VP9z-UNVVdPNWlpzzb02L6_P7kNlQqZfxx_w-vTVYY9Qa0BEb_grpUvH2JVoy1zQcHtVAJlDTZnfA__qEHkn6ePMQxm_avrce65xJBPP2IXxToXuVt_xkDcCl6zlKIe0tSF28lLZbftK7J7jFPViDCljj6q61ebjFCzNHz2VUbUTjzyfilMacP-jpfMwt0XcI7sn6NhMT3P3xWFO3bUgo7OeGtrxJFxasnDpk9DV7f6tRX68vKnyTqI-XaVQD6zFCURaONi6S_93YxDv37KtqmuNEl6kq2eKZHSkVMIxlDMecIlwM4ETqglnu9yZsupqkQlHaclqL3j5gTLeQlDYYd1VT3NgmpFT9ZuqhsDeA-ldx3IZwdb4bSexjPqnMfh9ngRVRrRTklBs_zUuW6N0XZM7ZQ7tRBBJh1Z8sYh6Pb4q8yXsmg6tTaFYYnNUV0yLAodE7ayoUnsAsd9e4LEBuHfqkZTp7J95cx4AbMxvCKaGRT5PWqhxTEi0dJPt5KhQ2wWEjTnVRXswxH9ZKgGyTUEyZJZpe2A_FSqgSA0tsCxPYlGN15VEDuiuSDuFuLFQIjiQXoTEOHb99ugdM4Qin9M9-atvizFsfJ4hmqlSUf8lgfizhqEAuMIcRvqKAOsiITkGCwSPq2JExjopuW4g5_zDdfVYAVSa-caZINNJY__QsC6-ZxBrRWVtuYNqlS7Ak_tOJdTOs3ZFSKMG3dZzZ6lDUAcj_3DUU14pdZjsBWlggRJbmgjLYpytt0lLcdvQM5QlStZN2LWdz5XAGokF9E5VRM9f31YnzRqWVhRAoEMLUsrqE35Vkg9gFICM0w4si--DUXvxQPTkXjx1MLTjU12Lzg9LfnbSWVe4FH-bfMBfJglB5E2aS3gNrY0QDadPl7K8kMvm3hdTQF5LkD2FtyF4-wFt4feT3MfMFhlE2JDFUratCZqiBDVQx8GM71D-fmJA8Psuq380Ij6kXeMdecXcXcJohIEPpwlRcUMoSdEEnrPg3pp9h3dGOkiq7bvJzL8zTLQzOcK31DRjFKU-HsKnhwyCPPiwIxJ3SUDSfeLNF1GEc8kq0rdk5TqgzCqeNWPzCsk0llqOIgoGwtpV2_LMLYVTMychiw7aR3rjwoJrj1TV2BHNi9vxWEZwrUBMtkhAOHzTYrSFkBNKWJFh7JhMRFsdcvbKh0ubycVndQGaVtQX_hPcjjdlPb4zRmvdwW6FJYiYVxLjZw_HTfMLKfsHjBHksjSvdyyXhuL-sxU1uEzvhA9tshhg9pgpVhFhjdeFRdGxZV1ghHSwB0iFPEQsDEBhD-lUQZJenWhS7xE9M5-UPeoWtOxFtMyVj_sFJK0CxbbxbntxtDYzdDYvMpTJVVaqBAe8sZv6hIAT92HsMd-4wIKCgP5ytrVe0QWTs-lJ7-sp-DvQp8yAyZOmXcJcLeVh25nugKzHNMc8zklns8mP6X0hCJ4sGS9cnagC3FM4bxGUBtYLBjZr5ORqmMBw4OU0oaTruursk_PvgAzwEtXAMf6JqPNApyl8GhMKcyakidanFrYT4nyBTElbQZCIvdS11zwrmimQcAzhSZ60WvUk4N1rhTsAQfLkH_QkLVdmRvVQFgbpjR6YOAv-6LKRgM6tKlDv2kEXFYBDtf3WZKMrKvrOvJUz9LMyzPMk-wlRjAtwhzS16i46O-XuGpboObh6DcjKriQZZ5zHgu_jn4dL_Y9-1dMC3X-RDMrY3Mgz6dpQ9dP48QuP7CvYTTgCO-ktw5XAhrSPQ-qNUcEzINeqkwKgFwmgU_Gg_kOIC_jN9z8FhOsZD5gaR-hMd4j7xoJNA3jze_HAQwYvjew-QwC1lP8bZR8p7JMXcKLNoVmQZPqKyDkWNRFdkgnr9rPFSpBFgTlsyUbgrYzsvzHhtm9szUfhxgnwUs3R2kUYwjqbkbgnknt4Kz1YGMfL7_hK6NLsWWdVD-ass-CAoaes_OQ-bdiHQVyVTofQJjVhSJ94tEDYifo34CBBw0rZkrkCvf0jWKd3jIEOGyidFlPqNF5n3WszxlxMiAFHNHVAtnlfAxZCpv3W4cvTRyFeklluUgfIefAYQrDXCzhftdf0yXetCZJRxBM8yi_DfSXdaxUYFPcolzRCorQdEMfw8aee1eoYwpDYtO9vEP0Rrzga1qtjlFtWBRqwJrsigN50kbARTWgjvo38-3zJxe3HL8tPtfBGfdjqVQekkSBTo1gx1R2jLG-74hcMs95cH6-8fFOORkLxdSNdEIdowZi3I9M-i7xxDAdJC4-Ho-WCUhvtnE8933aloIRalbpTbsa9ETCRftU_6_R5YQTcEpxq-Dd7hFZQmrI74iWGXM4iVP-TdwJcsldDgTisd-lhDdBYw_OPKhILktOKiRMFzKAz0kg3oiIMX3Tx4HL_vV5r3Yg_PEK3UgzOAgPq9wlMXRlO-aQtS2R_TVVjvLJ4Mj6kSzkCDdSLOamZrQx8PxHR5-DfgjY5t9M9GEavHbLgT8MsbBxjPVLPTNbswRxQnLkqBsAOel1WPMowC54BlICVscfS3xrP9DGddQJqlQg_4vQwb8dWDFL9zc-D9cRCGxCJzRep7JMRCH3X9Rngpq6QXWGRq7NZmTHypkuqECB0F9AMvo1rtAHM8QDxIHs9H51NhKlIF5_Gc5WGov1RKg7bPs8hgHTKR5ZIh5grSSCYzWLOV8_0sU1jMN_nycV4zhZft4hCT2VHuNaxI6r9Qunx9ltA2C5LOGkQ4aQSZE2gFGRbXUCYc_j2SSmVJrfrPWI2u2exsRVV8hW5HN-SiczMFFxnS-TP7X9a808ROKhT6aMlNltW_VYwP8phafizmSrkxnpXjcQsSnmoy_qIlOVn0N8Z9QVZAOmouOivC7SNAFdXV4bz_flMhcRNYNx-fBLNXSvVERZ6KNTqgWuVOa6_7lPOvYMzwjHXHz-vKtt01tRficrUpiwjFCoxtwMrKWFYAxFkL4tlglaBPdip7LrH87vTxTBZPNPZnVUk3TlS-kfX4xMbdTjTBffcGNCPrGndr2ryU1V9wxVs8aq8pJjveO_BPV8zqIyUvxPDLjBT4SwjHRRDVnaKNMUhDAAsAcUp-IEiYwOgD_yF \ No newline at end of file diff --git a/docs/logical_data_model.puml b/docs/logical_data_model.puml index 852d47841c..55c90742f3 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1073,12 +1073,14 @@ class Notifications{ * createdAt : timestamp with time zone * type : enum * updatedAt : timestamp with time zone +!issue='column missing from model' archivedAt: date displayId : varchar(255) entityId : integer label : text link : text text : text triggeredAt : date +!issue='column missing from model' viewedAt: date } class ObjectiveCollaborators{ diff --git a/src/migrations/20260608135105-create-notifications-table.js b/src/migrations/20260608135105-create-notifications-table.js index f91b3fb4b5..ca3a7f08a0 100644 --- a/src/migrations/20260608135105-create-notifications-table.js +++ b/src/migrations/20260608135105-create-notifications-table.js @@ -144,6 +144,14 @@ module.exports = { type: Sequelize.DATE, allowNull: false, }, + archivedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, + viewedAt: { + type: Sequelize.DATEONLY, + allowNull: true, + }, }, { transaction } ); From adb8e4fdd335111b2fe05591962478df2c54cd42 Mon Sep 17 00:00:00 2001 From: "thewatermethod@gmail.com" Date: Mon, 8 Jun 2026 11:31:08 -0400 Subject: [PATCH 23/23] Remove extra columns from migration --- docs/logical_data_model.encoded | 2 +- docs/logical_data_model.puml | 2 -- .../20260608135105-create-notifications-table.js | 8 -------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/logical_data_model.encoded b/docs/logical_data_model.encoded index 62abd86913..7bd734cde4 100644 --- a/docs/logical_data_model.encoded +++ b/docs/logical_data_model.encoded @@ -1 +1 @@ -xLrRR-IuiNxlNw7ZF6mowCr9ThC7Y944yNeycy6PYs5xDaY2mA0bkfiPIUoGb6UTFVdl1oGbYZT9fAJsp6puP3T9iOeVRyLYLV6Fb072cghSPDaN6NZ0gCWE1D-LiABP1yJs6QXt9IoodE0FDBk7a6soZBv7zG4GK6KbgY5xFC0C4xR7HbPU0agp7pTPbcNVOb90SevBJBAVVVtr_vpzzbTQ-iqUvUzhI6dsrIRB_YrOEUTVGxh9sdhESeOU47kyWZaw85Wp-kCcUvQX8ZlDKCtW3fBiDFlT3jQG00QBFsoo7G4rUsaMkBfuSN5ryURyuZex-OxdqvDVecAJORQ7vA9cY3sEVF5MbKAm_V1P1XHd1qp6w9qP1TNtEO6SuJEMdMOCLP0oK1qazlndzYzSm-mqg_67d_-2bsyEHNZvVsysDdwS-DdXGF039DCWBczVGo4P-UeTXUHfiCWAMCAfqfkinen_m-0FR9DH1bX3izCCraqrXEOvBajmXmbW-3yBqG4yNg0IFWKEzwY48ry9Dc9m26t_KBk_mw1yWM1PN45wm3MT0GAl8XW8NbWrS8T1UGEhGmaO54nS4bmTHjS__nV7WImZBIRjrFhRtyTGl88KDoH_KW19ItFaQyNMSI2IbOkkw9OS2HF_J_oy7O7grlZynqrMWqhYBj8tWj8zuObjbU6r_Ux_llnoh9k6UtAw1p51ErI3SgBdZBvQkdBi8cYjsyTvYIcyRkW1riKvhlbKK9kDddS50SLrj_5ai2wU0x4lSIAR357HCbW-NXI8GTwKEunB2FWdzw2aeimLO-00L2LyGFGxc-WvbQSskWT0yZqWF__gcsz-eOsPxhk1IVlsxXzGC7IE6608ro4pDs_BZlI4ggxlUO9L6-o1eBZGQzGfQL_6Reptg2vGlHCrTtMqQJTS0EvJ7RbJ9RvfgZlHnAveXMl4C47rxeMiwbfqmnkzevwP_SONAjxMvx143v0yjiEYptn0y8DL0OKFa23sQB946QWBG0gRrJYXgiNWkXjEOQC4DsJEzkZjuD4fhxxevxEWFJRc97CVKu9INCyJfTQ0wLCJhXI7ysM3DM3v3AK45pb7LD2515RoFmnS4lo02bWCI0jJjNf41lUJYQw-mGUKVvn-PfpqoFUIjOyo0S95gX0WtAOl5QxmBAh7FVe3yfPhcWn-VZrR6bCjF9TC_maIs4jWWy8KRGuSg5PSa5M9-QPeQjDEfJLKxW8-4Q-XyEMXQ3SiJu61eJH_2OqV3P_XWZcyJbwSl_hVSvNzZi9Yau6YG5os1EKRpBZ6CfS3YMkIQjy7Qh7rBwpXw-d4CZhTkoMWx9t4yDl7C8rIZEw9mDsuT7yb2inU0hYiIwLKtzV_g_Z5uLd6LQ5sHLzjjB7VJdzpFWDR9ztERAkKEJxKl0PI1pirUljYDwY2BUCcXHoKUSErECMreQxiHcA_f760CRd2PRy05UqvuZLaUrmuTHSDwKvE1SnH1SgVVxtzzIzyKw7NxBZ7rOeFI57a7nJNTtperRi6xEp6y8zBk0Ev78ucBzxvf56jnV7gxVqb80pbwC3L-_hHnfV_ihw4XEfTxo_tFIAi0MLfdA4EO0SvvB1j5qynIDjDEn2y8w3YuyINJNpZJ6Ryiu6KYN5fxIdy_iR0cZPt5Q9yE5kZGMGm1iskOO3i8Bl41pw2-6pqa1V2a3U30S-OvpaF4g_kbEgR6Mfp2jSPj8VDSy3W3fF7qVE9NXBDbpDQahTDW_IZVtMF5XsRnTfX20qovPRj2MvsUzK__UI-pTjcz_Evp-lOPBekGijN8acROoj4XBHJt1Rwq0YxB-ae5n0K_3fGVRvhK07g7Bfyenen1ChR0Z98AbIRh7IvDI1yxNc0JexKWDnsIvV9TL-8QqBCVkCRNfVHVgthpdW11YaROApDvCh3MuvN8wI-OMJQycuxsepHPgWoRYi8-E3lRvvXTRIaOVUiN3_TZkbKGhVcu5R9-cxIJrzn_TLUBBPRebWLWtI0siUZEfc-c7DG4SJESU7kZ6kBaa4pmWzCn8th4apMlXjS6PkWsrMzOSy10toRFp9ikKcogTIk-UaCeR3hb6KuwBU8WLU77tCuUN6IqlgnaGfjhPJfHEtTW3es0DS7c2DG8igE3G9Lb3xXDSUF4OC7-YIB2gVq73A0oiZRfuXBHKNe75S73idpQ3dEYz9HL0ZnYe6OJUqO6-3WaIguWqPEw4MJCtNuLcTo63BuQKxGmH1HzUCwRkvAc1q8p8MMn3sLxm7jjbTT1ju3AhOQFGrH8MHrufsUrTvEceANiAVJAfVSYLJhT2vp9ixD0wTS5ZkgfOqMrt_4gpxr6eBLe8Pfc1h-UFHgJR1tt9i5dIsXZ9X4em87UeCdrZ-jmex_1r2YuiKeOj-tHUQUqBq3y1dNjHp03Z3ujduDoGuQwd566dWa0q8DvAfgQfIB2VaAxqBlSdFhkz5jnopw2TtUsgn56Ewk0m7f28xkCnrJJtlRxp0TWCanDhO2CD3hPEExECoAOtBjM6HlOLd77BWgdpBwjdvtA37mMaOTTzu_xnHBLq9tSsoBgC8aD_AfnTWJNRrgIiXMcUNLNOsgtDeFOLqCwCU4WD3b3VL4K0z4Ob6HPNfWmUXjZhNxbM-slr4Pufh0bu4y6QtihmjdNPgf_iin8J1dfjWN9dJYXkLOct2ZEims4zQ5DrrqcfqZkjEOqtqNcufDaMhgFrrbgHeB5m-mPb8ekqGbPc-0l9GxXpM3HAoU0SC3MhMCp3QRe9fkjK5vf0QJnISn_pTvU-ENh69lLpd3lge6-oRyODw9uzez7pyl13Vy_DtLrSMRczkRbwylhc_ENb_wTaNyeJOHzvzX8w3na7MlI0yuTnhcN5Jt80QBbu6o0HSXhz6_eDd0z_3HCo8wWoFEmy2WeRDEpbhhhFky8wpbbYHnJ1c17x1ZtOPowumrgOxo7CTY3Dy_DyUj4jNllmNvUpM33BIZ7NDYeFfupZUuFkiCHAxqtLpaM2cB7NLYNNDY83ocdPWV9FqyO88LUr88iZ5RwgmpvPxZTSZXYLBbUBu7zIwWrs9c_TfkD0iE6PQbKmq_NFkDC75P3EGS8Bd7f0gO8AsLZhBFz0jAF4Feybo5fZ6ZVCj0uEGwzbKT9WNVraqHu21Or3PAquOrN2VU64UzUzvympPsZ-gU33vqracTkPTGBIgR9M5_vSMrDoVlO3WokjWc6688VTphL2ymiKlvN3m-2nDWBp1JoxI-DazyhDPefvolQwQxv1Z6jQeSmhLr9VMCNmljzKdVScgvckd9ht61xj4J6vY82ydsqcbYFFYCAOMTawCy23Ezc3z7f8Rq4X1OinW_s9WG5qyZBXq8r52SBK-rh7wFGEIbgEie-uBWwhe2XD4zXEo6WSjISAJTROxvoHBcTnrPwZyhkkROkk7rnyTy4KtTnrZ6XNswfVNZovTPrWHlXdPtkQxy9usez4z-XUhtwqRN2Lzw_6xInp064lnTmKC9SbYrlXkBhxAybQvqiNLKTPJ5Ts3tMosT6Wr3m12PNdmNDQJaLwZUVVT9JKdLH7qRCe5sqOqNvvxcThlkPkAoAm1GrmqeNpH2XViQrspl-Z0Lo2SljCFObzNlzCDBmWvoVvl37kKIFeUiNS3Kvuj13lqQRP7NMrfULDB0RxIGjo9K2piZiN5FDaWlR3cboWlHrogYXDoudhi-1NQHjzQLdh-K4UIYzMEQQhvJp0k0Rjmr8h8F6ldxHIzjok7cy55qxPpmwBg4cyDFKTlEspMmq7aK2r6hTnocHrAX21ehthJDsEZvgUMokIjHRlYTxm61nP-HM5zDdugNLUTD4NfFA2yaNumRahrKXUADQlKBmUmq--BbyuitDozVlBnu_iNfwOdGgrXSjpO09-4e2Jku-Xxtjwg66eF071FkFo_DJjiYDD_32hW3MsjiWATYk06UH7eIvNZhBloXvYPGPunhPNHVsCvF-a1m3YaTBX3UINCxY1rUZFQHXVwQyww0zy9Pi5SWY-j-M8Ex4WweB-xPuHeoXkgTyOqrXVa8AfePId4-R5u3-bupZ6RzS3rWoYvbbw5wnosDTTOLKgy1AY4npGhl-0KzyziuQqA4RVGyrwv2RJsG2mRYn6JRT3_B_e7lF5xUmjoUAyKNYvq4qgPaoZURDZb7JbimTSHg1clkhHe26oDej-FAWTRbGPNI9wrLfvgylSzbh8EPPfSYLAtwNKdujd4jORXlIYyIIkmJ4NYlcwe2WUOMxjdTjvLhEeQJNvvetvpquSjKa3KRKaicxMhflZg9sd-40kyyglIx88p3Om42IWd6rLko0pNwbnreGPhr1UezAomjjwvba1cd6cFVGpLbfCd7l_37cPlGgJ-FqZkyjkFV1OkDTZY-mP7ILjmD5VNTYHgo44xNyfqiQ0nR_jr9lqcExD-l_M7-kjgwxq_YpBDyiGUjIvfFOOppk0AJWwUrIEL7O0YALpACmDAheCxtFN3eMg4n5uxaFo7AmWpLrXZAyKDGylSuCXAVS2s-DtWoOcn-_24SwetdirytvgCR2C9ZNx7YuDCxTqMnsHrhnzBHZxShI2WvniIOUQx_N4RoAYDmxK7d2x_x94FkkhtDVZgXn1UNTx2cZZrjeBOVVsR2y1EEpP_SidZKzJ0jX8lwvadNZNGCISSGpubrnLNEFZ4VVusJnuksES5wgCM7-izCcoNByH2X7tsd__OnUcnqvIRvVug5M-kD5IUEDHQUQjxyT6GTlJrhYpeELadIYUwPa2vPOK5SjLUvrr1hVH0RcUjpwLfFq2TT8wKDLxzh_nbZ8DzN9aoHLmF4jI2A675kz2VF63JEmUkkTBCVoPc_riKe7LPL5o36xxwjnvCMp99Fxo1W1puEcqLTBEDPuzSRDNTU_8bFBNP4lFMKnTXIY-k1TXxJzAfl2ln1n96CyQEhss_6kgujyddvppbWkBvpcFbyvoO-57vpJXcx2vxY9PFWCOMD5zdupvivfXgv9vPdF_OmG_Tj034-QuC3RjVQXsE3K2ls2GINvdP0g7iiGmeoNAJG4xpGhEll7jgcEPOv_uKeRU3llyXnsLHr9kADrhlidk0ggt01oo-ot-Ny0UtJdnNV_ERhN_ts3pqcl_jlaVC75V0LqKC9V4zeGEU9QsxlVGVxsBJShhT_ZqZtgLTMJptcyKntlMQnh3fFUoBmwIssvxrSzTEnt9xXAV-fk27VM348ZFIL7P53N8nTJAUnx9rUpnPjLU5XENI_upcFnQqHwK041RvmrYNxQrLgmLiknuu3euVrc_Mmt6otvtj4AEk2VuUKVLbVSkSSOVmzNVeDPjVy41x6Hzkse7aKo3Cl6imoBnpCCIySol2WB_YGyxUs9lKBWZwIZnV5R97fj6BsajiqYSZdOUgzkCs0vdXqexxc-P_UFjkZ8_tRv0jYDIoN7cRj-eo7Rg0Pp2ksXTvNgLi7FIUseNmWRfdrFRo_b-lzf7eGUfq7vSBZ6ynYVlio3WJUenyyxsxU-vAxwUbt6spxmkUh-sb50R0LlBKV6VNmNCWJSZpbBn2GM5_BaI3V7sif6tCX9gCFDpOGMMdS0UgBqrF-6-JyDRu7G1vHlTC1s0MkbVLOGDGh-TxV1I76uMV8YMVTitRbVmaI7w1wMyBCQLyd_1ONZyezfOiYw4tqkl4s18tW4QfXcrXXP-l92bm1LC-vs2QbyVnS6urF8g3cHWKPCCFETYbXwkyALT0xYFqo20zB5_ouTinp5P_2izcXptk4KaUqTs0tz-05wrEmOD-KfNUlimTwttufDHr1iZGVdB5RlQhSUGs5qAd5X0vrMuvnlAKEbJ5Xr2byv77i4eoB4BSiQDzdXyrn0WvyaZ14tYkxRBKkVzo9M5tl_wTFhXi2K1rs2xjMqpx-RQo7kSdsjo-I1tBUFTGLt53nnCASTnNM3Fa_Euv_ZonYyAo4OH4BZZ4GGk8ZFSigIAbyXbtPQCYO_rt0Xyv2H8Lxw1jsoujsHba18NpcHWgsdWvu00G1SGmoGvm42xJXyI7uutc03SM2U0BnRrZAUzT5brvPWl_uQvAupfOnDju-G49G0TUUGqrFOkAJBjZ7SVPnyJvU0zsRE9rqo8WM7yr__Rg0zw0FbN4t_D17V4z-hw9qovFlHreD6NuFVQSMvZiqXtPV6u4y2ezf0FQuWbT7k5RLZt9x2YddPc09uugALtWd5HxhQopz-juIx7hZekmqExkwEllBt_xUxJi2l5jHweJ7Z-5DE6lO7XDna-A9CDCrqcC7uLUIxd4DttX37XgcJXedtAv9Xj3XTfEtkd4DtMILihlyKbwDWFBB0j3z7GQas6J_R429sECjeBGXtBhmji_ihADqMi8KIYHq0wfxEdEfMcpf4vo8Ie5iT9zyYa2cVprxtYef0a3fDHgOE2golvwzG2M39CpqfAbghvP6cDEEldtrrxDN_IM-gpqVKlboWVLMAo7Igl8MK2Vfxj59we6Ss1TusHy46VwecahIxPjw3Aqmn3NyS4lmXrjJjiPbuKbjJrIt5EqyFV7AD7DeB_59WcaKfW4BgWIL2vMbN4mGkH2DlQ6LyFBZvRcsIb3veKeGZKBKcyMwBqOalCRmij-eJcYDe9NsmN2iqk8UXvUjdarWZO2LS1h5ASsz89WIF1OyINeQ0aRe2bhrIZk6R9V2X9I3L594JOF4Ns_Wtb89es6CqWbu0Y3f9vKW6e6eDr9DC8t1P4OBI03P02LmlE38UnJBuYO1P0DaFQEP0980LLLJjtO1h_0kgTa2G4gtuQ589O0SW9BItmFBy-Ul0Y0f8XfMM0tBSLM1khXbOEUOml320aJLjC4sox0IjwYGuMfSLqIPxxNTau_ttNeDlARmCT_yJigYC9mIFXO-jeV2QAY_19aFiWIG1L0I6RsWYCS7pkMJYYW1P0F4zOh93702e6JLDeLGkcuT0QSDwB07zIu1v0EeSoqNy-oIOFxITC9f6AUnA8m9BHkjj3O5W9J0yO2JGB51uP4lajPZ39sa-WW0INiKA-7ZmUSA7agGmweDF9T0cW0f0UG3AA7Ye8ARGz9sJ26K1iyWD0aL2vMNLMV1cVJFW_0aQ0QXIR1OiBm6g8GasMnuaU7FpF4mA0aU3pmgbCmvJxfkgWga6wyndDhBmIJk1C7q9qHPka8DH-hH-x1oWcmQiZJ49l5Aeoe49SyZkGcf8QJIMXM5LLga9e5A20qY96HE2DE46qL9Ng6rM2MS9d1AWoa0fBJHHKALvjqKJWcWGO0w0JpDjo2O4aGMHCal9XAY18yD3_n117H8Hg0J02LWR62qIFtYiOqKx6e2fGIE1OuMOpRP74sZb6pHfn1AoDZ8i3qa5pm6GBgOTC2vXAH2eaI2neP6ldP-2Gd5TGtBX8yNdpHkB5nIoA5XP7PfeV71aiwTVc0y3ymdP3fa4YWMAFQJzGaLWOhpE7CoDG-2ARa-ny6LJ6I3SBfk3ln_LE_E60FvFlac1i5sGm2gq-L505898XiYzTPyGiP49WdNFbJuJKuIi0U0QT-JV_i02K55mcW4euID1vllvMlt2DVITYnXv6AKGB50SL-eJxXmNFmFrITi93OlKCbWmaNYilaYEwCKvqYQOMms6w46oav-VP9z-UNVVdPNWlpzzb02L6_P7kNlQqZfxx_w-vTVYY9Qa0BEb_grpUvH2JVoy1zQcHtVAJlDTZnfA__qEHkn6ePMQxm_avrce65xJBPP2IXxToXuVt_xkDcCl6zlKIe0tSF28lLZbftK7J7jFPViDCljj6q61ebjFCzNHz2VUbUTjzyfilMacP-jpfMwt0XcI7sn6NhMT3P3xWFO3bUgo7OeGtrxJFxasnDpk9DV7f6tRX68vKnyTqI-XaVQD6zFCURaONi6S_93YxDv37KtqmuNEl6kq2eKZHSkVMIxlDMecIlwM4ETqglnu9yZsupqkQlHaclqL3j5gTLeQlDYYd1VT3NgmpFT9ZuqhsDeA-ldx3IZwdb4bSexjPqnMfh9ngRVRrRTklBs_zUuW6N0XZM7ZQ7tRBBJh1Z8sYh6Pb4q8yXsmg6tTaFYYnNUV0yLAodE7ayoUnsAsd9e4LEBuHfqkZTp7J95cx4AbMxvCKaGRT5PWqhxTEi0dJPt5KhQ2wWEjTnVRXswxH9ZKgGyTUEyZJZpe2A_FSqgSA0tsCxPYlGN15VEDuiuSDuFuLFQIjiQXoTEOHb99ugdM4Qin9M9-atvizFsfJ4hmqlSUf8lgfizhqEAuMIcRvqKAOsiITkGCwSPq2JExjopuW4g5_zDdfVYAVSa-caZINNJY__QsC6-ZxBrRWVtuYNqlS7Ak_tOJdTOs3ZFSKMG3dZzZ6lDUAcj_3DUU14pdZjsBWlggRJbmgjLYpytt0lLcdvQM5QlStZN2LWdz5XAGokF9E5VRM9f31YnzRqWVhRAoEMLUsrqE35Vkg9gFICM0w4si--DUXvxQPTkXjx1MLTjU12Lzg9LfnbSWVe4FH-bfMBfJglB5E2aS3gNrY0QDadPl7K8kMvm3hdTQF5LkD2FtyF4-wFt4feT3MfMFhlE2JDFUratCZqiBDVQx8GM71D-fmJA8Psuq380Ij6kXeMdecXcXcJohIEPpwlRcUMoSdEEnrPg3pp9h3dGOkiq7bvJzL8zTLQzOcK31DRjFKU-HsKnhwyCPPiwIxJ3SUDSfeLNF1GEc8kq0rdk5TqgzCqeNWPzCsk0llqOIgoGwtpV2_LMLYVTMychiw7aR3rjwoJrj1TV2BHNi9vxWEZwrUBMtkhAOHzTYrSFkBNKWJFh7JhMRFsdcvbKh0ubycVndQGaVtQX_hPcjjdlPb4zRmvdwW6FJYiYVxLjZw_HTfMLKfsHjBHksjSvdyyXhuL-sxU1uEzvhA9tshhg9pgpVhFhjdeFRdGxZV1ghHSwB0iFPEQsDEBhD-lUQZJenWhS7xE9M5-UPeoWtOxFtMyVj_sFJK0CxbbxbntxtDYzdDYvMpTJVVaqBAe8sZv6hIAT92HsMd-4wIKCgP5ytrVe0QWTs-lJ7-sp-DvQp8yAyZOmXcJcLeVh25nugKzHNMc8zklns8mP6X0hCJ4sGS9cnagC3FM4bxGUBtYLBjZr5ORqmMBw4OU0oaTruursk_PvgAzwEtXAMf6JqPNApyl8GhMKcyakidanFrYT4nyBTElbQZCIvdS11zwrmimQcAzhSZ60WvUk4N1rhTsAQfLkH_QkLVdmRvVQFgbpjR6YOAv-6LKRgM6tKlDv2kEXFYBDtf3WZKMrKvrOvJUz9LMyzPMk-wlRjAtwhzS16i46O-XuGpboObh6DcjKriQZZ5zHgu_jn4dL_Y9-1dMC3X-RDMrY3Mgz6dpQ9dP48QuP7CvYTTgCO-ktw5XAhrSPQ-qNUcEzINeqkwKgFwmgU_Gg_kOIC_jN9z8FhOsZD5gaR-hMd4j7xoJNA3jze_HAQwYvjew-QwC1lP8bZR8p7JMXcKLNoVmQZPqKyDkWNRFdkgnr9rPFSpBFgTlsyUbgrYzsvzHhtm9szUfhxgnwUs3R2kUYwjqbkbgnknt4Kz1YGMfL7_hK6NLsWWdVD-ass-CAoaes_OQ-bdiHQVyVTofQJjVhSJ94tEDYifo34CBBw0rZkrkCvf0jWKd3jIEOGyidFlPqNF5n3WszxlxMiAFHNHVAtnlfAxZCpv3W4cvTRyFeklluUgfIefAYQrDXCzhftdf0yXetCZJRxBM8yi_DfSXdaxUYFPcolzRCorQdEMfw8aee1eoYwpDYtO9vEP0Rrzga1qtjlFtWBRqwJrsigN50kbARTWgjvo38-3zJxe3HL8tPtfBGfdjqVQekkSBTo1gx1R2jLG-74hcMs95cH6-8fFOORkLxdSNdEIdowZi3I9M-i7xxDAdJC4-Ho-WCUhvtnE8933aloIRalbpTbsa9ETCRftU_6_R5YQTcEpxq-Dd7hFZQmrI74iWGXM4iVP-TdwJcsldDgTisd-lhDdBYw_OPKhILktOKiRMFzKAz0kg3oiIMX3Tx4HL_vV5r3Yg_PEK3UgzOAgPq9wlMXRlO-aQtS2R_TVVjvLJ4Mj6kSzkCDdSLOamZrQx8PxHR5-DfgjY5t9M9GEavHbLgT8MsbBxjPVLPTNbswRxQnLkqBsAOel1WPMowC54BlICVscfS3xrP9DGddQJqlQg_4vQwb8dWDFL9zc-D9cRCGxCJzRep7JMRCH3X9Rngpq6QXWGRq7NZmTHypkuqECB0F9AMvo1rtAHM8QDxIHs9H51NhKlIF5_Gc5WGov1RKg7bPs8hgHTKR5ZIh5grSSCYzWLOV8_0sU1jMN_nycV4zhZft4hCT2VHuNaxI6r9Qunx9ltA2C5LOGkQ4aQSZE2gFGRbXUCYc_j2SSmVJrfrPWI2u2exsRVV8hW5HN-SiczMFFxnS-TP7X9a808ROKhT6aMlNltW_VYwP8phafizmSrkxnpXjcQsSnmoy_qIlOVn0N8Z9QVZAOmouOivC7SNAFdXV4bz_flMhcRNYNx-fBLNXSvVERZ6KNTqgWuVOa6_7lPOvYMzwjHXHz-vKtt01tRficrUpiwjFCoxtwMrKWFYAxFkL4tlglaBPdip7LrH87vTxTBZPNPZnVUk3TlS-kfX4xMbdTjTBffcGNCPrGndr2ryU1V9wxVs8aq8pJjveO_BPV8zqIyUvxPDLjBT4SwjHRRDVnaKNMUhDAAsAcUp-IEiYwOgD_yF \ 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 55c90742f3..852d47841c 100644 --- a/docs/logical_data_model.puml +++ b/docs/logical_data_model.puml @@ -1073,14 +1073,12 @@ class Notifications{ * createdAt : timestamp with time zone * type : enum * updatedAt : timestamp with time zone -!issue='column missing from model' archivedAt: date displayId : varchar(255) entityId : integer label : text link : text text : text triggeredAt : date -!issue='column missing from model' viewedAt: date } class ObjectiveCollaborators{ diff --git a/src/migrations/20260608135105-create-notifications-table.js b/src/migrations/20260608135105-create-notifications-table.js index ca3a7f08a0..8f7f0b7662 100644 --- a/src/migrations/20260608135105-create-notifications-table.js +++ b/src/migrations/20260608135105-create-notifications-table.js @@ -86,14 +86,6 @@ module.exports = { type: Sequelize.TEXT, allowNull: true, }, - archivedAt: { - type: Sequelize.DATEONLY, - allowNull: true, - }, - viewedAt: { - type: Sequelize.DATEONLY, - allowNull: true, - }, triggeredAt: { type: Sequelize.DATEONLY, allowNull: true,