diff --git a/api/src/database-models/README.md b/api/src/database-models/README.md new file mode 100644 index 0000000000..686ccd276d --- /dev/null +++ b/api/src/database-models/README.md @@ -0,0 +1,10 @@ +# Database Models & Records Structure +The files in this directory should only contain the `Data Models` and `Data Records` zod schemas and the equivalent types. + +Note: The file name should be a exact match to what is stored in the database ie: `survey.ts` + +## Data Models +1 to 1 mapping of the database table. + +## Data Records +1 to 1 mapping of the database table, ommitting the audit columns. diff --git a/api/src/database-models/critter_capture_attachment.ts b/api/src/database-models/critter_capture_attachment.ts index 9901c37da6..0e869ef52a 100644 --- a/api/src/database-models/critter_capture_attachment.ts +++ b/api/src/database-models/critter_capture_attachment.ts @@ -1,12 +1,4 @@ import { z } from 'zod'; -/** - * Note: These files should only contain the `Data Models` and `Data Records` with equivalent inferred types. - * - * Data Models contain a 1 to 1 mapping of the database table. - * - * Data Records contain a 1 to 1 mapping of the database table, minus the audit columns. - */ - /** * Critter Capture Attachment Model. * diff --git a/api/src/database-models/critter_mortality_attachment.ts b/api/src/database-models/critter_mortality_attachment.ts index f643cbf272..dac1d3f0f8 100644 --- a/api/src/database-models/critter_mortality_attachment.ts +++ b/api/src/database-models/critter_mortality_attachment.ts @@ -1,12 +1,4 @@ import { z } from 'zod'; -/** - * Note: These files should only contain the `Data Models` and `Data Records` with equivalent inferred types. - * - * Data Models contain a 1 to 1 mapping of the database table. - * - * Data Records contain a 1 to 1 mapping of the database table, minus the audit columns. - */ - /** * Critter Mortality Attachment Model. * diff --git a/api/src/database-models/device.ts b/api/src/database-models/device.ts new file mode 100644 index 0000000000..1c719ac03f --- /dev/null +++ b/api/src/database-models/device.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +/** + * Device Model. + * + * @description Data model for `device`. + */ +export const DeviceModel = z.object({ + device_id: z.number(), + survey_id: z.number(), + device_key: z.string(), + serial: z.string(), + device_make_id: z.number(), + model: z.string().nullable(), + comment: 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 DeviceModel = z.infer; + +/** + * Device Record. + * + * @description Data record for `device`. + */ +export const DeviceRecord = DeviceModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type DeviceRecord = z.infer; diff --git a/api/src/repositories/telemetry-repositories/telemetry-device-repository.interface.ts b/api/src/repositories/telemetry-repositories/telemetry-device-repository.interface.ts new file mode 100644 index 0000000000..9a05d0441d --- /dev/null +++ b/api/src/repositories/telemetry-repositories/telemetry-device-repository.interface.ts @@ -0,0 +1,13 @@ +import { DeviceRecord } from '../../database-models/device'; + +/** + * Interface reflecting the telemetry device data required to create a new device + * + */ +export type CreateTelemetryDevice = Pick; + +/** + * Interface reflecting the telemetry device data required to update an existing device + * + */ +export type UpdateTelemetryDevice = Partial>; diff --git a/api/src/repositories/telemetry-repositories/telemetry-device-repository.test.ts b/api/src/repositories/telemetry-repositories/telemetry-device-repository.test.ts new file mode 100644 index 0000000000..a4b14d6d87 --- /dev/null +++ b/api/src/repositories/telemetry-repositories/telemetry-device-repository.test.ts @@ -0,0 +1,89 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../../__mocks__/db'; +import { TelemetryDeviceRepository } from './telemetry-device-repository'; + +chai.use(sinonChai); + +describe('TelemetryDeviceRepository', () => { + it('should construct', () => { + const mockDBConnection = getMockDBConnection(); + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + expect(telemetryDeviceRepository).to.be.instanceof(TelemetryDeviceRepository); + }); + + describe('getDevicesByIds', () => { + it('should get devices by IDs', async () => { + const mockRows = [{ device_id: 1 }]; + const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves({ rows: mockRows }) }); + + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + const response = await telemetryDeviceRepository.getDevicesByIds(1, [1]); + expect(response).to.eql(mockRows); + }); + }); + + describe('deleteDevicesByIds', () => { + it('should delete devices by IDs', async () => { + const mockRows = [{ device_id: 1 }]; + const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves({ rows: mockRows }) }); + + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + const response = await telemetryDeviceRepository.deleteDevicesByIds(1, [1]); + expect(response).to.eql(mockRows); + }); + }); + + describe('createDevice', () => { + it('should create a new device', async () => { + const mockRows = [{ device_id: 1 }]; + const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves({ rows: mockRows, rowCount: 1 }) }); + + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + const response = await telemetryDeviceRepository.createDevice({ device_id: 1 } as any); + expect(response).to.eql({ device_id: 1 }); + }); + + it('should throw an error if unable to create a new device', async () => { + const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves({ rows: [], rowCount: 0 }) }); + + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + try { + await telemetryDeviceRepository.createDevice({ device_id: 1 } as any); + expect.fail(); + } catch (err: any) { + expect(err.message).to.equal('Device was not created'); + } + }); + }); + + describe('updateDevice', () => { + it('should update an existing device', async () => { + const mockRows = [{ device_id: 1 }]; + const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves({ rows: mockRows, rowCount: 1 }) }); + + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + const response = await telemetryDeviceRepository.updateDevice(1, 2, { comment: 1 } as any); + expect(response).to.eql({ device_id: 1 }); + }); + it('should throw an error if unable to update an existing device', async () => { + const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves({ rows: [], rowCount: 0 }) }); + + const telemetryDeviceRepository = new TelemetryDeviceRepository(mockDBConnection); + + try { + await telemetryDeviceRepository.updateDevice(1, 2, { comment: 1 } as any); + expect.fail(); + } catch (err: any) { + expect(err.message).to.equal('Device was not updated'); + } + }); + }); +}); diff --git a/api/src/repositories/telemetry-repositories/telemetry-device-repository.ts b/api/src/repositories/telemetry-repositories/telemetry-device-repository.ts new file mode 100644 index 0000000000..1c71996ffb --- /dev/null +++ b/api/src/repositories/telemetry-repositories/telemetry-device-repository.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; +import { DeviceRecord } from '../../database-models/device'; +import { getKnex } from '../../database/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { BaseRepository } from '../base-repository'; +import { CreateTelemetryDevice, UpdateTelemetryDevice } from './telemetry-device-repository.interface'; + +/** + * A repository class for accessing telemetry device data. + * + * @export + * @class TelemetryDeviceRepository + * @extends {BaseRepository} + */ +export class TelemetryDeviceRepository extends BaseRepository { + /** + * Get a list of devices by their IDs. + * + * @param {surveyId} surveyId + * @param {number[]} deviceIds + * @returns {*} {Promise} + */ + async getDevicesByIds(surveyId: number, deviceIds: number[]): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .select(['device_id', 'survey_id', 'device_key', 'serial', 'device_make_id', 'model', 'comment']) + .from('device') + .whereIn('device_id', deviceIds) + .andWhere('survey_id', surveyId); + + const response = await this.connection.knex(queryBuilder, DeviceRecord); + + return response.rows; + } + + /** + * Delete a list of devices by their IDs. + * + * @param {surveyId} surveyId + * @param {number[]} deviceIds + * @returns {*} {Promise>} + */ + async deleteDevicesByIds(surveyId: number, deviceIds: number[]): Promise> { + const knex = getKnex(); + + const queryBuilder = knex + .delete() + .from('device') + .whereIn('device_id', deviceIds) + .andWhere({ survey_id: surveyId }) + .returning(['device_id']); + + const response = await this.connection.knex(queryBuilder, z.object({ device_id: z.number() })); + + return response.rows; + } + + /** + * Create a new device record. + * + * @param {CreateTelemetryDevice} device + * @returns {*} {Promise} + */ + async createDevice(device: CreateTelemetryDevice): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .insert(device) + .into('device') + .returning(['device_id', 'survey_id', 'device_key', 'serial', 'device_make_id', 'model', 'comment']); + + const response = await this.connection.knex(queryBuilder, DeviceRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Device was not created', ['TelemetryDeviceRepository -> createDevice']); + } + + return response.rows[0]; + } + + /** + * Update an existing device record. + * + * @param {surveyId} surveyId + * @param {number} deviceId + * @param {UpdateTelemetryDevice} device + * @returns {*} {Promise} + */ + async updateDevice(surveyId: number, deviceId: number, device: UpdateTelemetryDevice): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .update(device) + .from('device') + .where({ device_id: deviceId, survey_id: surveyId }) + .returning(['device_id', 'survey_id', 'device_key', 'serial', 'device_make_id', 'model', 'comment']); + + const response = await this.connection.knex(queryBuilder, DeviceRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Device was not updated', ['TelemetryDeviceRepository -> updateDevice']); + } + + return response.rows[0]; + } +} diff --git a/api/src/services/bctw-service/bctw-device-service.ts b/api/src/services/bctw-service/bctw-device-service.ts index e53e38088a..fe6f0114e5 100644 --- a/api/src/services/bctw-service/bctw-device-service.ts +++ b/api/src/services/bctw-service/bctw-device-service.ts @@ -16,6 +16,7 @@ export type BctwUpdateCollarRequest = { frequency_unit?: number | null; }; +// BCTW-MIGRATION-TODO: DEPRECATED export class BctwDeviceService extends BctwService { /** * Get a list of all supported collar vendors. diff --git a/api/src/services/telemetry-services/telemetry-device-service.test.ts b/api/src/services/telemetry-services/telemetry-device-service.test.ts new file mode 100644 index 0000000000..a026228101 --- /dev/null +++ b/api/src/services/telemetry-services/telemetry-device-service.test.ts @@ -0,0 +1,126 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { TelemetryDeviceRepository } from '../../repositories/telemetry-repositories/telemetry-device-repository'; +import { getMockDBConnection } from '../../__mocks__/db'; +import { TelemetryDeviceService } from './telemetry-device-service'; + +chai.use(sinonChai); + +describe('TelemetryDeviceService', () => { + beforeEach(() => { + sinon.restore(); + }); + + describe('getDevice', () => { + it('should return a device by its ID', async () => { + const mockConnection = getMockDBConnection(); + const service = new TelemetryDeviceService(mockConnection); + + const repoStub = sinon.stub(TelemetryDeviceRepository.prototype, 'getDevicesByIds').resolves([true] as any); + + const device = await service.getDevice(1, 2); + + expect(repoStub).to.have.been.calledOnceWithExactly(1, [2]); + expect(device).to.be.true; + }); + + it('should throw an error if unable to get device', async () => { + const mockConnection = getMockDBConnection(); + const service = new TelemetryDeviceService(mockConnection); + + const repoStub = sinon.stub(TelemetryDeviceRepository.prototype, 'getDevicesByIds').resolves([]); + + try { + await service.getDevice(1, 2); + expect.fail(); + } catch (err: any) { + expect(err.message).to.equal('Device not found'); + } + + expect(repoStub).to.have.been.calledOnceWithExactly(1, [2]); + }); + }); + + describe('deleteDevice', () => { + it('should delete a device by its ID', async () => { + const mockConnection = getMockDBConnection(); + const service = new TelemetryDeviceService(mockConnection); + + const repoStub = sinon + .stub(TelemetryDeviceRepository.prototype, 'deleteDevicesByIds') + .resolves([{ device_id: 2 }] as any); + + const device = await service.deleteDevice(1, 2); + + expect(repoStub).to.have.been.calledOnceWithExactly(1, [2]); + expect(device).to.be.equal(2); + }); + + it('should throw an error if unable to delete device', async () => { + const mockConnection = getMockDBConnection(); + const service = new TelemetryDeviceService(mockConnection); + + const repoStub = sinon.stub(TelemetryDeviceRepository.prototype, 'deleteDevicesByIds').resolves([]); + + try { + await service.deleteDevice(1, 2); + expect.fail(); + } catch (err: any) { + expect(err.message).to.equal('Unable to delete device'); + } + + expect(repoStub).to.have.been.calledOnceWithExactly(1, [2]); + }); + }); + + describe('createDevice', () => { + it('should delete a device by its ID', async () => { + const mockConnection = getMockDBConnection(); + const service = new TelemetryDeviceService(mockConnection); + + const repoStub = sinon.stub(TelemetryDeviceRepository.prototype, 'createDevice').resolves(true as any); + + const device = await service.createDevice({ + device_make_id: 1, + model: null, + survey_id: 1, + serial: 'serial', + comment: 'comment' + }); + + expect(repoStub).to.have.been.calledOnceWithExactly({ + device_make_id: 1, + model: null, + survey_id: 1, + serial: 'serial', + comment: 'comment' + }); + + expect(device).to.be.equal(true); + }); + }); + + describe('updateDevice', () => { + it('should update a device by its ID', async () => { + const mockConnection = getMockDBConnection(); + const service = new TelemetryDeviceService(mockConnection); + + const repoStub = sinon.stub(TelemetryDeviceRepository.prototype, 'updateDevice').resolves(true as any); + + const device = await service.updateDevice(1, 2, { + device_make_id: 1, + serial: 'serial', + comment: 'comment' + }); + + expect(repoStub).to.have.been.calledOnceWithExactly(1, 2, { + device_make_id: 1, + serial: 'serial', + comment: 'comment' + }); + + expect(device).to.be.equal(true); + }); + }); +}); diff --git a/api/src/services/telemetry-services/telemetry-device-service.ts b/api/src/services/telemetry-services/telemetry-device-service.ts new file mode 100644 index 0000000000..2727b573d5 --- /dev/null +++ b/api/src/services/telemetry-services/telemetry-device-service.ts @@ -0,0 +1,91 @@ +import { DeviceRecord } from '../../database-models/device'; +import { IDBConnection } from '../../database/db'; +import { ApiGeneralError } from '../../errors/api-error'; +import { TelemetryDeviceRepository } from '../../repositories/telemetry-repositories/telemetry-device-repository'; +import { + CreateTelemetryDevice, + UpdateTelemetryDevice +} from '../../repositories/telemetry-repositories/telemetry-device-repository.interface'; +import { DBService } from '../db-service'; + +/** + * A service class for working with telemetry devices. + * + * Note: A telemetry `device` is different than a `deployment`. + * A device may have multiple deployments, but a deployment is associated with a single device. + * + * Device: The physical device. + * Deployment: The time period during which a device is attached to an animal. + * + * @export + * @class TelemetryDeviceService + * @extends {DBService} + */ +export class TelemetryDeviceService extends DBService { + telemetryDeviceRepository: TelemetryDeviceRepository; + + constructor(connection: IDBConnection) { + super(connection); + this.telemetryDeviceRepository = new TelemetryDeviceRepository(connection); + } + + /** + * Get a single device by its ID. + * + * @throws {ApiGeneralError} If the device is not found. + * + * @param {number} surveyId + * @param {number} deviceId + * @return {*} {Promise} + */ + async getDevice(surveyId: number, deviceId: number): Promise { + const devices = await this.telemetryDeviceRepository.getDevicesByIds(surveyId, [deviceId]); + + if (devices.length !== 1) { + throw new ApiGeneralError('Device not found', ['TelemetryDeviceService -> getDevice']); + } + + return devices[0]; + } + + /** + * Delete a single device by its ID. + * + * @throws {ApiGeneralError} If unable to delete the device. + * + * @param {number} surveyId + * @param {number} deviceId + * @return {*} {Promise} The device ID that was deleted. + */ + async deleteDevice(surveyId: number, deviceId: number): Promise { + const devices = await this.telemetryDeviceRepository.deleteDevicesByIds(surveyId, [deviceId]); + + if (devices.length !== 1 || devices[0].device_id !== deviceId) { + throw new ApiGeneralError('Unable to delete device', ['TelemetryDeviceService -> deleteDevice']); + } + + return devices[0].device_id; + } + + /** + * Create a new device record. + * + * @param {CreateTelemetryDevice} device + * @returns {*} {Promise} + */ + async createDevice(device: CreateTelemetryDevice): Promise { + return this.telemetryDeviceRepository.createDevice(device); + } + + /** + * Update an existing device record. + * + * @param {number} surveyId + * @param {number} deviceId + * @param {UpdateTelemetryDevice} device + * @returns {*} {Promise} + */ + async updateDevice(surveyId: number, deviceId: number, device: UpdateTelemetryDevice): Promise { + return this.telemetryDeviceRepository.updateDevice(surveyId, deviceId, device); + } +} diff --git a/database/src/migrations/20241008000000_bctw_migration.ts b/database/src/migrations/20241008000000_bctw_migration.ts index b4fa8f6e9c..bff182b6a0 100644 --- a/database/src/migrations/20241008000000_bctw_migration.ts +++ b/database/src/migrations/20241008000000_bctw_migration.ts @@ -24,7 +24,7 @@ export async function up(knex: Knex): Promise { device_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), survey_id integer NOT NULL, device_key varchar NOT NULL, - serial integer NOT NULL, + serial varchar NOT NULL, device_make_id integer NOT NULL, model varchar(100), comment varchar(250), @@ -40,7 +40,7 @@ export async function up(knex: Knex): Promise { COMMENT ON COLUMN device.device_id IS '(Generated) Surrogate primary key identifier.'; COMMENT ON COLUMN device.survey_id IS 'Foreign key to the survey table.'; COMMENT ON COLUMN device.device_key IS '(Generated) The SIMS unique key for the device.'; - COMMENT ON COLUMN device.serial IS 'The serial number of the device.'; + COMMENT ON COLUMN device.serial IS 'The serial identifier of the device.'; COMMENT ON COLUMN device.device_make_id IS 'Foreign key to the device_make table.'; COMMENT ON COLUMN device.model IS 'The device model.'; COMMENT ON COLUMN device.comment IS 'A comment about the device.'; @@ -61,6 +61,9 @@ export async function up(knex: Knex): Promise { FOREIGN KEY (device_make_id) REFERENCES device_make(device_make_id); + -- Add unique constraints + ALTER TABLE device ADD CONSTRAINT device_uk1 UNIQUE (survey_id, serial, device_make_id); + -- Add indexes for foreign keys CREATE INDEX device_idx1 ON device(survey_id);