diff --git a/api/src/models/alert-view.ts b/api/src/models/alert-view.ts new file mode 100644 index 0000000000..f57e2fffa5 --- /dev/null +++ b/api/src/models/alert-view.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +// Define the alert schema +export const IAlert = z.object({ + alert_id: z.number(), + alert_type_id: z.number().int(), + name: z.string(), + message: z.string(), + severity: z.enum(['info', 'success', 'error', 'warning']), + data: z.object({}).nullable(), + record_end_date: z.string().nullable(), + status: z.enum(['active', 'expired']) +}); + +// Infer types from the schema +export type IAlert = z.infer; +export type IAlertCreateObject = Omit; +export type IAlertUpdateObject = Omit; + +// Filter object for viewing alerts +export interface IAlertFilterObject { + expiresBefore?: string; + expiresAfter?: string; + types?: string[]; +} + +// Define severity and status types +export type IAlertSeverity = 'info' | 'success' | 'error' | 'warning'; +export type IAlertStatus = 'active' | 'expired'; diff --git a/api/src/openapi/schemas/alert.ts b/api/src/openapi/schemas/alert.ts new file mode 100644 index 0000000000..312681feb9 --- /dev/null +++ b/api/src/openapi/schemas/alert.ts @@ -0,0 +1,83 @@ +import { OpenAPIV3 } from 'openapi-types'; + +/** + * Base schema for system alerts + */ +const baseSystemAlertSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'Schema defining alerts created by system administrators.', + additionalProperties: false, + properties: { + name: { + description: 'Name to display as the title of the alert', + type: 'string' + }, + message: { + description: 'Message to display on the alert', + type: 'string' + }, + alert_type_id: { + description: 'Type of the alert, controlling how it is displayed.', + type: 'number' + }, + severity: { + description: 'Severity level of the alert', + type: 'string', + enum: ['info', 'success', 'warning', 'error'] + }, + data: { + description: 'Data associated with the alert', + type: 'object', + nullable: true + }, + record_end_date: { + description: 'End date of the alert', + type: 'string', + nullable: true + } + } +}; + +/** + * Schema for updating system alerts + */ +export const systemAlertPutSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity'], + additionalProperties: false, + properties: { + ...baseSystemAlertSchema.properties, + alert_id: { + type: 'integer', + minimum: 1, + description: 'Primary key of the alert' + } + } +}; + +/** + * Schema for getting system alerts + */ +export const systemAlertGetSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity', 'status'], + additionalProperties: false, + properties: { + ...systemAlertPutSchema.properties, + status: { + type: 'string', + enum: ['active', 'expired'], + description: + 'Status of the alert based on comparing the current date to record_end_date, calculated in the get query.' + } + } +}; + +/** + * Schema for creating system alerts + */ +export const systemAlertCreateSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + additionalProperties: false, + required: ['name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity'] +}; diff --git a/api/src/paths/alert/index.test.ts b/api/src/paths/alert/index.test.ts new file mode 100644 index 0000000000..82243c1846 --- /dev/null +++ b/api/src/paths/alert/index.test.ts @@ -0,0 +1,141 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { IAlertSeverity, IAlertStatus } from '../../models/alert-view'; +import { AlertService } from '../../services/alert-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { createAlert, getAlerts } from '../alert'; + +chai.use(sinonChai); + +describe('getAlerts', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('returns a list of system alerts', async () => { + const mockAlerts = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + }, + { + alert_id: 2, + name: 'Alert 2', + message: 'Message 2', + alert_type_id: 2, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + } + ]; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = getAlerts(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alerts: mockAlerts }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlerts').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getAlerts(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('createAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + it('creates a new alert', async () => { + const mockAlert = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + severity: 'medium' + }; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'createAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = mockAlert; + + const requestHandler = createAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'createAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = createAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/alert/index.ts b/api/src/paths/alert/index.ts new file mode 100644 index 0000000000..df67c3fb3d --- /dev/null +++ b/api/src/paths/alert/index.ts @@ -0,0 +1,244 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { IAlertFilterObject } from '../../models/alert-view'; +import { systemAlertCreateSchema, systemAlertGetSchema } from '../../openapi/schemas/alert'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { AlertService } from '../../services/alert-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/alert/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR, SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getAlerts() +]; + +GET.apiDoc = { + description: 'Gets a list of system alerts.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'types', + required: false, + schema: { + type: 'array', + items: { + type: 'string' + } + } + }, + { + in: 'query', + name: 'expiresBefore', + required: false, + schema: { + type: 'string' + } + }, + { + in: 'query', + name: 'expiresAfter', + required: false, + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + description: 'Response object containing system alerts', + additionalProperties: false, + required: ['alerts'], + properties: { + alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get system alerts created by system administrators describing important information, deadlines, etc. + * + * @returns {RequestHandler} + */ +export function getAlerts(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getAlerts' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const filterObject = parseQueryParams(req); + + const alertService = new AlertService(connection); + + const alerts = await alertService.getAlerts(filterObject); + + await connection.commit(); + + return res.status(200).json({ alerts: alerts }); + } catch (error) { + defaultLog.error({ label: 'getAlerts', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IAlertFilterObject} + */ +function parseQueryParams(req: Request): IAlertFilterObject { + return { + expiresBefore: req.query.expiresBefore ?? undefined, + expiresAfter: req.query.expiresAfter ?? undefined, + types: req.query.types ?? [] + }; +} + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + createAlert() +]; + +POST.apiDoc = { + description: 'Create an alert.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Alert post request object.', + required: true, + content: { + 'application/json': { + schema: systemAlertCreateSchema + } + } + }, + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Creates a new system alert + * + * @returns {RequestHandler} + */ +export function createAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'createAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alert = req.body; + + const alertService = new AlertService(connection); + + const id = await alertService.createAlert(alert); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'createAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/alert/{alertId}/index.test.ts b/api/src/paths/alert/{alertId}/index.test.ts new file mode 100644 index 0000000000..e10b9c1d78 --- /dev/null +++ b/api/src/paths/alert/{alertId}/index.test.ts @@ -0,0 +1,246 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { deleteAlert, getAlertById, updateAlert } from '.'; +import { SYSTEM_IDENTITY_SOURCE } from '../../../constants/database'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { IAlertSeverity, IAlertStatus } from '../../../models/alert-view'; +import { AlertService } from '../../../services/alert-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getAlerts', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('returns a single system alert', async () => { + const mockAlert = { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + }; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlertById').resolves(mockAlert); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = getAlertById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockAlert); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlertById').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + const requestHandler = getAlertById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('deleteAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('rejects an unauthorized request', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR], // Creators cannot delete alerts + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = deleteAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); + + describe('as a system admin user', () => { + it('deletes an alert and returns the alert id', async () => { + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = deleteAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + + const requestHandler = deleteAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('updateAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system admin user', () => { + it('updates an alert', async () => { + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + sinon.stub(AlertService.prototype, 'updateAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = updateAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'updateAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + + const requestHandler = updateAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); diff --git a/api/src/paths/alert/{alertId}/index.ts b/api/src/paths/alert/{alertId}/index.ts new file mode 100644 index 0000000000..141f52155e --- /dev/null +++ b/api/src/paths/alert/{alertId}/index.ts @@ -0,0 +1,317 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { systemAlertGetSchema, systemAlertPutSchema } from '../../../openapi/schemas/alert'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { AlertService } from '../../../services/alert-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/alert/{alertId}/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getAlertById() +]; + +GET.apiDoc = { + description: 'Gets a specific system alert.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to get' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: systemAlertGetSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a specific system alert by its id + * + * @returns {RequestHandler} + */ +export function getAlertById(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getAlertById' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + + const alertService = new AlertService(connection); + + const alert = await alertService.getAlertById(alertId); + + await connection.commit(); + + return res.status(200).json(alert); + } catch (error) { + defaultLog.error({ label: 'getAlertById', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const PUT: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateAlert() +]; + +PUT.apiDoc = { + description: 'Update an alert by its id.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to update' + } + } + ], + requestBody: { + description: 'Alert put request object.', + required: true, + content: { + 'application/json': { + schema: systemAlertPutSchema + } + } + }, + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Updates a system alert by its id + * + * @returns {RequestHandler} + */ +export function updateAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'updateAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + const alert = req.body; + + const alertService = new AlertService(connection); + + const id = await alertService.updateAlert({ ...alert, alert_id: alertId }); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'updateAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const DELETE: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteAlert() +]; + +DELETE.apiDoc = { + description: 'Delete an alert by its id.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to delete' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Deletes a system alert by its id + * + * @returns {RequestHandler} + */ +export function deleteAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'deleteAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + + const alertService = new AlertService(connection); + + const id = await alertService.deleteAlert(alertId); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'deleteAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index eebad437bc..0f60bb0406 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -382,6 +382,27 @@ GET.apiDoc = { } } } + }, + alert_types: { + type: 'array', + description: 'Alert type options for system administrators managing alert messages.', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/repositories/alert-repository.test.ts b/api/src/repositories/alert-repository.test.ts new file mode 100644 index 0000000000..0f6c05950e --- /dev/null +++ b/api/src/repositories/alert-repository.test.ts @@ -0,0 +1,169 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IAlertSeverity } from '../models/alert-view'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AlertRepository } from './alert-repository'; + +chai.use(sinonChai); + +describe('AlertRepository', () => { + it('should construct', () => { + const mockDBConnection = getMockDBConnection(); + const alertRepository = new AlertRepository(mockDBConnection); + + expect(alertRepository).to.be.instanceof(AlertRepository); + }); + + describe('getAlerts', () => { + it('should return an array of alerts with empty filters', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlerts({}); + + expect(response).to.be.an('array').that.is.not.empty; + expect(response[0]).to.have.property('alert_id', 1); + }); + + it('should apply filters when provided', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error', + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlerts({ expiresBefore: '2024-01-01', types: ['type1'] }); + + expect(response).to.equal(mockRows); + }); + }); + + describe('getAlertById', () => { + it('should return a specific alert by its Id', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error', + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlertById(1); + + expect(response).to.have.property('alert_id', 1); + }); + }); + + describe('updateAlert', () => { + it('should update an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + const alert = { + alert_id: 1, + name: 'Updated Alert', + message: 'Updated message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + const response = await alertRepository.updateAlert(alert); + + expect(response).to.equal(1); + }); + }); + + describe('createAlert', () => { + it('should create an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + const alert = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + const response = await alertRepository.createAlert(alert); + + expect(response).to.equal(1); + }); + }); + + describe('deleteAlert', () => { + it('should delete an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.deleteAlert(1); + + expect(response).to.equal(1); + }); + }); +}); diff --git a/api/src/repositories/alert-repository.ts b/api/src/repositories/alert-repository.ts new file mode 100644 index 0000000000..46ca743e86 --- /dev/null +++ b/api/src/repositories/alert-repository.ts @@ -0,0 +1,189 @@ +import { Knex } from 'knex'; +import SQL from 'sql-template-strings'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { BaseRepository } from './base-repository'; + +/** + * A repository class for accessing alert data. + * + * @export + * @class AlertRepository + * @extends {BaseRepository} + */ +export class AlertRepository extends BaseRepository { + /** + * Builds query for all alert records without filtering any records, and adds a status field based on record_end_date + * + * @return {*} {Knex.QueryBuilder} + * @memberof AlertRepository + */ + _getAlertBaseQuery(): Knex.QueryBuilder { + const knex = getKnex(); + + return knex + .select( + 'alert.alert_id', + 'alert.name', + 'alert.message', + 'alert.alert_type_id', + 'alert.data', + 'alert.severity', + 'alert.record_end_date', + knex.raw(` + CASE + WHEN alert.record_end_date < NOW() THEN 'expired' + ELSE 'active' + END AS status + `) + ) + .from('alert') + .orderBy('alert.create_date', 'DESC'); + } + + /** + * Get alert records with optional filters applied + * + * @param {IAlertFilterObject} filterObject + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlerts(filterObject: IAlertFilterObject): Promise { + const queryBuilder = this._getAlertBaseQuery(); + + if (filterObject.expiresAfter) { + queryBuilder.where((qb) => { + qb.whereRaw(`alert.record_end_date >= ?`, [filterObject.expiresAfter]).orWhereNull('alert.record_end_date'); + }); + } + + if (filterObject.expiresBefore) { + queryBuilder.where((qb) => { + qb.whereRaw(`alert.record_end_date < ?`, [filterObject.expiresBefore]); + }); + } + + if (filterObject.types && filterObject.types.length > 0) { + queryBuilder + .join('alert_type as at', 'at.alert_type_id', 'alert.alert_type_id') + .whereRaw('lower(at.name) = ANY(?)', [filterObject.types.map((type) => type.toLowerCase())]); + } + + const response = await this.connection.knex(queryBuilder, IAlert); + + return response.rows; + } + + /** + * Get a specific alert by its Id + * + * @param {number} alertId + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlertById(alertId: number): Promise { + const queryBuilder = this._getAlertBaseQuery(); + + queryBuilder.where('alert_id', alertId); + + const response = await this.connection.knex(queryBuilder, IAlert); + + return response.rows[0]; + } + + /** + * Update system alert. + * + * @param {IAlertUpdateObject} alert + * @return {*} Promise + * @memberof AlertRepository + */ + async updateAlert(alert: IAlertUpdateObject): Promise { + const sqlStatement = SQL` + UPDATE alert + SET + name = ${alert.name}, + message = ${alert.message}, + alert_type_id = ${alert.alert_type_id}, + severity = ${alert.severity}, + data = ${JSON.stringify(alert.data)}::json, + record_end_date = ${alert.record_end_date} + WHERE + alert_id = ${alert.alert_id} + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to update alert', [ + 'AlertRepository->updateAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } + + /** + * Create system alert. + * + * @param {IAlertCreateObject} alert + * @return {*} Promise + * @memberof AlertRepository + */ + async createAlert(alert: IAlertCreateObject): Promise { + const sqlStatement = SQL` + INSERT INTO + alert (name, message, alert_type_id, data, severity, record_end_date) + VALUES + (${alert.name}, ${alert.message}, ${alert.alert_type_id}, ${JSON.stringify(alert.data)}, ${alert.severity}, ${ + alert.record_end_date + }) + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to create alert', [ + 'AlertRepository->createAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } + + /** + * Delete system alert. + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertRepository + */ + async deleteAlert(alertId: number): Promise { + const sqlStatement = SQL` + DELETE FROM + alert + WHERE + alert_id = ${alertId} + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to delete alert', [ + 'AlertRepository->deleteAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } +} diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 07c079cda4..228bd6bdee 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -25,6 +25,7 @@ const SurveyProgressCode = ICode.extend({ description: z.string() }); const MethodResponseMetricsCode = ICode.extend({ description: z.string() }); const AttractantCode = ICode.extend({ description: z.string() }); const ObservationSubcountSignCode = ICode.extend({ description: z.string() }); +const AlertTypeCode = ICode.extend({ description: z.string() }); export const IAllCodeSets = z.object({ management_action_type: CodeSet(), @@ -46,7 +47,8 @@ export const IAllCodeSets = z.object({ survey_progress: CodeSet(SurveyProgressCode.shape), method_response_metrics: CodeSet(MethodResponseMetricsCode.shape), attractants: CodeSet(AttractantCode.shape), - observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape) + observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape), + alert_types: CodeSet(AlertTypeCode.shape) }); export type IAllCodeSets = z.infer; @@ -466,4 +468,26 @@ export class CodeRepository extends BaseRepository { return response.rows; } + + /** + * Fetch alert type codes + * + * @return {*} + * @memberof CodeRepository + */ + async getAlertTypes() { + const sqlStatement = SQL` + SELECT + alert_type_id AS id, + name, + description + FROM alert_type + WHERE record_end_date IS null + ORDER BY name ASC; + `; + + const response = await this.connection.sql(sqlStatement, AlertTypeCode); + + return response.rows; + } } diff --git a/api/src/services/administrative-activity-service.ts b/api/src/services/administrative-activity-service.ts index e1306e090c..873a28bf01 100644 --- a/api/src/services/administrative-activity-service.ts +++ b/api/src/services/administrative-activity-service.ts @@ -106,7 +106,7 @@ export class AdministrativeActivityService extends DBService { */ async sendAccessRequestNotificationEmailToAdmin(): Promise { const gcnotifyService = new GCNotifyService(); - const url = `${this.APP_HOST}/login?redirect=${encodeURIComponent('admin/users')}`; + const url = `${this.APP_HOST}/login?redirect=${encodeURIComponent('admin/manage/users')}`; const hrefUrl = `[click here.](${url})`; return gcnotifyService.sendEmailGCNotification(this.ADMIN_EMAIL, { diff --git a/api/src/services/alert-service.test.ts b/api/src/services/alert-service.test.ts new file mode 100644 index 0000000000..3c99559c01 --- /dev/null +++ b/api/src/services/alert-service.test.ts @@ -0,0 +1,130 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertSeverity } from '../models/alert-view'; +import { AlertRepository } from '../repositories/alert-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AlertService } from './alert-service'; + +chai.use(sinonChai); + +describe('AlertService', () => { + let alertService: AlertService; + let mockAlertRepository: sinon.SinonStubbedInstance; + + afterEach(() => { + sinon.restore(); + }); + + beforeEach(() => { + const dbConnection = getMockDBConnection(); + alertService = new AlertService(dbConnection); + mockAlertRepository = sinon.createStubInstance(AlertRepository); + alertService.alertRepository = mockAlertRepository; // Inject the mocked repository + }); + + describe('getAlerts', () => { + it('returns an array of alerts', async () => { + const mockAlerts: IAlert[] = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + } + ]; + + mockAlertRepository.getAlerts.resolves(mockAlerts); + + const filterObject: IAlertFilterObject = {}; // Define your filter object as needed + + const response = await alertService.getAlerts(filterObject); + + expect(response).to.eql(mockAlerts); + expect(mockAlertRepository.getAlerts).to.have.been.calledOnceWith(filterObject); + }); + }); + + describe('getAlertById', () => { + it('returns a specific alert by its Id', async () => { + const mockAlert: IAlert = { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + }; + + mockAlertRepository.getAlertById.resolves(mockAlert); + + const response = await alertService.getAlertById(1); + + expect(response).to.eql(mockAlert); + expect(mockAlertRepository.getAlertById).to.have.been.calledOnceWith(1); + }); + }); + + describe('createAlert', () => { + it('creates an alert and returns its Id', async () => { + const mockAlertId = 1; + const mockAlert: IAlertCreateObject = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + mockAlertRepository.createAlert.resolves(mockAlertId); + + const response = await alertService.createAlert(mockAlert); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.createAlert).to.have.been.calledOnceWith(mockAlert); + }); + }); + + describe('updateAlert', () => { + it('updates an alert and returns its Id', async () => { + const mockAlertId = 1; + const mockAlert: IAlert = { + alert_id: mockAlertId, + name: 'Updated Alert', + message: 'Updated message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + }; + + mockAlertRepository.updateAlert.resolves(mockAlertId); + + const response = await alertService.updateAlert(mockAlert); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.updateAlert).to.have.been.calledOnceWith(mockAlert); + }); + }); + + describe('deleteAlert', () => { + it('deletes an alert and returns its Id', async () => { + const mockAlertId = 1; + mockAlertRepository.deleteAlert.resolves(mockAlertId); + + const response = await alertService.deleteAlert(mockAlertId); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.deleteAlert).to.have.been.calledOnceWith(mockAlertId); + }); + }); +}); diff --git a/api/src/services/alert-service.ts b/api/src/services/alert-service.ts new file mode 100644 index 0000000000..7c8174356a --- /dev/null +++ b/api/src/services/alert-service.ts @@ -0,0 +1,69 @@ +import { IDBConnection } from '../database/db'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { AlertRepository } from '../repositories/alert-repository'; +import { DBService } from './db-service'; + +export class AlertService extends DBService { + alertRepository: AlertRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.alertRepository = new AlertRepository(connection); + } + + /** + * Get all alert records, including deactivated alerts + * + * @param {IAlertFilterObject} filterObject + * @return {*} Promise + * @memberof AlertService + */ + async getAlerts(filterObject: IAlertFilterObject): Promise { + return this.alertRepository.getAlerts(filterObject); + } + + /** + * Get a specific alert by its ID + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertService + */ + async getAlertById(alertId: number): Promise { + return this.alertRepository.getAlertById(alertId); + } + + /** + * Create a system alert. + * + * @param {IAlertCreateObjectt} alert + * @return {*} Promise + * @memberof AlertService + */ + async createAlert(alert: IAlertCreateObject): Promise { + return this.alertRepository.createAlert(alert); + } + + /** + * Update a system alert. + * + * @param {IAlertUpdateObject} alert + * @return {*} Promise + * @memberof AlertService + */ + async updateAlert(alert: IAlertUpdateObject): Promise { + return this.alertRepository.updateAlert(alert); + } + + /** + * Delete a system alert. + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertService + */ + async deleteAlert(alertId: number): Promise { + return this.alertRepository.deleteAlert(alertId); + } +} diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 9abf57fcee..12237b08bc 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -45,7 +45,8 @@ describe('CodeService', () => { 'sample_methods', 'survey_progress', 'method_response_metrics', - 'observation_subcount_signs' + 'observation_subcount_signs', + 'alert_types' ); }); }); diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 715193a3ba..9975056138 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -44,7 +44,8 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + alert_types ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -65,7 +66,8 @@ export class CodeService extends DBService { await this.codeRepository.getSurveyProgress(), await this.codeRepository.getMethodResponseMetrics(), await this.codeRepository.getAttractants(), - await this.codeRepository.getObservationSubcountSigns() + await this.codeRepository.getObservationSubcountSigns(), + await this.codeRepository.getAlertTypes() ]); return { @@ -88,7 +90,8 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + alert_types }; } diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 5e09060113..954446e2eb 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -2,7 +2,7 @@ import { AuthenticatedRouteGuard, SystemRoleRouteGuard } from 'components/securi import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContextProvider } from 'contexts/codesContext'; import { DialogContextProvider } from 'contexts/dialogContext'; -import AdminUsersRouter from 'features/admin/AdminUsersRouter'; +import AdminRouter from 'features/admin/AdminRouter'; import FundingSourcesRouter from 'features/funding-sources/FundingSourcesRouter'; import ProjectsRouter from 'features/projects/ProjectsRouter'; import ResourcesPage from 'features/resources/ResourcesPage'; @@ -78,13 +78,13 @@ const AppRouter: React.FC = () => { - + - + diff --git a/app/src/components/alert/AlertBar.tsx b/app/src/components/alert/AlertBar.tsx index ff7d46472f..fff13a5ff2 100644 --- a/app/src/components/alert/AlertBar.tsx +++ b/app/src/components/alert/AlertBar.tsx @@ -1,33 +1,40 @@ -import Alert from '@mui/material/Alert'; +import Alert, { AlertProps } from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; -import Box from '@mui/material/Box'; -import React from 'react'; -interface IAlertBarProps { +interface IAlertBarProps extends AlertProps { severity: 'error' | 'warning' | 'info' | 'success'; variant: 'filled' | 'outlined' | 'standard'; title: string; text: string | JSX.Element; } -const AlertBar: React.FC = (props) => { - const { severity, variant, title, text } = props; +/** + * Returns an alert banner + * + * @param props {IAlertBarProps} + * @returns + */ +const AlertBar = (props: IAlertBarProps) => { + const { severity, variant, title, text, ...alertProps } = props; + + const defaultProps = { + severity: 'success', + variant: 'standard', + title: '', + text: '' + }; return ( - - - {title} - {text} - - + + {title} + {text} + ); }; -AlertBar.defaultProps = { - severity: 'success', - variant: 'standard', - title: '', - text: '' -}; - export default AlertBar; diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index 421c969c23..5e39736b3b 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -25,6 +25,7 @@ export interface IAutocompleteField { required?: boolean; filterLimit?: number; showValue?: boolean; + disableClearable?: boolean; optionFilter?: 'value' | 'label'; // used to filter existing/ set data for the AutocompleteField, defaults to value in getExistingValue function getOptionDisabled?: (option: IAutocompleteFieldOption) => boolean; onChange?: (event: SyntheticEvent, option: IAutocompleteFieldOption | null) => void; @@ -67,6 +68,7 @@ const AutocompleteField = (props: IAutocompleteField< value={getExistingValue(get(values, props.name))} options={props.options} getOptionLabel={(option) => option.label} + disableClearable={props.disableClearable} isOptionEqualToValue={handleGetOptionSelected} getOptionDisabled={props.getOptionDisabled} filterOptions={createFilterOptions({ limit: props.filterLimit })} diff --git a/app/src/components/fields/DateField.tsx b/app/src/components/fields/DateField.tsx index 83ff4f36e0..fd1a4b5f69 100644 --- a/app/src/components/fields/DateField.tsx +++ b/app/src/components/fields/DateField.tsx @@ -4,25 +4,19 @@ import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DATE_FORMAT, DATE_LIMIT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; -import { FormikContextType } from 'formik'; +import { useFormikContext } from 'formik'; import { get } from 'lodash-es'; -interface IDateFieldProps { +interface IDateFieldProps { label: string; name: string; id: string; required: boolean; - formikProps: FormikContextType; } -export const DateField = (props: IDateFieldProps) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - label, - name, - id, - required - } = props; +export const DateField = (props: IDateFieldProps) => { + const { values, errors, touched, setFieldValue, setFieldError } = useFormikContext(); + const { label, name, id, required } = props; const rawDateValue = get(values, name); const formattedDateValue = @@ -34,12 +28,6 @@ export const DateField = (props: IDateFieldProps }} @@ -49,7 +37,7 @@ export const DateField = (props: IDateFieldProps(props: IDateFieldProps { - if (!value || value === 'Invalid Date') { + if (!value || !dayjs(value).isValid()) { // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will // contain an actual date string value if the field is not empty but is invalid. setFieldValue(name, null); @@ -74,6 +62,7 @@ export const DateField = (props: IDateFieldProps diff --git a/app/src/components/fields/TimeField.tsx b/app/src/components/fields/TimeField.tsx index 23772bbfab..045f02a945 100644 --- a/app/src/components/fields/TimeField.tsx +++ b/app/src/components/fields/TimeField.tsx @@ -5,25 +5,19 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { TIME_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; -import { FormikContextType } from 'formik'; +import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; -interface ITimeFieldProps { +interface ITimeFieldProps { label: string; name: string; id: string; required: boolean; - formikProps: FormikContextType; } -export const TimeField = (props: ITimeFieldProps) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - label, - name, - id, - required - } = props; +export const TimeField = (props: ITimeFieldProps) => { + const { values, errors, touched, setFieldValue } = useFormikContext(); + const { label, name, id, required } = props; const rawTimeValue = get(values, name); const formattedTimeValue = diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx index 1f6af9a314..c9d5e5e9e5 100644 --- a/app/src/components/layout/Header.test.tsx +++ b/app/src/components/layout/Header.test.tsx @@ -21,7 +21,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); @@ -40,7 +40,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); @@ -59,7 +59,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 1c87ee0caa..953ec35810 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -266,8 +266,8 @@ const Header: React.FC = () => { - - Manage Users + + Admin { - - Manage Users + + Admin diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 068bd3f0fb..6ee3b34128 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -498,3 +498,27 @@ export const SurveyExportI18N = { exportErrorText: 'An error has occurred while attempting to export survey data. Please try again. If the error persists, please contact your system administrator.' }; + +export const AlertI18N = { + cancelTitle: 'Discard changes and exit?', + cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', + + createAlertDialogTitle: 'Create Alert', + createAlertDialogText: + 'Enter a name, message, and type for the alert. The name and message will be displayed on the alert banner.', + createErrorTitle: 'Error Creating Alert', + createErrorText: + 'An error has occurred while attempting to create your alert, please try again. If the error persists, please contact your system administrator.', + + updateAlertDialogTitle: 'Edit Alert Details', + updateAlertDialogText: 'Edit the name, description and effective dates for this alert.', + updateErrorTitle: 'Error Updating Alert', + updateErrorText: + 'An error has occurred while attempting to update your Alert, please try again. If the error persists, please contact your system administrator.', + + deleteAlertErrorTitle: 'Error Deleting a Alert', + deleteAlertErrorText: + 'An error has occurred while attempting to delete the Alerts, please try again. If the error persists, please contact your system administrator.', + deleteAlertDialogTitle: 'Delete Alert?', + deleteAlertDialogText: 'Are you sure you want to permanently delete this alert? This action cannot be undone.' +}; diff --git a/app/src/features/admin/AdminManagePage.tsx b/app/src/features/admin/AdminManagePage.tsx new file mode 100644 index 0000000000..07a7db5cbb --- /dev/null +++ b/app/src/features/admin/AdminManagePage.tsx @@ -0,0 +1,67 @@ +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import PageHeader from 'components/layout/PageHeader'; +import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import AlertContainer from './alert/AlertContainer'; +import AccessRequestContainer from './users/access-requests/AccessRequestContainer'; +import ActiveUsersList from './users/active/ActiveUsersList'; + +/** + * Page to display admin functionality for managing users, alerts, etc. + * + * @return {*} + */ +const AdminManagePage = () => { + const biohubApi = useBiohubApi(); + + // ACCESS REQUESTS + const accessRequestsDataLoader = useDataLoader(() => + biohubApi.admin.getAdministrativeActivities( + [AdministrativeActivityType.SYSTEM_ACCESS], + [ + AdministrativeActivityStatusType.PENDING, + AdministrativeActivityStatusType.REJECTED, + AdministrativeActivityStatusType.ACTIONED + ] + ) + ); + + useEffect(() => { + accessRequestsDataLoader.load(); + }, [accessRequestsDataLoader]); + + // ACTIVE USERS + const activeUsersDataLoader = useDataLoader(() => biohubApi.user.getUsersList()); + useEffect(() => { + activeUsersDataLoader.load(); + }, [activeUsersDataLoader]); + + const refreshAccessRequests = () => { + accessRequestsDataLoader.refresh(); + activeUsersDataLoader.refresh(); + }; + + const refreshActiveUsers = () => { + activeUsersDataLoader.refresh(); + }; + + return ( + <> + + + + + + + + + + + + ); +}; + +export default AdminManagePage; diff --git a/app/src/features/admin/AdminUsersRouter.tsx b/app/src/features/admin/AdminRouter.tsx similarity index 58% rename from app/src/features/admin/AdminUsersRouter.tsx rename to app/src/features/admin/AdminRouter.tsx index 29daa60b0d..d1330e87c1 100644 --- a/app/src/features/admin/AdminUsersRouter.tsx +++ b/app/src/features/admin/AdminRouter.tsx @@ -2,31 +2,31 @@ import React from 'react'; import { Redirect, Route, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; -import ManageUsersPage from './users/ManageUsersPage'; +import AdminManagePage from './AdminManagePage'; import UsersDetailPage from './users/projects/UsersDetailPage'; /** - * Router for all `/admin/users/*` pages. + * Router for all `/admin/manage/*` pages. * * @return {*} */ -const AdminUsersRouter: React.FC = () => { +const AdminRouter: React.FC = () => { return ( - - + + - + {/* Catch any unknown routes, and re-direct to the not found page */} - + ); }; -export default AdminUsersRouter; +export default AdminRouter; diff --git a/app/src/features/admin/alert/AlertContainer.tsx b/app/src/features/admin/alert/AlertContainer.tsx new file mode 100644 index 0000000000..b1c9a22b71 --- /dev/null +++ b/app/src/features/admin/alert/AlertContainer.tsx @@ -0,0 +1,134 @@ +import { mdiCheck, mdiExclamationThick, mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlertFilterParams } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import CreateAlert from './create/CreateAlert'; +import DeleteAlert from './delete/DeleteAlert'; +import EditAlert from './edit/EditAlert'; +import AlertTable from './table/AlertTable'; + +enum AlertViewEnum { + ACTIVE = 'ACTIVE', + EXPIRED = 'EXPIRED' +} + +/** + * Container for displaying a list of alerts created by system administrators + */ +const AlertListContainer = () => { + const biohubApi = useBiohubApi(); + const [activeView, setActiveView] = useState(AlertViewEnum.ACTIVE); + const [modalState, setModalState] = useState({ + create: false, + edit: false, + delete: false + }); + const [alertId, setAlertId] = useState(null); + + const filters: IAlertFilterParams = + activeView === AlertViewEnum.ACTIVE ? { expiresAfter: dayjs().format() } : { expiresBefore: dayjs().format() }; + + // Load alerts based on filters + const alertDataLoader = useDataLoader((filters: IAlertFilterParams) => biohubApi.alert.getAlerts(filters)); + + // Define views + const views = [ + { value: AlertViewEnum.ACTIVE, label: 'Active', icon: mdiExclamationThick }, + { value: AlertViewEnum.EXPIRED, label: 'Expired', icon: mdiCheck } + ]; + + const closeModal = () => { + alertDataLoader.refresh(filters); + setModalState({ create: false, edit: false, delete: false }); + setAlertId(null); + }; + + useEffect(() => { + alertDataLoader.refresh(filters); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + return ( + + + + Alerts  + + + + + + view && setActiveView(view)} + exclusive + sx={{ + width: '100%', + gap: 1, + '& Button': { + py: 0.5, + px: 1.5, + border: 'none !important', + fontWeight: 700, + borderRadius: '4px !important', + fontSize: '0.875rem', + letterSpacing: '0.02rem' + } + }}> + {views.map(({ value, label, icon }) => ( + }> + {label} + + ))} + + + + + {/* Modals */} + + {alertId && modalState.edit && } + {alertId && modalState.delete && ( + + )} + + { + setAlertId(id); + setModalState((prev) => ({ ...prev, edit: true })); + }} + onDelete={(id) => { + setAlertId(id); + setModalState((prev) => ({ ...prev, delete: true })); + }} + /> + + + ); +}; + +export default AlertListContainer; diff --git a/app/src/features/admin/alert/create/CreateAlert.tsx b/app/src/features/admin/alert/create/CreateAlert.tsx new file mode 100644 index 0000000000..58253fa666 --- /dev/null +++ b/app/src/features/admin/alert/create/CreateAlert.tsx @@ -0,0 +1,118 @@ +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { AlertI18N } from 'constants/i18n'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import { AlertSeverity, IAlertCreateObject } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import yup from 'utils/YupSchema'; +import AlertForm from '../form/AlertForm'; + +const AlertYupSchema = yup.object().shape({ + name: yup.string().trim().max(50, 'Name cannot exceed 50 characters').required('Name is required'), + message: yup.string().max(250, 'Message cannot exceed 250 characters').required('Message is required'), + alert_type_id: yup.number().integer().required('Page is required'), + severity: yup.string().required('Style is required'), + record_end_date: yup.string().isValidDateString().nullable() +}); + +interface ICreateAlertProps { + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog containing the alert form for creating a new system alert + * + * @param {ICreateAlertProps} props + */ +const CreateAlert = (props: ICreateAlertProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + + const biohubApi = useBiohubApi(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext]); + + const alertTypeOptions = + codesContext.codesDataLoader.data?.alert_types.map((type) => ({ + value: type.id, + label: type.name + })) ?? []; + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: AlertI18N.createErrorTitle, + dialogText: AlertI18N.createErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmitAlert = async (values: IAlertCreateObject) => { + try { + setIsSubmitting(true); + + await biohubApi.alert.createAlert(values); + + // creation was a success, tell parent to refresh + props.onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert '{values.name}' created + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + , + initialValues: { + name: '', + message: '', + alert_type_id: '' as unknown as number, + severity: 'info' as AlertSeverity, + data: null, + record_end_date: null + }, + validationSchema: AlertYupSchema + }} + dialogSaveButtonLabel="Create" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmitAlert(formValues); + }} + /> + ); +}; + +export default CreateAlert; diff --git a/app/src/features/admin/alert/delete/DeleteAlert.tsx b/app/src/features/admin/alert/delete/DeleteAlert.tsx new file mode 100644 index 0000000000..c836e1c484 --- /dev/null +++ b/app/src/features/admin/alert/delete/DeleteAlert.tsx @@ -0,0 +1,91 @@ +import Typography from '@mui/material/Typography'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { AlertI18N } from 'constants/i18n'; +import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useContext, useEffect } from 'react'; + +interface IDeleteAlertProps { + alertId: number; + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog for deleting an alert + * + * @param {IDeleteAlertProps} props + * @returns + */ +const DeleteAlert = (props: IDeleteAlertProps) => { + const { alertId, open, onClose } = props; + const dialogContext = useContext(DialogContext); + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => biohubApi.alert.getAlertById(alertId)); + + useEffect(() => { + alertDataLoader.load(); + }, [alertDataLoader]); + + // API Error dialog + const showDeleteErrorDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: AlertI18N.deleteAlertErrorTitle, + dialogText: AlertI18N.deleteAlertErrorText, + open: true, + onYes: async () => dialogContext.setYesNoDialog({ open: false }), + onClose: () => dialogContext.setYesNoDialog({ open: false }) + }); + }; + + // Success snack bar + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + + const deleteAlert = async () => { + try { + await biohubApi.alert.deleteAlert(alertId); + // delete was a success, tell parent to refresh + onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert deleted + + ), + open: true + }); + } catch (error) { + // error deleting, show dialog that says you need to remove references + onClose(false); + showDeleteErrorDialog(); + } + }; + + if (!alertDataLoader.isReady || !alertDataLoader.data) { + return <>; + } + + return ( + { + deleteAlert(); + }} + onClose={() => {}} + onNo={() => onClose()} + /> + ); +}; + +export default DeleteAlert; diff --git a/app/src/features/admin/alert/edit/EditAlert.tsx b/app/src/features/admin/alert/edit/EditAlert.tsx new file mode 100644 index 0000000000..0a7cc993d2 --- /dev/null +++ b/app/src/features/admin/alert/edit/EditAlert.tsx @@ -0,0 +1,130 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { AlertI18N } from 'constants/i18n'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlertUpdateObject } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import yup from 'utils/YupSchema'; +import AlertForm from '../form/AlertForm'; + +interface IEditAlertProps { + alertId: number; + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog containing the alert form for editing an existing system alert + * + * @param {IEditAlertProps} props + * + */ +const EditAlert = (props: IEditAlertProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => biohubApi.alert.getAlertById(props.alertId)); + + useEffect(() => { + alertDataLoader.load(); + codesContext.codesDataLoader.load(); + }, [alertDataLoader, codesContext]); + + const alertTypeOptions = + codesContext.codesDataLoader.data?.alert_types.map((type) => ({ + value: type.id, + label: type.name + })) ?? []; + + // This is placed inside the `EditAlert` component to make use of an API call to check for used names + // The API call would violate the rules of react hooks if placed in an object outside of the component + // Reference: https://react.dev/warnings/invalid-hook-call-warning + const AlertYupSchema = yup.object().shape({ + name: yup.string().trim().max(50, 'Name cannot exceed 50 characters').required('Name is required'), + message: yup.string().max(250, 'Message cannot exceed 250 characters').required('Message is required'), + record_end_date: yup.string().isValidDateString().nullable() + }); + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: AlertI18N.updateErrorTitle, + dialogText: AlertI18N.updateErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmit = async (values: IAlertUpdateObject) => { + try { + setIsSubmitting(true); + + await biohubApi.alert.updateAlert(values); + + // creation was a success, tell parent to refresh + props.onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert '{values.name}' saved + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } finally { + setIsSubmitting(false); + } + }; + + if (!alertDataLoader.isReady || !alertDataLoader.data) { + return ; + } + + return ( + , + initialValues: { + alert_id: alertDataLoader.data.alert_id, + name: alertDataLoader.data.name, + message: alertDataLoader.data.message, + alert_type_id: alertDataLoader.data.alert_type_id, + severity: alertDataLoader.data.severity, + data: alertDataLoader.data.data, + record_end_date: alertDataLoader.data.record_end_date + }, + validationSchema: AlertYupSchema + }} + dialogSaveButtonLabel="Save" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmit(formValues); + }} + /> + ); +}; + +export default EditAlert; diff --git a/app/src/features/admin/alert/form/AlertForm.tsx b/app/src/features/admin/alert/form/AlertForm.tsx new file mode 100644 index 0000000000..3190abf843 --- /dev/null +++ b/app/src/features/admin/alert/form/AlertForm.tsx @@ -0,0 +1,81 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { DateField } from 'components/fields/DateField'; +import { useFormikContext } from 'formik'; +import { IAlertCreateObject } from 'interfaces/useAlertApi.interface'; + +interface IAlertFormProps { + alertTypeOptions: IAutocompleteFieldOption[]; +} + +/** + * Form used to create and update system alerts, used by system administrators + * + */ +const AlertForm = (props: IAlertFormProps) => { + const { alertTypeOptions } = props; + + const { values } = useFormikContext(); + + return ( + <> +
+ + Display information + + + + + + + + + + + Expiry date (optional) + + + + +
+ + Preview + + + + ); +}; + +export default AlertForm; diff --git a/app/src/features/admin/alert/table/AlertTable.tsx b/app/src/features/admin/alert/table/AlertTable.tsx new file mode 100644 index 0000000000..47793fdb16 --- /dev/null +++ b/app/src/features/admin/alert/table/AlertTable.tsx @@ -0,0 +1,121 @@ +import Box from '@mui/material/Box'; +import { green, red } from '@mui/material/colors'; +import { GridColDef } from '@mui/x-data-grid'; +import AlertBar from 'components/alert/AlertBar'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useCodesContext } from 'hooks/useContext'; +import { AlertSeverity, IAlert } from 'interfaces/useAlertApi.interface'; +import AlertTableActionsMenu from './components/AlertTableActionsMenu'; + +export interface IAlertTableTableProps { + alerts: IAlert[]; + onEdit: (alertId: number) => void; + onDelete: (alertId: number) => void; +} + +export interface IAlertTableRow { + id: number; + alert_type_id: number; + severity: AlertSeverity; + name: string; + message: string; + data: object | null; + record_end_date: string | null; + status: 'expired' | 'active'; +} + +/** + * Data grid table displaying alerts created by system administrators + * + * @param {IAlertTableTableProps} props + */ +const AlertTable = (props: IAlertTableTableProps) => { + const codesContext = useCodesContext(); + + const rows: IAlertTableRow[] = props.alerts.map((alert) => ({ ...alert, id: alert.alert_id })); + + const columns: GridColDef[] = [ + { + field: 'preview', + headerName: 'Alert', + flex: 1, + renderCell: (params) => ( + + + + ) + }, + { + field: 'alert_type_id', + headerName: 'Page', + description: 'Page that the alert displays on.', + headerAlign: 'left', + align: 'left', + width: 150, + renderCell: (params) => + codesContext.codesDataLoader.data?.alert_types.find((code) => code.id === params.row.alert_type_id)?.name + }, + { + field: 'record_end_date', + headerName: 'Expiry date', + description: 'Status of the alert.', + headerAlign: 'left', + align: 'left', + width: 150, + renderCell: (params) => + params.row.record_end_date ? dayjs(params.row.record_end_date).format(DATE_FORMAT.MediumDateFormat) : null + }, + { + field: 'status', + headerName: 'Status', + description: 'Status of the alert.', + headerAlign: 'center', + align: 'center', + width: 150, + renderCell: (params) => ( + + ) + }, + { + field: 'actions', + type: 'actions', + sortable: false, + align: 'right', + flex: 0, + renderCell: (params) => ( + + ) + } + ]; + + return ( + 'auto'} + rows={rows} + getRowId={(row) => `alert-${row.alert_id}`} + columns={columns} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + hideFooter + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + sortingOrder={['asc', 'desc']} + data-testid="alert-table" + /> + ); +}; + +export default AlertTable; diff --git a/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx b/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx new file mode 100644 index 0000000000..17a7743fe7 --- /dev/null +++ b/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx @@ -0,0 +1,81 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import { useState } from 'react'; + +export interface IAlertTableActionsMenuProps { + alertId: number; + onEdit: (alertId: number) => void; + onDelete: (alertId: number) => void; +} + +/** + * Actions displayed in the context menu of an alert row in the alert table data grid + * + * @param {IAlertTableActionsMenuProps} props + */ +const AlertTableActionsMenu = (props: IAlertTableActionsMenuProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: any) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + { + handleClose(); + props.onEdit(props.alertId); + }} + data-testid="alert-table-row-edit"> + + + + Edit + + { + handleClose(); + props.onDelete(props.alertId); + }} + data-testid="alert-table-row-delete"> + + + + Delete + + + + ); +}; + +export default AlertTableActionsMenu; diff --git a/app/src/features/admin/users/ManageUsersPage.test.tsx b/app/src/features/admin/users/ManageUsersPage.test.tsx deleted file mode 100644 index ea17d4e119..0000000000 --- a/app/src/features/admin/users/ManageUsersPage.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { AuthStateContext } from 'contexts/authStateContext'; -import { CodesContext, ICodesContext } from 'contexts/codesContext'; -import { createMemoryHistory } from 'history'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { Router } from 'react-router'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { codes } from 'test-helpers/code-helpers'; -import { cleanup, render, waitFor } from 'test-helpers/test-utils'; -import ManageUsersPage from './ManageUsersPage'; - -const history = createMemoryHistory(); - -const renderContainer = () => { - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes, - load: () => {} - } as DataLoader - }; - - return render( - - - - - - - - ); -}; - -jest.mock('../../../hooks/useBioHubApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - admin: { - getAdministrativeActivities: jest.fn() - }, - user: { - getUsersList: jest.fn() - }, - codes: { - getAllCodeSets: jest.fn() - } -}; - -describe('ManageUsersPage', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.admin.getAdministrativeActivities.mockClear(); - mockUseApi.user.getUsersList.mockClear(); - mockUseApi.codes.getAllCodeSets.mockClear(); - - // mock code set response - mockUseApi.codes.getAllCodeSets.mockReturnValue({ - system_roles: [{ id: 1, name: 'Role 1' }], - administrative_activity_status_type: [ - { id: 1, name: 'Actioned' }, - { id: 1, name: 'Rejected' } - ] - }); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders the main page content correctly', async () => { - mockUseApi.admin.getAdministrativeActivities.mockReturnValue([]); - mockUseApi.user.getUsersList.mockReturnValue([]); - - const { getByText } = renderContainer(); - - await waitFor(() => { - expect(getByText('Manage Users')).toBeVisible(); - }); - }); - - it('renders the access requests and active users component', async () => { - mockUseApi.admin.getAdministrativeActivities.mockReturnValue([]); - mockUseApi.user.getUsersList.mockReturnValue([]); - - const { getByText } = renderContainer(); - - await waitFor(() => { - expect(getByText('No Pending Access Requests')).toBeVisible(); - expect(getByText('No Active Users')).toBeVisible(); - }); - }); -}); diff --git a/app/src/features/admin/users/ManageUsersPage.tsx b/app/src/features/admin/users/ManageUsersPage.tsx deleted file mode 100644 index 9c11f7e044..0000000000 --- a/app/src/features/admin/users/ManageUsersPage.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import Container from '@mui/material/Container'; -import PageHeader from 'components/layout/PageHeader'; -import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; -import { ISystemUser } from 'interfaces/useUserApi.interface'; -import React, { useEffect, useState } from 'react'; -import AccessRequestContainer from './access-requests/AccessRequestContainer'; -import ActiveUsersList from './active/ActiveUsersList'; - -/** - * Page to display user management data/functionality. - * - * @return {*} - */ -const ManageUsersPage: React.FC = () => { - const biohubApi = useBiohubApi(); - - const [accessRequests, setAccessRequests] = useState([]); - const [isLoadingAccessRequests, setIsLoadingAccessRequests] = useState(false); - const [hasLoadedAccessRequests, setHasLoadedAccessRequests] = useState(false); - - const [activeUsers, setActiveUsers] = useState([]); - const [isLoadingActiveUsers, setIsLoadingActiveUsers] = useState(false); - const [hasLoadedActiveUsers, setHasLoadedActiveUsers] = useState(false); - - const [codes, setCodes] = useState(); - const [isLoadingCodes, setIsLoadingCodes] = useState(false); - - const refreshAccessRequests = async () => { - const accessResponse = await biohubApi.admin.getAdministrativeActivities( - [AdministrativeActivityType.SYSTEM_ACCESS], - [ - AdministrativeActivityStatusType.PENDING, - AdministrativeActivityStatusType.REJECTED, - AdministrativeActivityStatusType.ACTIONED - ] - ); - - setAccessRequests(accessResponse); - }; - - useEffect(() => { - const getAccessRequests = async () => { - const accessResponse = await biohubApi.admin.getAdministrativeActivities( - [AdministrativeActivityType.SYSTEM_ACCESS], - [ - AdministrativeActivityStatusType.PENDING, - AdministrativeActivityStatusType.REJECTED, - AdministrativeActivityStatusType.ACTIONED - ] - ); - - setAccessRequests(() => { - setHasLoadedAccessRequests(true); - setIsLoadingAccessRequests(false); - return accessResponse; - }); - }; - - if (isLoadingAccessRequests || hasLoadedAccessRequests) { - return; - } - - setIsLoadingAccessRequests(true); - - getAccessRequests(); - }, [biohubApi.admin, isLoadingAccessRequests, hasLoadedAccessRequests]); - - const refreshActiveUsers = async () => { - const activeUsersResponse = await biohubApi.user.getUsersList(); - - setActiveUsers(activeUsersResponse); - }; - - useEffect(() => { - const getActiveUsers = async () => { - const activeUsersResponse = await biohubApi.user.getUsersList(); - - setActiveUsers(() => { - setHasLoadedActiveUsers(true); - setIsLoadingActiveUsers(false); - return activeUsersResponse; - }); - }; - - if (hasLoadedActiveUsers || isLoadingActiveUsers) { - return; - } - - setIsLoadingActiveUsers(true); - - getActiveUsers(); - }, [biohubApi, isLoadingActiveUsers, hasLoadedActiveUsers]); - - useEffect(() => { - const getCodes = async () => { - const codesResponse = await biohubApi.codes.getAllCodeSets(); - - if (!codesResponse) { - // TODO error handling/messaging - return; - } - - setCodes(() => { - setIsLoadingCodes(false); - return codesResponse; - }); - }; - - if (isLoadingCodes || codes) { - return; - } - - setIsLoadingCodes(true); - - getCodes(); - }, [biohubApi.codes, isLoadingCodes, codes]); - - if (!hasLoadedAccessRequests || !hasLoadedActiveUsers || !codes) { - return ; - } - - return ( - <> - - - { - refreshAccessRequests(); - refreshActiveUsers(); - }} - /> - - - - - - ); -}; - -export default ManageUsersPage; diff --git a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx index 051a9616ce..154f211262 100644 --- a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx +++ b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx @@ -9,7 +9,6 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup/ToggleButtonGroup import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { useState } from 'react'; import AccessRequestActionedList from './list/actioned/AccessRequestActionedList'; import AccessRequestPendingList from './list/pending/AccessRequestPendingList'; @@ -17,7 +16,6 @@ import AccessRequestRejectedList from './list/rejected/AccessRequestRejectedList export interface IAccessRequestContainerProps { accessRequests: IGetAccessRequestsListResponse[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -32,7 +30,8 @@ enum AccessRequestViewEnum { * */ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { - const { accessRequests, codes, refresh } = props; + const { accessRequests, refresh } = props; + const [activeView, setActiveView] = useState(AccessRequestViewEnum.PENDING); const views = [ @@ -107,7 +106,7 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { {activeView === AccessRequestViewEnum.PENDING && ( - + )} {activeView === AccessRequestViewEnum.ACTIONED && ( diff --git a/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx index 8144e3da8c..07a362026c 100644 --- a/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx +++ b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx @@ -11,9 +11,9 @@ import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import dayjs from 'dayjs'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext } from 'hooks/useContext'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; -import { useContext, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import ReviewAccessRequestForm, { IReviewAccessRequestForm, ReviewAccessRequestFormInitialValues, @@ -22,7 +22,6 @@ import ReviewAccessRequestForm, { interface IAccessRequestPendingListProps { accessRequests: IGetAccessRequestsListResponse[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -33,7 +32,10 @@ interface IAccessRequestPendingListProps { * @returns */ const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { - const { accessRequests, codes, refresh } = props; + const { accessRequests, refresh } = props; + + const codesContext = useCodesContext(); + const codes = codesContext.codesDataLoader?.data; const biohubApi = useBiohubApi(); const dialogContext = useContext(DialogContext); @@ -41,6 +43,10 @@ const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { const [showReviewDialog, setShowReviewDialog] = useState(false); const [activeReview, setActiveReview] = useState(null); + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + const showSnackBar = (textDialogProps?: Partial) => { dialogContext.setSnackbar({ ...textDialogProps, open: true }); }; diff --git a/app/src/features/admin/users/active/ActiveUsersList.test.tsx b/app/src/features/admin/users/active/ActiveUsersList.test.tsx index 272db2df25..755bd0f8e8 100644 --- a/app/src/features/admin/users/active/ActiveUsersList.test.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.test.tsx @@ -60,7 +60,6 @@ describe('ActiveUsersList', () => { it('shows `No Active Users` when there are no active users', async () => { const { getByText } = renderContainer({ activeUsers: [], - codes: codes, refresh: () => {} }); @@ -72,7 +71,6 @@ describe('ActiveUsersList', () => { it('renders the add new users button correctly', async () => { const { getByTestId } = renderContainer({ activeUsers: [], - codes: codes, refresh: () => {} }); diff --git a/app/src/features/admin/users/active/ActiveUsersList.tsx b/app/src/features/admin/users/active/ActiveUsersList.tsx index f6380948e2..2e9073a02c 100644 --- a/app/src/features/admin/users/active/ActiveUsersList.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.tsx @@ -18,7 +18,6 @@ import { APIError } from 'hooks/api/useAxios'; import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useCodesContext } from 'hooks/useContext'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import { useContext, useEffect, useState } from 'react'; import { useHistory } from 'react-router'; @@ -32,7 +31,6 @@ import AddSystemUsersForm, { export interface IActiveUsersListProps { activeUsers: ISystemUser[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -43,7 +41,7 @@ const pageSizeOptions = [10, 25, 50]; * */ const ActiveUsersList = (props: IActiveUsersListProps) => { - const { activeUsers, codes, refresh } = props; + const { activeUsers, refresh } = props; const authStateContext = useAuthStateContext(); const biohubApi = useBiohubApi(); @@ -59,6 +57,12 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); + const codes = codesContext.codesDataLoader.data; + + if (!codes) { + return <>; + } + const activeUsersColumnDefs: GridColDef[] = [ { field: 'system_user_id', @@ -86,7 +90,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { {params.row.display_name || 'No identifier'} @@ -159,7 +163,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { menuLabel: 'View Users Details', menuOnClick: () => history.push({ - pathname: `/admin/users/${params.row.system_user_id}`, + pathname: `/admin/manage/users/${params.row.system_user_id}`, state: params.row }) }, diff --git a/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx index cda6451ea1..ebf91a260c 100644 --- a/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx @@ -41,7 +41,7 @@ describe('UsersDetailHeader', () => { }); it('renders correctly when selectedUser are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByTestId } = render( @@ -56,7 +56,7 @@ describe('UsersDetailHeader', () => { describe('Are you sure? Dialog', () => { it('Remove User button opens dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -74,7 +74,7 @@ describe('UsersDetailHeader', () => { }); it('does nothing if the user clicks `Cancel` or away from the dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -93,16 +93,16 @@ describe('UsersDetailHeader', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); - it('deletes the user and routes user back to Manage Users page', async () => { + it('deletes the user and routes user back to Admin page', async () => { mockUseApi.user.deleteSystemUser.mockResolvedValue({ response: 200 } as any); - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -121,7 +121,7 @@ describe('UsersDetailHeader', () => { fireEvent.click(getByText('Remove')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users'); + expect(history.location.pathname).toEqual('/admin/manage/users'); }); }); }); diff --git a/app/src/features/admin/users/projects/UsersDetailHeader.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.tsx index 48d73acebd..ac52ecd4f7 100644 --- a/app/src/features/admin/users/projects/UsersDetailHeader.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.tsx @@ -79,7 +79,7 @@ const UsersDetailHeader: React.FC = (props) => { open: true }); - history.push('/admin/users'); + history.push('/admin/manage/users'); } catch (error) { openErrorDialog({ dialogTitle: SystemUserI18N.removeUserErrorTitle, @@ -93,8 +93,8 @@ const UsersDetailHeader: React.FC = (props) => { '}> - - Manage Users + + Admin {userDetails.display_name} diff --git a/app/src/features/admin/users/projects/UsersDetailPage.test.tsx b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx index e12cf2326b..dcf73b1557 100644 --- a/app/src/features/admin/users/projects/UsersDetailPage.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx @@ -51,7 +51,7 @@ describe('UsersDetailPage', () => { }); it('renders correctly when selectedUser are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.user.getUserById.mockResolvedValue({ system_user_id: 1, diff --git a/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx index ebac9cf029..4c26dbc221 100644 --- a/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx @@ -52,7 +52,7 @@ describe('UsersDetailProjects', () => { }); it('shows circular spinner when assignedProjects not yet loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByTestId } = render( @@ -66,7 +66,7 @@ describe('UsersDetailProjects', () => { }); it('renders empty list correctly when assignedProjects empty and loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }] @@ -87,7 +87,7 @@ describe('UsersDetailProjects', () => { }); it('renders list of a single project correctly when assignedProjects are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -119,7 +119,7 @@ describe('UsersDetailProjects', () => { }); it('renders list of a multiple projects correctly when assignedProjects are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -161,7 +161,7 @@ describe('UsersDetailProjects', () => { }); it('routes to project id details on click', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -199,7 +199,7 @@ describe('UsersDetailProjects', () => { describe('Are you sure? Dialog', () => { it('does nothing if the user clicks `Cancel` or away from the dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -239,12 +239,12 @@ describe('UsersDetailProjects', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); it('deletes User from project if the user clicks on `Remove` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -317,7 +317,7 @@ describe('UsersDetailProjects', () => { describe('Change users Project Role', () => { it('renders list of roles to change per project', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -361,7 +361,7 @@ describe('UsersDetailProjects', () => { }); it('renders dialog pop on role selection, does nothing if user clicks `Cancel` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -414,12 +414,12 @@ describe('UsersDetailProjects', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); it('renders dialog pop on role selection, Changes role on click of `Change Role` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], diff --git a/app/src/features/alert/banner/SystemAlertBanner.tsx b/app/src/features/alert/banner/SystemAlertBanner.tsx new file mode 100644 index 0000000000..8e5863969f --- /dev/null +++ b/app/src/features/alert/banner/SystemAlertBanner.tsx @@ -0,0 +1,116 @@ +import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlert, SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; + +interface ISystemAlertBannerProps { + alertTypes?: SystemAlertBannerEnum[]; +} + +// The number of alerts to show on initial page load +const NumberOfAlertsShownInitially = 2; + +/** + * Stack of system alerts created by system administrators + * + * @param {ISystemAlertBannerProps} props + * @returns + */ +export const SystemAlertBanner = (props: ISystemAlertBannerProps) => { + const { alertTypes } = props; + + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => + biohubApi.alert.getAlerts({ types: alertTypes, expiresAfter: dayjs().format() }) + ); + + useEffect(() => { + alertDataLoader.load(); + }, [alertDataLoader]); + + const [isExpanded, setIsExpanded] = useState(false); + + const alerts = alertDataLoader.data?.alerts ?? []; + + const numberOfAlerts = alerts.length; + + const renderAlerts = (alerts: IAlert[]) => { + const visibleAlerts = []; + const collapsedAlerts = []; + + for (let index = 0; index < numberOfAlerts; index++) { + const alert = alerts[index]; + + const alertComponent = ( + + ); + + if (index < NumberOfAlertsShownInitially) { + visibleAlerts.push(alertComponent); + } else { + collapsedAlerts.push(alertComponent); + } + } + + return ( + + {visibleAlerts} + {collapsedAlerts.length > 0 && {collapsedAlerts}} + + ); + }; + + if (!numberOfAlerts) { + return null; + } + + return ( + + {renderAlerts(alerts)} + {numberOfAlerts > NumberOfAlertsShownInitially && ( + + )} + + ); +}; diff --git a/app/src/features/funding-sources/list/FundingSourcesListPage.tsx b/app/src/features/funding-sources/list/FundingSourcesListPage.tsx index 7b2360a41a..e303eb8b3c 100644 --- a/app/src/features/funding-sources/list/FundingSourcesListPage.tsx +++ b/app/src/features/funding-sources/list/FundingSourcesListPage.tsx @@ -10,10 +10,12 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import PageHeader from 'components/layout/PageHeader'; import { FundingSourceI18N } from 'constants/i18n'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import useDataLoaderError from 'hooks/useDataLoaderError'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import React, { useState } from 'react'; import CreateFundingSource from '../components/CreateFundingSource'; import DeleteFundingSource from '../components/DeleteFundingSource'; @@ -111,6 +113,7 @@ const FundingSourcesListPage: React.FC = () => { /> + diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index e26ec4fdf7..6dd72d2423 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -5,8 +5,10 @@ import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import ProjectAttachments from 'features/projects/view/ProjectAttachments'; import SurveysListPage from 'features/surveys/list/SurveysListPage'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useContext, useEffect } from 'react'; import ProjectDetails from './ProjectDetails'; import ProjectHeader from './ProjectHeader'; @@ -36,6 +38,7 @@ const ProjectPage = () => { <> + diff --git a/app/src/features/standards/StandardsPage.tsx b/app/src/features/standards/StandardsPage.tsx index 7c963b5d93..9a1af27b41 100644 --- a/app/src/features/standards/StandardsPage.tsx +++ b/app/src/features/standards/StandardsPage.tsx @@ -6,6 +6,8 @@ import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import PageHeader from 'components/layout/PageHeader'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useState } from 'react'; import { StandardsToolbar } from './components/StandardsToolbar'; import { EnvironmentStandards } from './view/environment/EnvironmentStandards'; @@ -37,6 +39,7 @@ const StandardsPage = () => { <> + {/* TOOLBAR FOR SWITCHING VIEWS */} diff --git a/app/src/features/summary/SummaryPage.tsx b/app/src/features/summary/SummaryPage.tsx index b70623d604..39d50016db 100644 --- a/app/src/features/summary/SummaryPage.tsx +++ b/app/src/features/summary/SummaryPage.tsx @@ -6,8 +6,10 @@ import Paper from '@mui/material/Paper'; import PageHeader from 'components/layout/PageHeader'; import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { ListDataTableContainer } from 'features/summary/list-data/ListDataTableContainer'; import { TabularDataTableContainer } from 'features/summary/tabular-data/TabularDataTableContainer'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { Link as RouterLink } from 'react-router-dom'; /** @@ -36,6 +38,8 @@ const SummaryPage = () => { /> + + diff --git a/app/src/features/surveys/animals/AnimalPage.tsx b/app/src/features/surveys/animals/AnimalPage.tsx index 07058fdb2d..b8418906ad 100644 --- a/app/src/features/surveys/animals/AnimalPage.tsx +++ b/app/src/features/surveys/animals/AnimalPage.tsx @@ -1,9 +1,11 @@ import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import Box from '@mui/system/Box'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useAnimalPageContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useEffect } from 'react'; import { AnimalHeader } from './AnimalHeader'; import { AnimalListContainer } from './list/AnimalListContainer'; @@ -56,6 +58,7 @@ export const SurveyAnimalPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> + { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> - { /> + diff --git a/app/src/features/surveys/telemetry/TelemetryPage.tsx b/app/src/features/surveys/telemetry/TelemetryPage.tsx index c91b025ff6..f969f3f3f6 100644 --- a/app/src/features/surveys/telemetry/TelemetryPage.tsx +++ b/app/src/features/surveys/telemetry/TelemetryPage.tsx @@ -2,12 +2,14 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { SurveyDeploymentList } from 'features/surveys/telemetry/list/SurveyDeploymentList'; import { TelemetryTableContainer } from 'features/surveys/telemetry/table/TelemetryTableContainer'; import { TelemetryHeader } from 'features/surveys/telemetry/TelemetryHeader'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useEffect } from 'react'; export const TelemetryPage = () => { @@ -72,6 +74,7 @@ export const TelemetryPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> + {/* Telematry List */} diff --git a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx index df3b446537..667ec1691d 100644 --- a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx +++ b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx @@ -243,14 +243,12 @@ export const DeploymentTimelineForm = (props: IDeploymentTimelineFormProps) => { name="attachment_end_date" label="End date" required={values.attachment_end_time !== null} - formikProps={formikProps} /> )} diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index be139300f0..8f42f5d485 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -5,7 +5,9 @@ import Stack from '@mui/material/Stack'; import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import React, { useContext, useEffect } from 'react'; import { SurveySamplingTableContainer } from './components/sampling-data/SurveySamplingTableContainer'; import SurveyStudyArea from './components/SurveyStudyArea'; @@ -34,6 +36,7 @@ const SurveyPage: React.FC = () => { <> + diff --git a/app/src/hooks/api/useAlertApi.ts b/app/src/hooks/api/useAlertApi.ts new file mode 100644 index 0000000000..3dd402e7bc --- /dev/null +++ b/app/src/hooks/api/useAlertApi.ts @@ -0,0 +1,88 @@ +import { AxiosInstance } from 'axios'; +import { + IAlert, + IAlertCreateObject, + IAlertFilterParams, + IAlertUpdateObject, + IGetAlertsResponse +} from 'interfaces/useAlertApi.interface'; +import qs from 'qs'; + +/** + * Returns a set of supported api methods for managing alerts + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +export const useAlertApi = (axios: AxiosInstance) => { + /** + * Get system alert details based on its ID for viewing purposes. + * + * @param {IAlertFilterParams} filterObject + * @return {*} {Promise} + */ + const getAlerts = async (filterObject?: IAlertFilterParams): Promise => { + const params = { + ...filterObject + }; + + const { data } = await axios.get(`/api/alert`, { + params: params, + paramsSerializer: (params: any) => { + return qs.stringify(params); + } + }); + + return data; + }; + + /** + * Get a specific alert for editing + * + * @param {number} alertId + * @return {*} {Promise} + */ + const getAlertById = async (alertId: number): Promise => { + const { data } = await axios.get(`/api/alert/${alertId}`); + + return data; + }; + + /** + * Create a new system alert + * + * @param {IAlertCreateObject} alert + * @return {*} {Promise} + */ + const createAlert = async (alert: IAlertCreateObject): Promise => { + const { data } = await axios.post(`/api/alert`, alert); + + return data; + }; + + /** + * Create a new system alert + * + * @param {IAlert} alert + * @return {*} {Promise<{ alert_id: number }>} + */ + const updateAlert = async (alert: IAlertUpdateObject): Promise<{ alert_id: number }> => { + const { data } = await axios.put(`/api/alert/${alert.alert_id}`, alert); + + return data; + }; + + /** + * Get system alert details based on its ID for viewing purposes. + * + * @param {number} alertId + * @return {*} {Promise<{ alert_id: number }>} + */ + const deleteAlert = async (alertId: number): Promise<{ alert_id: number }> => { + const { data } = await axios.delete(`/api/alert/${alertId}`); + + return data; + }; + + return { getAlerts, updateAlert, createAlert, deleteAlert, getAlertById }; +}; diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index 0115bd2df5..794ca897e1 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -3,6 +3,7 @@ import useReferenceApi from 'hooks/api/useReferenceApi'; import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAdminApi from './api/useAdminApi'; +import { useAlertApi } from './api/useAlertApi'; import useAnalyticsApi from './api/useAnalyticsApi'; import useAnimalApi from './api/useAnimalApi'; import useAxios from './api/useAxios'; @@ -72,6 +73,8 @@ export const useBiohubApi = () => { const telemetry = useTelemetryApi(apiAxios); + const alert = useAlertApi(apiAxios); + return useMemo( () => ({ analytics, @@ -93,7 +96,8 @@ export const useBiohubApi = () => { samplingSite, standards, reference, - telemetry + telemetry, + alert }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/src/interfaces/useAlertApi.interface.ts b/app/src/interfaces/useAlertApi.interface.ts new file mode 100644 index 0000000000..deb5e5d6bd --- /dev/null +++ b/app/src/interfaces/useAlertApi.interface.ts @@ -0,0 +1,38 @@ +export interface IGetAlertsResponse { + alerts: IAlert[]; +} + +export type AlertSeverity = 'info' | 'success' | 'error' | 'warning'; +export interface IAlert { + alert_id: number; + alert_type_id: number; + severity: AlertSeverity; + name: string; + message: string; + data: object | null; + record_end_date: string | null; + status: 'expired' | 'active'; +} + +export type IAlertCreateObject = Omit; + +export type IAlertUpdateObject = Omit; + +export interface IAlertFilterParams { + expiresBefore?: string; + expiresAfter?: string; + types?: SystemAlertBannerEnum[]; +} + +export enum SystemAlertBannerEnum { + SUMMARY = 'Summary', + TELEMETRY = 'Manage Telemetry', + OBSERVATIONS = 'Manage Observations', + ANIMALS = 'Manage Animals', + SAMPLING = 'Manage Sampling', + PROJECTS = 'Project', + SURVEYS = 'Survey', + STANDARDS = 'Standards', + ADMINISTRATOR = 'Administrator', + FUNDING = 'Funding' +} diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 61d67cc9ea..a053431566 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -41,4 +41,5 @@ export interface IGetAllCodeSetsResponse { method_response_metrics: CodeSet<{ id: number; name: string; description: string }>; attractants: CodeSet<{ id: number; name: string; description: string }>; observation_subcount_signs: CodeSet<{ id: number; name: string; description: string }>; + alert_types: CodeSet<{ id: number; name: string; description: string }>; } diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 240f3e60ce..368f48a5da 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -66,5 +66,9 @@ export const codes: IGetAllCodeSetsResponse = { observation_subcount_signs: [ { id: 1, name: 'Scat', description: 'Scat left by the species.' }, { id: 2, name: 'Direct sighting', description: 'A direct sighting of the species.' } + ], + alert_types: [ + { id: 1, name: 'Survey', description: 'Alert about surveys.' }, + { id: 2, name: 'General', description: 'General alert.' } ] }; diff --git a/database/src/migrations/20240927000000_alert_table.ts b/database/src/migrations/20240927000000_alert_table.ts new file mode 100644 index 0000000000..a1b8e0ad8a --- /dev/null +++ b/database/src/migrations/20240927000000_alert_table.ts @@ -0,0 +1,130 @@ +import { Knex } from 'knex'; + +/** + * New table: + * - alert + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Create lookup table for alert type options + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub; + + CREATE TABLE alert_type ( + alert_type_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(100) NOT NULL, + description varchar(100) NOT NULL, + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT alert_type_pk PRIMARY KEY (alert_type_id) + ); + + COMMENT ON TABLE alert_type IS 'Alert type options that alerts can belong to.'; + COMMENT ON COLUMN alert_type.alert_type_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN alert_type.name IS 'The name of the alert_type.'; + COMMENT ON COLUMN alert_type.description IS 'The description of the alert_type and its intended use case.'; + COMMENT ON COLUMN alert_type.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN alert_type.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN alert_type.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN alert_type.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN alert_type.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN alert_type.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Create new table + ---------------------------------------------------------------------------------------- + + CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'error', 'success'); + + CREATE TABLE alert ( + alert_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + alert_type_id integer NOT NULL, + name varchar(50) NOT NULL, + message varchar(250) NOT NULL, + data json, + severity alert_severity NOT NULL, + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT alert_pk PRIMARY KEY (alert_id) + ); + + COMMENT ON TABLE alert IS 'Alert records about various topics (i.e. bad data, system news, etc).'; + COMMENT ON COLUMN alert.alert_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN alert.alert_type_id IS 'The alert_type_id of the alert. Used to categorize/group alerts by type.'; + COMMENT ON COLUMN alert.name IS 'The name of the alert.'; + COMMENT ON COLUMN alert.message IS 'The message of the alert.'; + COMMENT ON COLUMN alert.severity IS 'The severity of the alert, used for MUI styling.'; + COMMENT ON COLUMN alert.data IS 'The data of the alert. Should ideally align with the type of the alert.'; + COMMENT ON COLUMN alert.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN alert.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN alert.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN alert.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN alert.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN alert.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Create audit/journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_alert BEFORE INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_alert AFTER INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_alert_type BEFORE INSERT OR UPDATE OR DELETE ON biohub.alert_type FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_alert_type AFTER INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create constraints/indexes on foreign keys + ---------------------------------------------------------------------------------------- + + ALTER TABLE alert ADD CONSTRAINT alert_fk1 + FOREIGN KEY (alert_type_id) + REFERENCES alert_type(alert_type_id); + + CREATE INDEX alert_idx1 ON alert(alert_type_id); + + ---------------------------------------------------------------------------------------- + -- Insert initial alert type options + ---------------------------------------------------------------------------------------- + + INSERT INTO + alert_type (name, description) + VALUES + ('Summary', 'General alerts for the summary page.'), + ('Manage Telemetry', 'Alerts about telemetry.'), + ('Manage Observations', 'Alerts about observations.'), + ('Manage Animals', 'Alerts about animals.'), + ('Manage Sampling', 'Alerts about sampling information.'), + ('Project', 'Alerts about Projects.'), + ('Survey', 'Alerts about Surveys.'), + ('Standards', 'Alerts about standards.'), + ('Funding', 'Alerts about funding sources.'), + ('Administrator', 'Alerts about administrator functions.'); + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW alert AS SELECT * FROM biohub.alert; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 00b02d99e1..89eef14992 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -51,6 +51,11 @@ export async function seed(knex: Knex): Promise { await knex.raw(`${insertAccessRequest()}`); } + // Insert system alerts + for (let i = 0; i < 8; i++) { + await knex.raw(`${insertSystemAlert()}`); + } + // Check if at least 1 project already exists const checkProjectsResponse = await knex.raw(checkAnyProjectExists()); @@ -770,3 +775,31 @@ const insertAccessRequest = () => ` $$${faker.lorem.sentences(2)}$$ ); `; + +/** + * SQL to insert a fake system alert + * + */ +const insertSystemAlert = () => ` + INSERT INTO alert + ( + alert_type_id, + name, + message, + data, + severity, + record_end_date, + create_user, + update_user + ) + VALUES ( + (SELECT alert_type_id FROM alert_type ORDER BY random() LIMIT 1), + $$${faker.lorem.words(3)}$$, + $$${faker.lorem.sentences(2)}$$, + NULL, + '${faker.helpers.arrayElement(['info', 'success', 'warning', 'error'])}', + (CASE WHEN random() < 0.5 THEN NULL ELSE (CURRENT_DATE - INTERVAL '30 days') END), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1) + ); +`;