Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
58c8efa
feat: add Notifications schema (model, migrations, seed, tests)
thewatermethod Jun 1, 2026
50ce3f0
Simplify model
thewatermethod Jun 1, 2026
5f18679
Merge branch mb/TTAHUB-5539/actionable-notification-spec into mb/TTAH…
thewatermethod Jun 1, 2026
bd6ed6a
Update to add "triggeredAt" to model
thewatermethod Jun 1, 2026
f3440d0
Merge branch 'mb/TTAHUB-5539/actionable-notification-spec' into mb/TT…
thewatermethod Jun 1, 2026
e367d50
Add all enum types
thewatermethod Jun 2, 2026
6d9000f
Scopes, work in progress
thewatermethod Jun 2, 2026
a4d249b
Remove digest from notifications enum
thewatermethod Jun 2, 2026
a3884cd
Merge branch 'mb/TTAHUB/actionable-notification-model' into mb/TTAHUB…
thewatermethod Jun 2, 2026
c258671
Add notification scopes
thewatermethod Jun 2, 2026
b1a20ff
Register scopes
thewatermethod Jun 2, 2026
3b4ccbf
Fix bugs, add integration test
thewatermethod Jun 2, 2026
c3e9c0a
Add services and tests
thewatermethod Jun 3, 2026
51c56f6
Update model with other needed field
thewatermethod Jun 3, 2026
b53428a
Merge branch 'mb/TTAHUB/actionable-notification-model' into mb/TTAHUB…
thewatermethod Jun 3, 2026
233334a
Merge branch 'mb/TTAHUB-5385/add-notification-scopes' into mb/TTAHUB-…
thewatermethod Jun 3, 2026
bfc17c4
Accomodate missing column in services
thewatermethod Jun 3, 2026
0d71b6b
Potential fix for pull request finding
thewatermethod Jun 3, 2026
194a776
Potential fix for pull request finding
thewatermethod Jun 3, 2026
a68e39b
Add notification configuration tests
thewatermethod Jun 3, 2026
d9c6a39
fix: replace generic deleteNotification(scopes) with targeted delete …
thewatermethod Jun 5, 2026
d7b6c91
Merge branch 'mb/TTAHUB-5539/actionable-notification-spec' into mb/TT…
thewatermethod Jun 5, 2026
90e35d5
Merge branch mb/TTAHUB-5385/add-notification-scopes into mb/TTAHUB-53…
thewatermethod Jun 5, 2026
42b970c
Fix bad test
thewatermethod Jun 5, 2026
64bf0c5
[TTAHUB-5387] Add notification handlers (#3674)
thewatermethod Jun 8, 2026
0491e7e
Merge upstream
thewatermethod Jun 8, 2026
2bcf54a
Regenerate Logical Data Model
thewatermethod Jun 8, 2026
13147b4
Merge branch mb/TTAHUB-5539/actionable-notification-spec into mb/TTAH…
thewatermethod Jun 8, 2026
31033f4
Merge branch 'mb/TTAHUB-5539/actionable-notification-spec' into mb/TT…
thewatermethod Jun 8, 2026
58ca47f
Condense down notifications table
thewatermethod Jun 8, 2026
35d7ce2
Remove extraneous migration
thewatermethod Jun 8, 2026
a0be75d
Fix LDM bugs
thewatermethod Jun 8, 2026
adb8e4f
Remove extra columns from migration
thewatermethod Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/logical_data_model.encoded

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions docs/logical_data_model.puml
Original file line number Diff line number Diff line change
Expand Up @@ -1057,20 +1057,28 @@ class NextSteps{
completeDate : date
}

class NotificationUserStates{
* id : integer : <generated>
* 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 : <generated>
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{
Expand Down Expand Up @@ -2541,6 +2549,20 @@ class ZALNextSteps{
session_sig : text
}

class ZALNotificationUserStates{
* id : bigint : <generated>
* 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 : <generated>
* data_id : bigint
Expand Down Expand Up @@ -3069,6 +3091,7 @@ NationalCenters "1" --[#black,dashed,thickness=2]--{ "n" EventReportPilotNation
NationalCenters "1" --[#black,dashed,thickness=2]--{ "n" NationalCenterUsers : nationalCenter, nationalCenterUsers
NationalCenters "1" --[#black,dashed,thickness=2]--{ "n" NationalCenters : mapsToNationalCenter, mapsFromNationalCenters
NextSteps "1" --[#black,dashed,thickness=2]--{ "n" NextStepResources : nextStep, nextStepResources
Notifications "1" --[#black,dashed,thickness=2]--{ "n" NotificationUserStates : notification, userStates
ObjectiveTemplates "1" --[#black,dashed,thickness=2]--{ "n" GoalTemplateObjectiveTemplates : objectiveTemplate, goalTemplateObjectiveTemplates
ObjectiveTemplates "1" --[#black,dashed,thickness=2]--{ "n" Objectives : objectives, objectiveTemplate
Objectives "1" --[#black,dashed,thickness=2]--{ "n" ActivityReportObjectives : objective, originalObjective, activityReportObjectives, reassignedActivityReportObjectives
Expand Down Expand Up @@ -3118,6 +3141,7 @@ Users "1" --[#black,dashed,thickness=2]--{ "n" GoalCollaborators : user, goalCo
Users "1" --[#black,dashed,thickness=2]--{ "n" GoalStatusChanges : user, goalStatusChanges
Users "1" --[#black,dashed,thickness=2]--{ "n" GroupCollaborators : user, groupCollaborators
Users "1" --[#black,dashed,thickness=2]--{ "n" NationalCenterUsers : user, nationalCenterUsers
Users "1" --[#black,dashed,thickness=2]--{ "n" NotificationUserStates : user, notificationUserStates
Users "1" --[#black,dashed,thickness=2]--{ "n" Notifications : user, notifications
Users "1" --[#black,dashed,thickness=2]--{ "n" ObjectiveCollaborators : user, objectiveCollaborators
Users "1" --[#black,dashed,thickness=2]--{ "n" Permissions : user, permissions
Expand Down
25 changes: 25 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,29 @@ const NOTIFICATION_TYPES = {
SYSTEM_UNPLANNED_OUTAGE: 'systemUnplannedOutage',
};

const NOTIFICATION_CONFIGURATION = {
[NOTIFICATION_TYPES.ACTIVITY_REPORT_NEEDS_ACTION]: {
textFn: ({ userName, recipientName }) =>
`${userName} has requested changes to your Activity Report for ${recipientName}.`,
actionable: true,
linkFn: ({ id }) => `/activity-reports/${id}`,
linkText: () => 'View AR',
displayId: ({ displayId }) => displayId,
},
[NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE]: {
textFn: ({ date }) => `Planned outage: the TTA Hub will be closed for maintenance from ${date}`,
actionable: true,
linkFn: () => null,
linkText: () => null,
displayId: () => null,
},
};

const ADMIN_BROADCASTABLE_NOTIFICATION_TYPES = [
NOTIFICATION_TYPES.SYSTEM_PLANNED_OUTAGE,
NOTIFICATION_TYPES.SYSTEM_UNPLANNED_OUTAGE,
];

const EMAIL_ACTIONS = {
COLLABORATOR_ADDED: 'collaboratorAssigned',
NEEDS_ACTION: 'changesRequested',
Expand Down Expand Up @@ -358,6 +381,8 @@ module.exports = {
RESOURCE_ACTIONS,
USER_SETTINGS,
NOTIFICATION_TYPES,
NOTIFICATION_CONFIGURATION,
ADMIN_BROADCASTABLE_NOTIFICATION_TYPES,
EMAIL_ACTIONS,
S3_ACTIONS,
EMAIL_DIGEST_FREQ,
Expand Down
4 changes: 4 additions & 0 deletions src/middleware/checkIdParamMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
51 changes: 51 additions & 0 deletions src/middleware/checkIdParamMiddleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
checkGroupIdParam,
checkIdIdParam,
checkIdParam,
checkNotificationIdParam,
checkObjectiveIdParam,
checkObjectiveTemplateIdParam,
checkRecipientIdParam,
Expand Down Expand Up @@ -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 = {
Expand Down
58 changes: 51 additions & 7 deletions src/migrations/20260608135105-create-notifications-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,47 @@ module.exports = {
type: Sequelize.TEXT,
allowNull: true,
},
archivedAt: {
triggeredAt: {
type: Sequelize.DATEONLY,
allowNull: true,
},
viewedAt: {
type: Sequelize.DATEONLY,
allowNull: true,
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
triggeredAt: {
type: Sequelize.DATEONLY,
allowNull: true,
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
},
{ transaction }
);

await queryInterface.createTable(
'NotificationUserStates',
{
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
notificationId: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'Notifications',
key: 'id',
},
onDelete: 'CASCADE',
},
userId: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id',
},
onDelete: 'CASCADE',
},
createdAt: {
type: Sequelize.DATE,
Expand All @@ -106,10 +136,23 @@ module.exports = {
type: Sequelize.DATE,
allowNull: false,
},
archivedAt: {
type: Sequelize.DATEONLY,
allowNull: true,
},
viewedAt: {
type: Sequelize.DATEONLY,
allowNull: true,
},
},
{ transaction }
);

await queryInterface.addIndex('NotificationUserStates', ['notificationId', 'userId'], {
unique: true,
transaction,
});

await updateUsersFlagsEnum(
queryInterface,
transaction,
Expand All @@ -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
Expand Down
18 changes: 4 additions & 14 deletions src/models/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}
}

Expand Down Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/models/notificationUserState.js
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading