Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -1053,20 +1053,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 @@ -2537,6 +2545,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 @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -377,6 +382,7 @@ module.exports = {
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
135 changes: 135 additions & 0 deletions src/migrations/20260601000002-create-notification-user-states.js
Original file line number Diff line number Diff line change
@@ -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']);
});
},
};
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