Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/dev' into SIMSBIOHUB-648
Browse files Browse the repository at this point in the history
  • Loading branch information
NickPhura committed Jan 20, 2025
2 parents 9bce8ae + 7344aa4 commit 5a10cc9
Show file tree
Hide file tree
Showing 28 changed files with 565 additions and 321 deletions.
38 changes: 38 additions & 0 deletions api/src/database-models/alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod';

/**
* Alert Model.
*
* @description Data model for `Alert`.
*/
export const AlertModel = z.object({
alert_id: z.number(),
alert_type_id: z.number(),
name: z.string(),
message: z.string(),
severity: z.enum(['info', 'success', 'error', 'warning']),
data: z.object({}).nullable(),
record_end_date: z.string().nullable(),
create_date: z.string(),
create_user: z.number(),
update_date: z.string().nullable(),
update_user: z.number().nullable(),
revision_count: z.number()
});

export type AlertModel = z.infer<typeof AlertModel>;

/**
* Alert Record. Intentionally do not omit create_date.
*
* @description Data record for `Alert`.
*/
export const AlertRecord = AlertModel.omit({
create_date: true,
create_user: true,
update_date: true,
update_user: true,
revision_count: true
});

export type AlertRecord = z.infer<typeof AlertRecord>;
20 changes: 7 additions & 13 deletions api/src/models/alert-view.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
import { z } from 'zod';
import { AlertRecord } from '../database-models/alert';

// 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(),
export const AlertRecordWithStatus = AlertRecord.extend({
create_date: z.string(),
status: z.enum(['active', 'expired'])
});
export type AlertRecordWithStatus = z.infer<typeof AlertRecordWithStatus>;

// Infer types from the schema
export type IAlert = z.infer<typeof IAlert>;
export type IAlertCreateObject = Omit<IAlert, 'alert_id' | 'status'>;
export type IAlertUpdateObject = Omit<IAlert, 'status'>;
export type IAlertUpdateObject = AlertRecord;

export type IAlertCreateObject = Omit<AlertRecord, 'alert_id'>;

// Filter object for viewing alerts
export interface IAlertFilterObject {
Expand Down
16 changes: 15 additions & 1 deletion api/src/openapi/schemas/alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const baseSystemAlertSchema: OpenAPIV3.SchemaObject = {
description: 'End date of the alert',
type: 'string',
nullable: true
},
create_date: {
description: 'Timestamp for when the record was created',
type: 'string'
}
}
};
Expand All @@ -60,7 +64,17 @@ export const systemAlertPutSchema: OpenAPIV3.SchemaObject = {
*/
export const systemAlertGetSchema: OpenAPIV3.SchemaObject = {
...baseSystemAlertSchema,
required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity', 'status'],
required: [
'alert_id',
'name',
'message',
'data',
'alert_type_id',
'record_end_date',
'severity',
'status',
'create_date'
],
additionalProperties: false,
properties: {
...systemAlertPutSchema.properties,
Expand Down
32 changes: 28 additions & 4 deletions api/src/paths/alert/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('getAlerts', () => {

describe('as a system user', () => {
it('returns a list of system alerts', async () => {
const mockTotal = 10;
const mockAlerts = [
{
alert_id: 1,
Expand All @@ -29,7 +30,8 @@ describe('getAlerts', () => {
severity: 'error' as IAlertSeverity,
status: 'active' as IAlertStatus,
data: null,
record_end_date: null
record_end_date: null,
create_date: '2020-01-01T10:10:10'
},
{
alert_id: 2,
Expand All @@ -39,13 +41,18 @@ describe('getAlerts', () => {
severity: 'error' as IAlertSeverity,
status: 'active' as IAlertStatus,
data: null,
record_end_date: null
record_end_date: null,
create_date: '2020-01-01T10:10:10'
}
];
const mockFilters = { types: 'Surveys', expiresBefore: '2020-01-01', expiresAfter: undefined };
const mockPaginationParams = { page: '1', limit: '10', sort: undefined, order: undefined };

const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() });
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts);

const getAlertsStub = sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts);
const getAlertsCountStub = sinon.stub(AlertService.prototype, 'getAlertsCount').resolves(mockTotal);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.system_user = {
Expand All @@ -63,13 +70,30 @@ describe('getAlerts', () => {
agency: null
};

mockReq.query = {
...mockFilters,
...mockPaginationParams
};

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;

expect(getAlertsStub).to.have.been.calledOnceWith(mockFilters, {
...mockPaginationParams,
page: Number(mockPaginationParams.page),
limit: Number(mockPaginationParams.limit)
});
expect(getAlertsCountStub).to.have.been.calledOnceWith(mockFilters);

expect(mockRes.jsonValue.pagination).not.to.be.null;
expect(mockRes.jsonValue).to.eql({
alerts: mockAlerts,
pagination: { total: mockTotal, per_page: 10, current_page: 1, last_page: 1, sort: undefined, order: undefined }
});
});

it('handles errors gracefully', async () => {
Expand Down
23 changes: 19 additions & 4 deletions api/src/paths/alert/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ 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 { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination';
import { authorizeRequestHandler } from '../../request-handlers/security/authorization';
import { AlertService } from '../../services/alert-service';
import { getLogger } from '../../utils/logger';
import {
ensureCompletePaginationOptions,
makePaginationOptionsFromRequest,
makePaginationResponse
} from '../../utils/pagination';

const defaultLog = getLogger('paths/alert/index');

Expand Down Expand Up @@ -59,7 +65,8 @@ GET.apiDoc = {
schema: {
type: 'string'
}
}
},
...paginationRequestQueryParamSchema
],
responses: {
200: {
Expand All @@ -72,7 +79,8 @@ GET.apiDoc = {
additionalProperties: false,
required: ['alerts'],
properties: {
alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema }
alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema },
pagination: { ...paginationResponseSchema }
}
}
}
Expand Down Expand Up @@ -112,13 +120,20 @@ export function getAlerts(): RequestHandler {

const filterObject = parseQueryParams(req);

const paginationOptions = makePaginationOptionsFromRequest(req);

const alertService = new AlertService(connection);

const alerts = await alertService.getAlerts(filterObject);
const [alerts, alertsTotalCount] = await Promise.all([
alertService.getAlerts(filterObject, ensureCompletePaginationOptions(paginationOptions)),
alertService.getAlertsCount(filterObject)
]);

await connection.commit();

return res.status(200).json({ alerts: alerts });
return res
.status(200)
.json({ alerts: alerts, pagination: makePaginationResponse(alertsTotalCount, paginationOptions) });
} catch (error) {
defaultLog.error({ label: 'getAlerts', message: 'error', error });
await connection.rollback();
Expand Down
3 changes: 2 additions & 1 deletion api/src/paths/alert/{alertId}/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ describe('getAlerts', () => {
severity: 'error' as IAlertSeverity,
status: 'active' as IAlertStatus,
data: null,
record_end_date: null
record_end_date: null,
create_date: '2020-01-01T10:10:10'
};

const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('getSurveyCritter', () => {

expect(mockRes.status).to.have.been.calledWith(200);
expect(mockRes.json).to.have.been.calledWith({
attachments: mockAttachments,
attachments: { capture_attachments: mockAttachments.captureAttachments },
...mockCritterbaseCritter,
...mockSimsCritter
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ GET.apiDoc = {
type: 'object',
description:
'Attachments associated with the critter. Only included if requested via the expand query parameter.',
required: ['captureAttachments'],
required: ['capture_attachments'],
properties: {
capture_attachments: {
type: 'array',
Expand Down Expand Up @@ -310,7 +310,7 @@ export function getSurveyCritter(): RequestHandler {
? critterAttachmentService.findAllCritterAttachments(surveyCritter.critter_id).then((response) => {
return {
attachments: {
captureAttachments: response.captureAttachments
capture_attachments: response.captureAttachments
// TODO: add mortality attachments
}
};
Expand Down
76 changes: 67 additions & 9 deletions api/src/repositories/alert-repository.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { Knex } from 'knex';
import SQL from 'sql-template-strings';
import { z } from 'zod';
import { getKnex } from '../database/db';
import { ApiExecuteSQLError } from '../errors/api-error';
import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view';
import {
AlertRecordWithStatus,
IAlertCreateObject,
IAlertFilterObject,
IAlertUpdateObject
} from '../models/alert-view';
import { ApiPaginationOptions } from '../zod-schema/pagination';
import { BaseRepository } from './base-repository';

/**
Expand Down Expand Up @@ -31,25 +38,29 @@ export class AlertRepository extends BaseRepository {
'alert.data',
'alert.severity',
'alert.record_end_date',
'alert.create_date',
knex.raw(`
CASE
WHEN alert.record_end_date < NOW() THEN 'expired'
ELSE 'active'
END AS status
`)
)
.from('alert')
.orderBy('alert.create_date', 'DESC');
.from('alert');
}

/**
* Get alert records with optional filters applied
*
* @param {IAlertFilterObject} filterObject
* @return {*} {Promise<IAlert[]>}
* @param {ApiPaginationOptions} pagination
* @return {*} {Promise<AlertRecordWithStatus[]>}
* @memberof AlertRepository
*/
async getAlerts(filterObject: IAlertFilterObject): Promise<IAlert[]> {
async getAlerts(
filterObject: IAlertFilterObject,
pagination?: ApiPaginationOptions
): Promise<AlertRecordWithStatus[]> {
const queryBuilder = this._getAlertBaseQuery();

if (filterObject.expiresAfter) {
Expand All @@ -70,24 +81,71 @@ export class AlertRepository extends BaseRepository {
.whereRaw('lower(at.name) = ANY(?)', [filterObject.types.map((type) => type.toLowerCase())]);
}

const response = await this.connection.knex(queryBuilder, IAlert);
if (pagination) {
queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit);

if (pagination.sort && pagination.order) {
queryBuilder.orderBy(pagination.sort, pagination.order);
} else {
queryBuilder.orderBy('alert_id', 'desc');
}
}

const response = await this.connection.knex(queryBuilder, AlertRecordWithStatus);

return response.rows;
}

/**
* Gets count of alert records with optional filters applied
*
* @param {IAlertFilterObject} filterObject
* @return {*} {Promise<number>}
* @memberof AlertRepository
*/
async getAlertsCount(filterObject: IAlertFilterObject): Promise<number> {
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 knex = getKnex();

const query = knex.from(queryBuilder.as('qb')).select(knex.raw('count(*)::integer as count'));

const response = await this.connection.knex(query, z.object({ count: z.number() }));

return response.rows[0].count;
}

/**
* Get a specific alert by its Id
*
* @param {number} alertId
* @return {*} {Promise<IAlert>}
* @return {*} {Promise< AlertRecordWithStatus>}
* @memberof AlertRepository
*/
async getAlertById(alertId: number): Promise<IAlert> {
async getAlertById(alertId: number): Promise<AlertRecordWithStatus> {
const queryBuilder = this._getAlertBaseQuery();

queryBuilder.where('alert_id', alertId);

const response = await this.connection.knex(queryBuilder, IAlert);
const response = await this.connection.knex(queryBuilder, AlertRecordWithStatus);

return response.rows[0];
}
Expand Down
Loading

0 comments on commit 5a10cc9

Please sign in to comment.