From 97d876ebaf8efee31ef838ec8f3ff854f9f392ae Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Wed, 17 Sep 2025 11:58:16 -0700 Subject: [PATCH 01/22] creating schema and user --- .../20250917200000_bcgw_schema_user.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 database/src/migrations/20250917200000_bcgw_schema_user.ts diff --git a/database/src/migrations/20250917200000_bcgw_schema_user.ts b/database/src/migrations/20250917200000_bcgw_schema_user.ts new file mode 100644 index 000000000..809b016fb --- /dev/null +++ b/database/src/migrations/20250917200000_bcgw_schema_user.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs'; +import { Knex } from 'knex'; +import path from 'path'; + +const DB_USER_BCGW_PASS = process.env.DB_USER_BCGW_PASS; +const DB_USER_BCGW = process.env.DB_USER_BCGW; + +const DB_RELEASE = 'release.0.8.0'; + +/** + * Apply biohub-platform release changes. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + const create_spatial_extensions = fs.readFileSync(path.join(__dirname, DB_RELEASE, 'create_spatial_extensions.psql')); + + await knex.raw(` + -- set up spatial extensions + ${create_spatial_extensions} + + -- set up bcgw schema + create schema if not exists bcgw; + + -- setup bcgw user + create user ${DB_USER_BCGW} password '${DB_USER_BCGW_PASS}'; + GRANT USAGE ON SCHEMA bcgw TO ${DB_USER_BCGW}; + alter role ${DB_USER_BCGW} set search_path to "$user", bcgw, public; + + -- alter default privileges for the schema owner so that bcgw user is granted access to all future materialized views + ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA bcgw + GRANT SELECT ON MATERIALIZED VIEWS TO ${DB_USER_BCGW}; + `); +} + +/** + * Revert biohub-platform release changes for bcgw schema and user. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function down(knex: Knex): Promise { + await knex.raw(` + -- revert default privileges for the schema owner + ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA bcgw + REVOKE SELECT ON MATERIALIZED VIEWS FROM ${DB_USER_BCGW}; + + -- drop bcgw user and schema + DROP SCHEMA IF EXISTS bcgw CASCADE; + DROP USER IF EXISTS ${DB_USER_BCGW}; + `); +} From 4bb900239005160572c42743f0b992e9c353c166 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Thu, 18 Sep 2025 08:20:12 -0700 Subject: [PATCH 02/22] SIMSBIOHUB-780 --- database/src/migrations/20250917200000_bcgw_schema_user.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/database/src/migrations/20250917200000_bcgw_schema_user.ts b/database/src/migrations/20250917200000_bcgw_schema_user.ts index 809b016fb..f7e0c7b79 100644 --- a/database/src/migrations/20250917200000_bcgw_schema_user.ts +++ b/database/src/migrations/20250917200000_bcgw_schema_user.ts @@ -29,9 +29,9 @@ export async function up(knex: Knex): Promise { GRANT USAGE ON SCHEMA bcgw TO ${DB_USER_BCGW}; alter role ${DB_USER_BCGW} set search_path to "$user", bcgw, public; - -- alter default privileges for the schema owner so that bcgw user is granted access to all future materialized views + -- alter default privileges for the schema owner so that bcgw user is granted access to all future tables, views, and materialized views ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA bcgw - GRANT SELECT ON MATERIALIZED VIEWS TO ${DB_USER_BCGW}; + GRANT SELECT ON TABLES TO ${DB_USER_BCGW}; `); } @@ -46,7 +46,7 @@ export async function down(knex: Knex): Promise { await knex.raw(` -- revert default privileges for the schema owner ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA bcgw - REVOKE SELECT ON MATERIALIZED VIEWS FROM ${DB_USER_BCGW}; + REVOKE SELECT ON TABLES FROM ${DB_USER_BCGW}; -- drop bcgw user and schema DROP SCHEMA IF EXISTS bcgw CASCADE; From 2c76e5c3b28b87d157d534e86acd917af286fef6 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Thu, 18 Sep 2025 14:03:50 -0700 Subject: [PATCH 03/22] make fix --- compose.yml | 2 ++ .../src/migrations/20250917200000_bcgw_schema_user.ts | 9 --------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/compose.yml b/compose.yml index eebfd5b8f..460c349fc 100644 --- a/compose.yml +++ b/compose.yml @@ -204,6 +204,8 @@ services: - DB_USER_API_PASS=${DB_USER_API_PASS} - ENABLE_MOCK_FEATURE_SEEDING=${ENABLE_MOCK_FEATURE_SEEDING} - NUM_MOCK_FEATURE_SUBMISSIONS=${NUM_MOCK_FEATURE_SUBMISSIONS} + - DB_USER_BCGW=${DB_USER_BCGW} + - DB_USER_BCGW_PASS=${DB_USER_BCGW_PASS} volumes: - /opt/app-root/src/node_modules # prevents local node_modules overriding container node_modules networks: diff --git a/database/src/migrations/20250917200000_bcgw_schema_user.ts b/database/src/migrations/20250917200000_bcgw_schema_user.ts index f7e0c7b79..1613804ff 100644 --- a/database/src/migrations/20250917200000_bcgw_schema_user.ts +++ b/database/src/migrations/20250917200000_bcgw_schema_user.ts @@ -1,12 +1,8 @@ -import * as fs from 'fs'; import { Knex } from 'knex'; -import path from 'path'; const DB_USER_BCGW_PASS = process.env.DB_USER_BCGW_PASS; const DB_USER_BCGW = process.env.DB_USER_BCGW; -const DB_RELEASE = 'release.0.8.0'; - /** * Apply biohub-platform release changes. * @@ -15,12 +11,7 @@ const DB_RELEASE = 'release.0.8.0'; * @return {*} {Promise} */ export async function up(knex: Knex): Promise { - const create_spatial_extensions = fs.readFileSync(path.join(__dirname, DB_RELEASE, 'create_spatial_extensions.psql')); - await knex.raw(` - -- set up spatial extensions - ${create_spatial_extensions} - -- set up bcgw schema create schema if not exists bcgw; From 83aac5a7bf23c7ea4835d5c6974764a0b729a43c Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Fri, 19 Dec 2025 09:32:37 -0800 Subject: [PATCH 04/22] amending migration --- database/src/migrations/20250917200000_bcgw_schema_user.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/database/src/migrations/20250917200000_bcgw_schema_user.ts b/database/src/migrations/20250917200000_bcgw_schema_user.ts index 1613804ff..feae3bf33 100644 --- a/database/src/migrations/20250917200000_bcgw_schema_user.ts +++ b/database/src/migrations/20250917200000_bcgw_schema_user.ts @@ -13,12 +13,12 @@ const DB_USER_BCGW = process.env.DB_USER_BCGW; export async function up(knex: Knex): Promise { await knex.raw(` -- set up bcgw schema - create schema if not exists bcgw; + create schema bcgw; -- setup bcgw user - create user ${DB_USER_BCGW} password '${DB_USER_BCGW_PASS}'; + create role ${DB_USER_BCGW} login password '${DB_USER_BCGW_PASS}'; GRANT USAGE ON SCHEMA bcgw TO ${DB_USER_BCGW}; - alter role ${DB_USER_BCGW} set search_path to "$user", bcgw, public; + alter role ${DB_USER_BCGW} set search_path to bcgw; -- alter default privileges for the schema owner so that bcgw user is granted access to all future tables, views, and materialized views ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA bcgw From 567dae94764e2f1b5c5769795a373f2bff804c87 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Fri, 19 Dec 2025 13:37:21 -0800 Subject: [PATCH 05/22] initial view bones --- .../20250917200000_bcgw_schema_user.ts | 4 +- .../20251219200000_telemetry_all_view.ts | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 database/src/migrations/20251219200000_telemetry_all_view.ts diff --git a/database/src/migrations/20250917200000_bcgw_schema_user.ts b/database/src/migrations/20250917200000_bcgw_schema_user.ts index feae3bf33..e322cdb58 100644 --- a/database/src/migrations/20250917200000_bcgw_schema_user.ts +++ b/database/src/migrations/20250917200000_bcgw_schema_user.ts @@ -4,7 +4,7 @@ const DB_USER_BCGW_PASS = process.env.DB_USER_BCGW_PASS; const DB_USER_BCGW = process.env.DB_USER_BCGW; /** - * Apply biohub-platform release changes. + * Create bcgw schema and user. * * @export * @param {Knex} knex @@ -27,7 +27,7 @@ export async function up(knex: Knex): Promise { } /** - * Revert biohub-platform release changes for bcgw schema and user. + * Revert changes for bcgw schema and user. * * @export * @param {Knex} knex diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts new file mode 100644 index 000000000..2e238b2be --- /dev/null +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -0,0 +1,43 @@ +import { Knex } from 'knex'; + +/** + * Creating a materialised view for telemetry_all. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + CREATE MATERIALIZED VIEW bcgw.telemetry_all AS + SELECT + sf.submission_feature_id, + sf.uuid, + sf.submission_id, + sf.source_id, + sf.data->>'device_id' as device_id, + (sf.data->>'latitude')::numeric as latitude, + (sf.data->>'longitude')::numeric as longitude, + (sf.data->>'timestamp')::timestamptz as timestamp, + td.data->>'device_key' as device_key + FROM biohub.submission_feature sf + JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id + LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') + AND td.data->>'device_key' = sf.data->>'device_id' + WHERE ft.name = 'telemetry' + AND sf.record_end_date IS NULL; + `); +} + +/** + * Revert materialized view. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function down(knex: Knex): Promise { + await knex.raw(` + DROP MATERIALIZED VIEW IF EXISTS bcgw.telemetry_all; + `); +} From 9e98fd8644dd061ff2c159b2e313c1d67a530590 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Tue, 30 Dec 2025 11:55:58 -0800 Subject: [PATCH 06/22] public telemetry schema initial build --- .../20251230000000_telemetry_public.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 database/src/migrations/20251230000000_telemetry_public.ts diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts new file mode 100644 index 000000000..75896476a --- /dev/null +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -0,0 +1,44 @@ +import { Knex } from 'knex'; + +/** + * Creating a materialised view for telemetry_all. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + CREATE MATERIALIZED VIEW bcgw.telemetry_public AS + SELECT + sf.submission_feature_id, + sf.uuid, + sf.submission_id, + sf.source_id, + sf.data->>'device_id' as device_id, + (sf.data->>'latitude')::numeric as latitude, + (sf.data->>'longitude')::numeric as longitude, + (sf.data->>'timestamp')::timestamptz as timestamp, + td.data->>'device_key' as device_key + FROM biohub.submission_feature sf + JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id + LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') + AND td.data->>'device_key' = sf.data->>'device_id' + WHERE ft.name = 'telemetry' + AND sf.record_end_date IS NULL + AND sf.submission_feature_id NOT IN (SELECT submission_feature_id FROM biohub.submission_feature_security); + `); +} + +/** + * Revert materialized view. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function down(knex: Knex): Promise { + await knex.raw(` + DROP MATERIALIZED VIEW IF EXISTS bcgw.telemetry_public; + `); +} From 1848aada969ac28f9ebe3def51a7eb367df2330e Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Tue, 30 Dec 2025 16:01:31 -0800 Subject: [PATCH 07/22] modifying joins --- database/src/migrations/20251219200000_telemetry_all_view.ts | 4 ++-- database/src/migrations/20251230000000_telemetry_public.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index 2e238b2be..a669feb03 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -22,8 +22,8 @@ export async function up(knex: Knex): Promise { td.data->>'device_key' as device_key FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id - LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') - AND td.data->>'device_key' = sf.data->>'device_id' + LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_device') + AND td.data->>'device_id' = sf.data->>'device_id' WHERE ft.name = 'telemetry' AND sf.record_end_date IS NULL; `); diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts index 75896476a..02dde6303 100644 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -22,8 +22,8 @@ export async function up(knex: Knex): Promise { td.data->>'device_key' as device_key FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id - LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') - AND td.data->>'device_key' = sf.data->>'device_id' + LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_device') + AND td.data->>'device_id' = sf.data->>'device_id' WHERE ft.name = 'telemetry' AND sf.record_end_date IS NULL AND sf.submission_feature_id NOT IN (SELECT submission_feature_id FROM biohub.submission_feature_security); From c4c71a3844676c1b5b8bd39d904f762828138fb0 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Mon, 5 Jan 2026 14:40:12 -0800 Subject: [PATCH 08/22] adding device and deployments to seeds --- database/src/seeds/04_mock_test_data.ts | 108 ++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index 7371de660..6b696c630 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -68,6 +68,26 @@ const insertRecord = async (knex: Knex) => { // Dataset const parent_submission_feature_id1 = await insertDatasetRecord(knex, { submission_id }); + // Telemetry Deployments + const deploymentIds: number[] = []; + const deviceInfos: { submission_feature_id: number; device_id: string }[] = []; + for (let i = 0; i < 5; i++) { + const deploymentId = await insertTelemetryDeployment(knex, { + submission_id, + parent_submission_feature_id: parent_submission_feature_id1 + }); + deploymentIds.push(deploymentId); + + // Devices under deployment + for (let j = 0; j < 2; j++) { + const deviceInfo = await insertTelemetryDevice(knex, { + submission_id, + parent_submission_feature_id: deploymentId + }); + deviceInfos.push(deviceInfo); + } + } + // Sample Sites and their children const sampleSitePromises = Array.from({ length: 10 }).map(async () => { const parent_submission_feature_id2 = await insertSampleSiteRecord(knex, { @@ -90,9 +110,17 @@ const insertRecord = async (knex: Knex) => { }); // Telemetry - const telemetryPromises = Array.from({ length: 100 }).map(() => - insertTelemetryRecord(knex, { submission_id, parent_submission_feature_id: parent_submission_feature_id1 }) - ); + const possibleParents = [...deploymentIds, ...deviceInfos.map((d) => d.submission_feature_id)]; + const telemetryPromises = Array.from({ length: 100 }).map(() => { + const randomParent = possibleParents[Math.floor(Math.random() * possibleParents.length)]; + const isDevice = deviceInfos.some((d) => d.submission_feature_id === randomParent); + const deviceInfo = isDevice ? deviceInfos.find((d) => d.submission_feature_id === randomParent) : undefined; + return insertTelemetryRecord(knex, { + submission_id, + parent_submission_feature_id: randomParent, + device_id: deviceInfo?.device_id + }); + }); // Wait for all sample sites and telemetry to complete concurrently await Promise.all([...sampleSitePromises, ...telemetryPromises]); @@ -299,7 +327,15 @@ export const insertSubmission = (includeSecurityReviewTimestamp: boolean, includ export const insertSubmissionFeature = (options: { submission_id: number; parent_submission_feature_id: number | null; - feature_type: 'dataset' | 'sample_site' | 'species_observation' | 'animal' | 'artifact' | 'telemetry'; + feature_type: + | 'dataset' + | 'sample_site' + | 'species_observation' + | 'animal' + | 'artifact' + | 'telemetry' + | 'telemetry_deployment' + | 'telemetry_device'; data: { [key: string]: any }; }) => ` INSERT INTO submission_feature @@ -450,12 +486,72 @@ const randomIntFromInterval = (min: number, max: number) => { return Math.floor(Math.random() * (max - min + 1) + min); }; -export const insertTelemetryRecord = async ( +export const insertTelemetryDeployment = async ( knex: Knex, options: { submission_id: number; parent_submission_feature_id: number } ): Promise => { + const deploymentData = { + animal_identifier: faker.string.alphanumeric({ length: 10 }), + device_key: faker.string.alphanumeric({ length: 8 }), + start_date: faker.date.past().toISOString(), + end_date: faker.date.future().toISOString() + }; + + const response = await knex.raw( + `${insertSubmissionFeature({ + submission_id: options.submission_id, + parent_submission_feature_id: options.parent_submission_feature_id, + feature_type: 'telemetry_deployment', + data: deploymentData + })}` + ); + const submission_feature_id = response.rows[0].submission_feature_id; + + await knex.raw(`${insertSearchString({ submission_feature_id })}`); + await knex.raw(`${insertSearchString({ submission_feature_id })}`); + + await knex.raw(`${insertSearchStartDatetime({ submission_feature_id })}`); + await knex.raw(`${insertSearchEndDatetime({ submission_feature_id })}`); + + return submission_feature_id; +}; + +export const insertTelemetryDevice = async ( + knex: Knex, + options: { submission_id: number; parent_submission_feature_id: number; device_id?: string } +): Promise<{ submission_feature_id: number; device_id: string }> => { + const device_id = options.device_id || faker.string.alphanumeric({ length: 8 }); + const deviceData = { + device_id, + device_manufacturer: faker.company.name(), + device_model: faker.commerce.productName(), + description: faker.lorem.sentence(), + serial_number: faker.string.alphanumeric({ length: 12 }) + }; + + const response = await knex.raw( + `${insertSubmissionFeature({ + submission_id: options.submission_id, + parent_submission_feature_id: options.parent_submission_feature_id, + feature_type: 'telemetry_device', + data: deviceData + })}` + ); + const submission_feature_id = response.rows[0].submission_feature_id; + + await knex.raw(`${insertSearchString({ submission_feature_id })}`); + await knex.raw(`${insertSearchString({ submission_feature_id })}`); + + return { submission_feature_id, device_id }; +}; + +export const insertTelemetryRecord = async ( + knex: Knex, + options: { submission_id: number; parent_submission_feature_id: number; device_id?: string } +): Promise => { + const device_id = options.device_id || faker.string.alphanumeric({ length: 8 }); const telemetryData = { - device_id: faker.string.alphanumeric({ length: 8 }), + device_id, latitude: faker.number.float({ min: 48.617424, max: 60.664785, multipleOf: 0.000001 }), longitude: faker.number.float({ min: -135.878906, max: -114.433594, multipleOf: 0.000001 }), timestamp: faker.date.recent().toISOString(), From 3eaa0f4ef7026df77367168f1fded4cb759dd1a5 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Mon, 12 Jan 2026 16:10:46 -0800 Subject: [PATCH 09/22] updating seeds --- .../src/migrations/20251219200000_telemetry_all_view.ts | 7 ++++--- database/src/seeds/04_mock_test_data.ts | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index a669feb03..87663039f 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -19,11 +19,12 @@ export async function up(knex: Knex): Promise { (sf.data->>'latitude')::numeric as latitude, (sf.data->>'longitude')::numeric as longitude, (sf.data->>'timestamp')::timestamptz as timestamp, - td.data->>'device_key' as device_key + dep.data->>'device_key' as device_key, + dep.data->>'animal_id' as animal_id FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id - LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_device') - AND td.data->>'device_id' = sf.data->>'device_id' + LEFT JOIN biohub.submission_feature dep ON dep.submission_feature_id = sf.parent_submission_feature_id + AND dep.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') WHERE ft.name = 'telemetry' AND sf.record_end_date IS NULL; `); diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index 6b696c630..15c0cadf4 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -557,6 +557,7 @@ export const insertTelemetryRecord = async ( timestamp: faker.date.recent().toISOString(), temperature: faker.number.float({ min: -20, max: 50, multipleOf: 0.1 }), humidity: faker.number.float({ min: 0, max: 100, multipleOf: 0.1 }), + dop: faker.number.float({ min: 1, max: 20, multipleOf: 0.1 }), status: faker.helpers.arrayElement(['active', 'idle', 'error']) }; @@ -575,6 +576,7 @@ export const insertTelemetryRecord = async ( await knex.raw(`${insertSearchString({ submission_feature_id })}`); // e.g., status await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); // e.g., temperature await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); // e.g., humidity + await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); // e.g., dop // Spatial search index await knex.raw( From 75f4a842e2380b0db65f29e730904be92c463eef Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Mon, 12 Jan 2026 16:48:38 -0800 Subject: [PATCH 10/22] updating views --- .../migrations/20251219200000_telemetry_all_view.ts | 4 +++- .../src/migrations/20251230000000_telemetry_public.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index 87663039f..2f57a26cd 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -19,6 +19,7 @@ export async function up(knex: Knex): Promise { (sf.data->>'latitude')::numeric as latitude, (sf.data->>'longitude')::numeric as longitude, (sf.data->>'timestamp')::timestamptz as timestamp, + (sf.data->>'dop')::numeric as dop, dep.data->>'device_key' as device_key, dep.data->>'animal_id' as animal_id FROM biohub.submission_feature sf @@ -26,7 +27,8 @@ export async function up(knex: Knex): Promise { LEFT JOIN biohub.submission_feature dep ON dep.submission_feature_id = sf.parent_submission_feature_id AND dep.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') WHERE ft.name = 'telemetry' - AND sf.record_end_date IS NULL; + AND sf.record_end_date IS NULL + AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); `); } diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts index 02dde6303..eaac48b8a 100644 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -19,14 +19,17 @@ export async function up(knex: Knex): Promise { (sf.data->>'latitude')::numeric as latitude, (sf.data->>'longitude')::numeric as longitude, (sf.data->>'timestamp')::timestamptz as timestamp, - td.data->>'device_key' as device_key + (sf.data->>'dop')::numeric as dop, + td.data->>'device_key' as device_key, + dep.data->>'animal_id' as animal_id FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id - LEFT JOIN biohub.submission_feature td ON td.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_device') - AND td.data->>'device_id' = sf.data->>'device_id' + LEFT JOIN biohub.submission_feature dep ON dep.submission_feature_id = sf.parent_submission_feature_id + AND dep.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') WHERE ft.name = 'telemetry' AND sf.record_end_date IS NULL - AND sf.submission_feature_id NOT IN (SELECT submission_feature_id FROM biohub.submission_feature_security); + AND sf.submission_feature_id NOT IN (SELECT submission_feature_id FROM biohub.submission_feature_security) + AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); `); } From b7552066b526319f9758f935983f11f0ac2f339d Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Wed, 14 Jan 2026 10:10:12 -0800 Subject: [PATCH 11/22] fix syntax --- database/src/migrations/20251230000000_telemetry_public.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts index eaac48b8a..3595d76a6 100644 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -20,7 +20,7 @@ export async function up(knex: Knex): Promise { (sf.data->>'longitude')::numeric as longitude, (sf.data->>'timestamp')::timestamptz as timestamp, (sf.data->>'dop')::numeric as dop, - td.data->>'device_key' as device_key, + dep.data->>'device_key' as device_key, dep.data->>'animal_id' as animal_id FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id From c8b2b7748100815cc96d82dfe5c5b3bf04a5edbf Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Wed, 14 Jan 2026 10:15:43 -0800 Subject: [PATCH 12/22] updating seeding --- database/src/seeds/04_mock_test_data.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index 15c0cadf4..b1561ccf7 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -96,7 +96,7 @@ const insertRecord = async (knex: Knex) => { }); // Animals - const animalPromises = Array.from({ length: 2 }).map(() => + const animalPromises = Array.from({ length: 5 }).map(() => insertAnimalRecord(knex, { submission_id, parent_submission_feature_id: parent_submission_feature_id2 }) ); @@ -554,7 +554,7 @@ export const insertTelemetryRecord = async ( device_id, latitude: faker.number.float({ min: 48.617424, max: 60.664785, multipleOf: 0.000001 }), longitude: faker.number.float({ min: -135.878906, max: -114.433594, multipleOf: 0.000001 }), - timestamp: faker.date.recent().toISOString(), + timestamp: faker.date.between({ from: '2020-01-01T00:00:00.000Z', to: new Date().toISOString() }).toISOString(), temperature: faker.number.float({ min: -20, max: 50, multipleOf: 0.1 }), humidity: faker.number.float({ min: 0, max: 100, multipleOf: 0.1 }), dop: faker.number.float({ min: 1, max: 20, multipleOf: 0.1 }), From d3a96ccf781e77f1d040bced32f900671bc0f592 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Thu, 15 Jan 2026 16:13:11 -0800 Subject: [PATCH 13/22] updating views to use ctes --- .../20251219200000_telemetry_all_view.ts | 50 ++++++++++------- .../20251230000000_telemetry_public.ts | 55 ++++++++++++------- 2 files changed, 66 insertions(+), 39 deletions(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index 2f57a26cd..c0de93dcd 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -9,26 +9,38 @@ import { Knex } from 'knex'; */ export async function up(knex: Knex): Promise { await knex.raw(` - CREATE MATERIALIZED VIEW bcgw.telemetry_all AS +CREATE MATERIALIZED VIEW bcgw.telemetry_all AS +WITH deployments AS ( SELECT - sf.submission_feature_id, - sf.uuid, - sf.submission_id, - sf.source_id, - sf.data->>'device_id' as device_id, - (sf.data->>'latitude')::numeric as latitude, - (sf.data->>'longitude')::numeric as longitude, - (sf.data->>'timestamp')::timestamptz as timestamp, - (sf.data->>'dop')::numeric as dop, - dep.data->>'device_key' as device_key, - dep.data->>'animal_id' as animal_id - FROM biohub.submission_feature sf - JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id - LEFT JOIN biohub.submission_feature dep ON dep.submission_feature_id = sf.parent_submission_feature_id - AND dep.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') - WHERE ft.name = 'telemetry' - AND sf.record_end_date IS NULL - AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); + dep.submission_feature_id, + dep.data->>'device_key' AS device_key, + dep.data->>'animal_id' AS animal_id + FROM biohub.submission_feature dep + JOIN biohub.feature_type ft_dep + ON dep.feature_type_id = ft_dep.feature_type_id + WHERE ft_dep.name = 'telemetry_deployment' + AND dep.record_end_date IS NULL +) +SELECT + sf.submission_feature_id, + sf.uuid, + sf.submission_id, + sf.source_id, + sf.data->>'device_id' AS device_id, + (sf.data->>'latitude')::numeric AS latitude, + (sf.data->>'longitude')::numeric AS longitude, + (sf.data->>'timestamp')::timestamptz AS timestamp, + (sf.data->>'dop')::numeric AS dop, + d.device_key, + d.animal_id +FROM biohub.submission_feature sf +JOIN biohub.feature_type ft + ON sf.feature_type_id = ft.feature_type_id +LEFT JOIN deployments d + ON d.submission_feature_id = sf.parent_submission_feature_id +WHERE ft.name = 'telemetry' + AND sf.record_end_date IS NULL + AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); `); } diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts index 3595d76a6..e30df40b6 100644 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -9,27 +9,42 @@ import { Knex } from 'knex'; */ export async function up(knex: Knex): Promise { await knex.raw(` - CREATE MATERIALIZED VIEW bcgw.telemetry_public AS +CREATE MATERIALIZED VIEW bcgw.telemetry_public AS +WITH deployments AS ( SELECT - sf.submission_feature_id, - sf.uuid, - sf.submission_id, - sf.source_id, - sf.data->>'device_id' as device_id, - (sf.data->>'latitude')::numeric as latitude, - (sf.data->>'longitude')::numeric as longitude, - (sf.data->>'timestamp')::timestamptz as timestamp, - (sf.data->>'dop')::numeric as dop, - dep.data->>'device_key' as device_key, - dep.data->>'animal_id' as animal_id - FROM biohub.submission_feature sf - JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id - LEFT JOIN biohub.submission_feature dep ON dep.submission_feature_id = sf.parent_submission_feature_id - AND dep.feature_type_id = (SELECT feature_type_id FROM biohub.feature_type WHERE name = 'telemetry_deployment') - WHERE ft.name = 'telemetry' - AND sf.record_end_date IS NULL - AND sf.submission_feature_id NOT IN (SELECT submission_feature_id FROM biohub.submission_feature_security) - AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); + dep.submission_feature_id, + dep.data->>'device_key' AS device_key, + dep.data->>'animal_id' AS animal_id + FROM biohub.submission_feature dep + JOIN biohub.feature_type ft_dep + ON dep.feature_type_id = ft_dep.feature_type_id + WHERE ft_dep.name = 'telemetry_deployment' + AND dep.record_end_date IS NULL +) +SELECT + sf.submission_feature_id, + sf.uuid, + sf.submission_id, + sf.source_id, + sf.data->>'device_id' AS device_id, + (sf.data->>'latitude')::numeric AS latitude, + (sf.data->>'longitude')::numeric AS longitude, + (sf.data->>'timestamp')::timestamptz AS timestamp, + (sf.data->>'dop')::numeric AS dop, + d.device_key, + d.animal_id +FROM biohub.submission_feature sf +JOIN biohub.feature_type ft + ON sf.feature_type_id = ft.feature_type_id +LEFT JOIN deployments d + ON d.submission_feature_id = sf.parent_submission_feature_id +WHERE ft.name = 'telemetry' + AND sf.record_end_date IS NULL + AND sf.submission_feature_id NOT IN ( + SELECT submission_feature_id + FROM biohub.submission_feature_security + ) + AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); `); } From 34c199fae1ad8230ea3ef28460563ad7a336acf1 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Thu, 15 Jan 2026 16:27:00 -0800 Subject: [PATCH 14/22] animal syntax --- database/src/migrations/20251219200000_telemetry_all_view.ts | 2 +- database/src/migrations/20251230000000_telemetry_public.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index c0de93dcd..289c8f9d2 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -14,7 +14,7 @@ WITH deployments AS ( SELECT dep.submission_feature_id, dep.data->>'device_key' AS device_key, - dep.data->>'animal_id' AS animal_id + dep.data->>'animal_identifier' AS animal_id FROM biohub.submission_feature dep JOIN biohub.feature_type ft_dep ON dep.feature_type_id = ft_dep.feature_type_id diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts index e30df40b6..c2026fa1b 100644 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -14,7 +14,7 @@ WITH deployments AS ( SELECT dep.submission_feature_id, dep.data->>'device_key' AS device_key, - dep.data->>'animal_id' AS animal_id + dep.data->>'animal_identifier' AS animal_id FROM biohub.submission_feature dep JOIN biohub.feature_type ft_dep ON dep.feature_type_id = ft_dep.feature_type_id From b5993adcf7878fc1245be6fb9099b32c649b7965 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Tue, 20 Jan 2026 08:05:06 -0800 Subject: [PATCH 15/22] commenting on all view --- .../20251219200000_telemetry_all_view.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index 289c8f9d2..f8e22422e 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -22,17 +22,16 @@ WITH deployments AS ( AND dep.record_end_date IS NULL ) SELECT - sf.submission_feature_id, - sf.uuid, - sf.submission_id, - sf.source_id, - sf.data->>'device_id' AS device_id, - (sf.data->>'latitude')::numeric AS latitude, - (sf.data->>'longitude')::numeric AS longitude, - (sf.data->>'timestamp')::timestamptz AS timestamp, - (sf.data->>'dop')::numeric AS dop, + sf.submission_feature_id AS Feature_ID, + d.animal_id, + -- Contingent on Feature Array: Add columns Species Code, Species english name, species scientific name, Sex, Ecological Unit d.device_key, - d.animal_id + (sf.data->>'timestamp')::timestamptz AS DATETIME, + (EXTRACT(YEAR FROM (sf.data->>'timestamp')::timestamptz))::int AS YEAR, + (sf.data->>'latitude')::numeric AS Latitude, + (sf.data->>'longitude')::numeric AS Longitude, + (sf.data->>'dop')::numeric AS dop + -- contingent on feature array: join to dataset and get the survey name and id, and the study area id FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id @@ -42,6 +41,17 @@ WHERE ft.name = 'telemetry' AND sf.record_end_date IS NULL AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); `); + + await knex.raw(` + COMMENT ON COLUMN bcgw.telemetry_all.Feature_ID IS 'System generated surrogate primary key identifier'; + COMMENT ON COLUMN bcgw.telemetry_all.Latitude IS 'The latitude of the GPS location'; + COMMENT ON COLUMN bcgw.telemetry_all.Longitude IS 'The longitude of the GPS location'; + COMMENT ON COLUMN bcgw.telemetry_all.DATETIME IS 'The date and time that the GPS location was recorded'; + COMMENT ON COLUMN bcgw.telemetry_all.YEAR IS 'The year that the GPS location was recorded'; + COMMENT ON COLUMN bcgw.telemetry_all.dop IS 'The dilution of precision'; + COMMENT ON COLUMN bcgw.telemetry_all.device_key IS 'The vendor and device serial'; + COMMENT ON COLUMN bcgw.telemetry_all.animal_id IS 'The identifier of the animal wearing the telemetry device'; + `); } /** From 09bf4bdd66a5980b4485cfb12c49a61620214f81 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Tue, 20 Jan 2026 08:09:10 -0800 Subject: [PATCH 16/22] security comment --- .../migrations/20251219200000_telemetry_all_view.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251219200000_telemetry_all_view.ts index f8e22422e..f4fb1302b 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251219200000_telemetry_all_view.ts @@ -30,7 +30,14 @@ SELECT (EXTRACT(YEAR FROM (sf.data->>'timestamp')::timestamptz))::int AS YEAR, (sf.data->>'latitude')::numeric AS Latitude, (sf.data->>'longitude')::numeric AS Longitude, - (sf.data->>'dop')::numeric AS dop + (sf.data->>'dop')::numeric AS dop, + CASE + WHEN sf.submission_feature_id IN ( + SELECT submission_feature_id + FROM biohub.submission_feature_security + ) THEN 'Secured' + ELSE 'Open' + END AS SECURITY -- contingent on feature array: join to dataset and get the survey name and id, and the study area id FROM biohub.submission_feature sf JOIN biohub.feature_type ft @@ -51,6 +58,7 @@ WHERE ft.name = 'telemetry' COMMENT ON COLUMN bcgw.telemetry_all.dop IS 'The dilution of precision'; COMMENT ON COLUMN bcgw.telemetry_all.device_key IS 'The vendor and device serial'; COMMENT ON COLUMN bcgw.telemetry_all.animal_id IS 'The identifier of the animal wearing the telemetry device'; + COMMENT ON COLUMN bcgw.telemetry_all.SECURITY IS 'The security status of the feature'; `); } From 9afc7f82b5172a9b45c62205cba1c160f9d56f97 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Tue, 20 Jan 2026 08:13:22 -0800 Subject: [PATCH 17/22] updating public view --- .../20251230000000_telemetry_public.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts index c2026fa1b..751b8469e 100644 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ b/database/src/migrations/20251230000000_telemetry_public.ts @@ -22,17 +22,16 @@ WITH deployments AS ( AND dep.record_end_date IS NULL ) SELECT - sf.submission_feature_id, - sf.uuid, - sf.submission_id, - sf.source_id, - sf.data->>'device_id' AS device_id, - (sf.data->>'latitude')::numeric AS latitude, - (sf.data->>'longitude')::numeric AS longitude, - (sf.data->>'timestamp')::timestamptz AS timestamp, - (sf.data->>'dop')::numeric AS dop, + sf.submission_feature_id AS Feature_ID, + d.animal_id, + -- Contingent on Feature Array: Add columns Species Code, Species english name, species scientific name, Sex, Ecological Unit d.device_key, - d.animal_id + (sf.data->>'timestamp')::timestamptz AS DATETIME, + (EXTRACT(YEAR FROM (sf.data->>'timestamp')::timestamptz))::int AS YEAR, + (sf.data->>'latitude')::numeric AS Latitude, + (sf.data->>'longitude')::numeric AS Longitude, + (sf.data->>'dop')::numeric AS dop + -- contingent on feature array: join to dataset and get the survey name and id, and the study area id FROM biohub.submission_feature sf JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id @@ -46,6 +45,17 @@ WHERE ft.name = 'telemetry' ) AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); `); + + await knex.raw(` + COMMENT ON COLUMN bcgw.telemetry_public.Feature_ID IS 'System generated surrogate primary key identifier'; + COMMENT ON COLUMN bcgw.telemetry_public.Latitude IS 'The latitude of the GPS location'; + COMMENT ON COLUMN bcgw.telemetry_public.Longitude IS 'The longitude of the GPS location'; + COMMENT ON COLUMN bcgw.telemetry_public.DATETIME IS 'The date and time that the GPS location was recorded'; + COMMENT ON COLUMN bcgw.telemetry_public.YEAR IS 'The year that the GPS location was recorded'; + COMMENT ON COLUMN bcgw.telemetry_public.dop IS 'The dilution of precision'; + COMMENT ON COLUMN bcgw.telemetry_public.device_key IS 'The vendor and device serial'; + COMMENT ON COLUMN bcgw.telemetry_public.animal_id IS 'The identifier of the animal wearing the telemetry device'; + `); } /** From 6a189d50924e717ad954db080281edbd211d5716 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Wed, 18 Feb 2026 15:26:50 -0800 Subject: [PATCH 18/22] combining views --- ...20251230000000_bcgw_materialised_views.ts} | 54 +++++++++++++- .../20251230000000_telemetry_public.ts | 72 ------------------- 2 files changed, 53 insertions(+), 73 deletions(-) rename database/src/migrations/{20251219200000_telemetry_all_view.ts => 20251230000000_bcgw_materialised_views.ts} (53%) delete mode 100644 database/src/migrations/20251230000000_telemetry_public.ts diff --git a/database/src/migrations/20251219200000_telemetry_all_view.ts b/database/src/migrations/20251230000000_bcgw_materialised_views.ts similarity index 53% rename from database/src/migrations/20251219200000_telemetry_all_view.ts rename to database/src/migrations/20251230000000_bcgw_materialised_views.ts index f4fb1302b..1ddedcd5e 100644 --- a/database/src/migrations/20251219200000_telemetry_all_view.ts +++ b/database/src/migrations/20251230000000_bcgw_materialised_views.ts @@ -1,7 +1,7 @@ import { Knex } from 'knex'; /** - * Creating a materialised view for telemetry_all. + * Creating materialised views for telemetry and observations datasets to be replicated in the BC Geographic Warehouse. * * @export * @param {Knex} knex @@ -60,6 +60,55 @@ WHERE ft.name = 'telemetry' COMMENT ON COLUMN bcgw.telemetry_all.animal_id IS 'The identifier of the animal wearing the telemetry device'; COMMENT ON COLUMN bcgw.telemetry_all.SECURITY IS 'The security status of the feature'; `); + + await knex.raw(` +CREATE MATERIALIZED VIEW bcgw.telemetry_public AS +WITH deployments AS ( + SELECT + dep.submission_feature_id, + dep.data->>'device_key' AS device_key, + dep.data->>'animal_identifier' AS animal_id + FROM biohub.submission_feature dep + JOIN biohub.feature_type ft_dep + ON dep.feature_type_id = ft_dep.feature_type_id + WHERE ft_dep.name = 'telemetry_deployment' + AND dep.record_end_date IS NULL +) +SELECT + sf.submission_feature_id AS Feature_ID, + d.animal_id, + -- Contingent on Feature Array: Add columns Species Code, Species english name, species scientific name, Sex, Ecological Unit + d.device_key, + (sf.data->>'timestamp')::timestamptz AS DATETIME, + (EXTRACT(YEAR FROM (sf.data->>'timestamp')::timestamptz))::int AS YEAR, + (sf.data->>'latitude')::numeric AS Latitude, + (sf.data->>'longitude')::numeric AS Longitude, + (sf.data->>'dop')::numeric AS dop + -- contingent on feature array: join to dataset and get the survey name and id, and the study area id +FROM biohub.submission_feature sf +JOIN biohub.feature_type ft + ON sf.feature_type_id = ft.feature_type_id +LEFT JOIN deployments d + ON d.submission_feature_id = sf.parent_submission_feature_id +WHERE ft.name = 'telemetry' + AND sf.record_end_date IS NULL + AND sf.submission_feature_id NOT IN ( + SELECT submission_feature_id + FROM biohub.submission_feature_security + ) + AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); + `); + + await knex.raw(` + COMMENT ON COLUMN bcgw.telemetry_public.Feature_ID IS 'System generated surrogate primary key identifier'; + COMMENT ON COLUMN bcgw.telemetry_public.Latitude IS 'The latitude of the GPS location'; + COMMENT ON COLUMN bcgw.telemetry_public.Longitude IS 'The longitude of the GPS location'; + COMMENT ON COLUMN bcgw.telemetry_public.DATETIME IS 'The date and time that the GPS location was recorded'; + COMMENT ON COLUMN bcgw.telemetry_public.YEAR IS 'The year that the GPS location was recorded'; + COMMENT ON COLUMN bcgw.telemetry_public.dop IS 'The dilution of precision'; + COMMENT ON COLUMN bcgw.telemetry_public.device_key IS 'The vendor and device serial'; + COMMENT ON COLUMN bcgw.telemetry_public.animal_id IS 'The identifier of the animal wearing the telemetry device'; + `); } /** @@ -73,4 +122,7 @@ export async function down(knex: Knex): Promise { await knex.raw(` DROP MATERIALIZED VIEW IF EXISTS bcgw.telemetry_all; `); + await knex.raw(` + DROP MATERIALIZED VIEW IF EXISTS bcgw.telemetry_public; + `); } diff --git a/database/src/migrations/20251230000000_telemetry_public.ts b/database/src/migrations/20251230000000_telemetry_public.ts deleted file mode 100644 index 751b8469e..000000000 --- a/database/src/migrations/20251230000000_telemetry_public.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Knex } from 'knex'; - -/** - * Creating a materialised view for telemetry_all. - * - * @export - * @param {Knex} knex - * @return {*} {Promise} - */ -export async function up(knex: Knex): Promise { - await knex.raw(` -CREATE MATERIALIZED VIEW bcgw.telemetry_public AS -WITH deployments AS ( - SELECT - dep.submission_feature_id, - dep.data->>'device_key' AS device_key, - dep.data->>'animal_identifier' AS animal_id - FROM biohub.submission_feature dep - JOIN biohub.feature_type ft_dep - ON dep.feature_type_id = ft_dep.feature_type_id - WHERE ft_dep.name = 'telemetry_deployment' - AND dep.record_end_date IS NULL -) -SELECT - sf.submission_feature_id AS Feature_ID, - d.animal_id, - -- Contingent on Feature Array: Add columns Species Code, Species english name, species scientific name, Sex, Ecological Unit - d.device_key, - (sf.data->>'timestamp')::timestamptz AS DATETIME, - (EXTRACT(YEAR FROM (sf.data->>'timestamp')::timestamptz))::int AS YEAR, - (sf.data->>'latitude')::numeric AS Latitude, - (sf.data->>'longitude')::numeric AS Longitude, - (sf.data->>'dop')::numeric AS dop - -- contingent on feature array: join to dataset and get the survey name and id, and the study area id -FROM biohub.submission_feature sf -JOIN biohub.feature_type ft - ON sf.feature_type_id = ft.feature_type_id -LEFT JOIN deployments d - ON d.submission_feature_id = sf.parent_submission_feature_id -WHERE ft.name = 'telemetry' - AND sf.record_end_date IS NULL - AND sf.submission_feature_id NOT IN ( - SELECT submission_feature_id - FROM biohub.submission_feature_security - ) - AND (sf.data->>'timestamp')::timestamptz <= (NOW() - INTERVAL '4 months'); - `); - - await knex.raw(` - COMMENT ON COLUMN bcgw.telemetry_public.Feature_ID IS 'System generated surrogate primary key identifier'; - COMMENT ON COLUMN bcgw.telemetry_public.Latitude IS 'The latitude of the GPS location'; - COMMENT ON COLUMN bcgw.telemetry_public.Longitude IS 'The longitude of the GPS location'; - COMMENT ON COLUMN bcgw.telemetry_public.DATETIME IS 'The date and time that the GPS location was recorded'; - COMMENT ON COLUMN bcgw.telemetry_public.YEAR IS 'The year that the GPS location was recorded'; - COMMENT ON COLUMN bcgw.telemetry_public.dop IS 'The dilution of precision'; - COMMENT ON COLUMN bcgw.telemetry_public.device_key IS 'The vendor and device serial'; - COMMENT ON COLUMN bcgw.telemetry_public.animal_id IS 'The identifier of the animal wearing the telemetry device'; - `); -} - -/** - * Revert materialized view. - * - * @export - * @param {Knex} knex - * @return {*} {Promise} - */ -export async function down(knex: Knex): Promise { - await knex.raw(` - DROP MATERIALIZED VIEW IF EXISTS bcgw.telemetry_public; - `); -} From 864b17737c11359f4b1543574b9296fc3000fecd Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Wed, 18 Feb 2026 16:56:51 -0800 Subject: [PATCH 19/22] updating seeds to secure features --- database/src/seeds/04_mock_test_data.ts | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index ccd9e0639..e6bc18afd 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -486,6 +486,23 @@ const randomIntFromInterval = (min: number, max: number) => { return Math.floor(Math.random() * (max - min + 1) + min); }; +export const insertSubmissionFeatureSecurity = async ( + knex: Knex, + options: { submission_feature_id: number; security_rule_id: number } +): Promise => { + const res = await knex.raw(` + INSERT INTO submission_feature_security (submission_feature_id, security_rule_id, create_user) + VALUES ( + ${options.submission_feature_id}, + ${options.security_rule_id}, + (SELECT system_user_id from "system_user" where user_identifier = 'SIMS') + ) + RETURNING submission_feature_security_id; + `); + + return res.rows[0].submission_feature_security_id; +}; + export const insertTelemetryDeployment = async ( knex: Knex, options: { submission_id: number; parent_submission_feature_id: number } @@ -585,5 +602,16 @@ export const insertTelemetryRecord = async ( })}` ); + // randomly secure some telemetry points + if (Math.random() < 0.1) { + const ruleRes = await knex.raw(`SELECT security_rule_id FROM security_rule ORDER BY random() LIMIT 1`); + if (ruleRes.rows.length) { + await insertSubmissionFeatureSecurity(knex, { + submission_feature_id, + security_rule_id: ruleRes.rows[0].security_rule_id + }); + } + } + return submission_feature_id; }; From bf8ba2c4297cd7956866cbd9024c40e137c073b7 Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Tue, 24 Feb 2026 15:41:11 -0800 Subject: [PATCH 20/22] adding measurements to observations view - trial --- .../20251230000000_bcgw_materialised_views.ts | 51 +++++++++++++++++++ database/src/seeds/04_mock_test_data.ts | 38 +++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/database/src/migrations/20251230000000_bcgw_materialised_views.ts b/database/src/migrations/20251230000000_bcgw_materialised_views.ts index 1ddedcd5e..c856a956e 100644 --- a/database/src/migrations/20251230000000_bcgw_materialised_views.ts +++ b/database/src/migrations/20251230000000_bcgw_materialised_views.ts @@ -109,6 +109,54 @@ WHERE ft.name = 'telemetry' COMMENT ON COLUMN bcgw.telemetry_public.device_key IS 'The vendor and device serial'; COMMENT ON COLUMN bcgw.telemetry_public.animal_id IS 'The identifier of the animal wearing the telemetry device'; `); + + await knex.raw(` +CREATE MATERIALIZED VIEW bcgw.observations_public AS +WITH measurements AS ( + SELECT + m.parent_submission_feature_id AS observation_id, + (m.data->>'sex')::text AS sex, + (m.data->>'life_stage')::text AS life_stage, + (m.data->>'measurement_type')::text AS measurement_type, + (m.data->>'measurement_value')::text AS measurement_value + FROM biohub.submission_feature m + JOIN biohub.feature_type ft_m + ON m.feature_type_id = ft_m.feature_type_id + WHERE ft_m.name = 'measurement' + AND m.record_end_date IS NULL +) +SELECT + sf.submission_feature_id AS Feature_ID, + (sf.data->>'timestamp')::timestamptz AS DATETIME, + (EXTRACT(YEAR FROM (sf.data->>'timestamp')::timestamptz))::int AS YEAR, + public.ST_Y(public.ST_GeomFromGeoJSON(sf.data->>'geometry')) AS Latitude, + public.ST_X(public.ST_GeomFromGeoJSON(sf.data->>'geometry')) AS Longitude, + (sf.data->>'sign')::text AS sign, + (sf.data->>'count')::int AS count, + meas.sex, + meas.life_stage +FROM biohub.submission_feature sf +JOIN biohub.feature_type ft + ON sf.feature_type_id = ft.feature_type_id +LEFT JOIN measurements meas + ON meas.observation_id = sf.submission_feature_id +WHERE ft.name = 'species_observation' + AND sf.record_end_date IS NULL + AND sf.submission_feature_id NOT IN ( + SELECT submission_feature_id + FROM biohub.submission_feature_security + ); + `); + + await knex.raw(` + COMMENT ON COLUMN bcgw.observations_public.Feature_ID IS 'System generated surrogate primary key identifier'; + COMMENT ON COLUMN bcgw.observations_public.Latitude IS 'The latitude of the observation location'; + COMMENT ON COLUMN bcgw.observations_public.Longitude IS 'The longitude of the observation location'; + COMMENT ON COLUMN bcgw.observations_public.DATETIME IS 'The timestamp of the observation'; + COMMENT ON COLUMN bcgw.observations_public.YEAR IS 'The year of the observation'; + COMMENT ON COLUMN bcgw.observations_public.sign IS 'Type of sign associated with the observation'; + COMMENT ON COLUMN bcgw.observations_public.count IS 'Count value for the observation'; + `); } /** @@ -125,4 +173,7 @@ export async function down(knex: Knex): Promise { await knex.raw(` DROP MATERIALIZED VIEW IF EXISTS bcgw.telemetry_public; `); + await knex.raw(` + DROP MATERIALIZED VIEW IF EXISTS bcgw.observations_public; + `); } diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index e6bc18afd..60a11dd7e 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -223,7 +223,10 @@ export const insertObservationRecord = async ( 1, // number of features in feature collection [-135.878906, 48.617424, -114.433594, 60.664785] // bbox constraint )['features'][0]['geometry'], - count: faker.number.int({ min: 0, max: 100 }) + count: faker.number.int({ min: 0, max: 100 }), + // species observation-specific properties + timestamp: faker.date.between({ from: '2020-01-01T00:00:00.000Z', to: new Date().toISOString() }).toISOString(), + sign: faker.helpers.arrayElement(['tracks', 'scat', 'sighting', 'other']) } })}` ); @@ -247,6 +250,36 @@ export const insertObservationRecord = async ( await knex.raw(`${insertSpatialPoint({ submission_feature_id })}`); + // attach a measurement child record (sex & life stage) to the observation + await insertMeasurementRecord(knex, { + submission_id: options.submission_id, + parent_submission_feature_id: submission_feature_id + }); + + return submission_feature_id; +}; + +export const insertMeasurementRecord = async ( + knex: Knex, + options: { submission_id: number; parent_submission_feature_id: number } +): Promise => { + const response = await knex.raw( + `${insertSubmissionFeature({ + submission_id: options.submission_id, + parent_submission_feature_id: options.parent_submission_feature_id, + feature_type: 'measurement', + data: { + sex: faker.helpers.arrayElement(['male', 'female', 'unknown']), + life_stage: faker.helpers.arrayElement(['adult', 'juvenile', 'unknown']) + } + })}` + ); + const submission_feature_id = response.rows[0].submission_feature_id; + await knex.raw(`${insertSearchString({ submission_feature_id })}`); + await knex.raw(`${insertSearchString({ submission_feature_id })}`); + await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); + await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); + return submission_feature_id; }; @@ -335,7 +368,8 @@ export const insertSubmissionFeature = (options: { | 'artifact' | 'telemetry' | 'telemetry_deployment' - | 'telemetry_device'; + | 'telemetry_device' + | 'measurement'; data: { [key: string]: any }; }) => ` INSERT INTO submission_feature From 00ea578f7fc134e232b39f93ef5ec8e99744214c Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Thu, 5 Mar 2026 16:32:03 -0800 Subject: [PATCH 21/22] seeding taxonomy --- database/src/seeds/04_mock_test_data.ts | 82 ++++++++++++++++++++----- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/database/src/seeds/04_mock_test_data.ts b/database/src/seeds/04_mock_test_data.ts index 60a11dd7e..e8fbe41fc 100644 --- a/database/src/seeds/04_mock_test_data.ts +++ b/database/src/seeds/04_mock_test_data.ts @@ -48,6 +48,8 @@ export async function seed(knex: Knex): Promise { SET SEARCH_PATH = 'biohub','public'; `); + // Ensure there are mock taxonomy records for animals/observations to reference + await ensureTaxonomySeed(trx); for (let i = 0; i < NUM_MOCK_FEATURE_SUBMISSIONS; i++) { await insertRecord(trx); // pass the transaction instead of knex } @@ -212,13 +214,15 @@ export const insertObservationRecord = async ( knex: Knex, options: { submission_id: number; parent_submission_feature_id: number } ): Promise => { + const taxonId = await getRandomTaxonId(knex); + const response = await knex.raw( `${insertSubmissionFeature({ submission_id: options.submission_id, parent_submission_feature_id: options.parent_submission_feature_id, feature_type: 'species_observation', data: { - taxon_id: faker.number.int({ min: 10000, max: 99999 }), + taxon_id: taxonId, geometry: random.point( 1, // number of features in feature collection [-135.878906, 48.617424, -114.433594, 60.664785] // bbox constraint @@ -243,7 +247,7 @@ export const insertObservationRecord = async ( await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); - await knex.raw(`${insertSearchStringTaxonomy({ submission_feature_id })}`); + await knex.raw(`${insertSearchStringTaxonomy({ submission_feature_id, taxon_id: taxonId })}`); // await knex.raw(`${insertSearchStartDatetime({ submission_feature_id })}`); // await knex.raw(`${insertSearchEndDatetime({ submission_feature_id })}`); @@ -287,6 +291,8 @@ const insertAnimalRecord = async ( knex: Knex, options: { submission_id: number; parent_submission_feature_id: number } ): Promise => { + const taxonId = await getRandomTaxonId(knex); + const response = await knex.raw( `${insertSubmissionFeature({ submission_id: options.submission_id, @@ -295,7 +301,7 @@ const insertAnimalRecord = async ( data: { species: faker.animal.type(), count: faker.number.int({ min: 0, max: 100 }), - taxon_id: faker.number.int({ min: 10000, max: 99999 }), + taxon_id: taxonId, start_date: faker.date.past().toISOString(), end_date: faker.date.future().toISOString() } @@ -314,7 +320,7 @@ const insertAnimalRecord = async ( await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); await knex.raw(`${insertSearchNumber({ submission_feature_id })}`); - await knex.raw(`${insertSearchStringTaxonomy({ submission_feature_id })}`); + await knex.raw(`${insertSearchStringTaxonomy({ submission_feature_id, taxon_id: taxonId })}`); await knex.raw(`${insertSearchStartDatetime({ submission_feature_id })}`); await knex.raw(`${insertSearchEndDatetime({ submission_feature_id })}`); @@ -423,19 +429,19 @@ const insertSearchNumber = (options: { submission_feature_id: number }) => ` ); `; -const insertSearchStringTaxonomy = (options: { submission_feature_id: number }) => ` - INSERT INTO search_string - ( - submission_feature_id, - feature_property_id, - value - ) - values - ( - ${options.submission_feature_id}, - (select feature_property_id from feature_property where name = 'taxon_id'), - $$${faker.number.int({ min: 10000, max: 99999 })}$$ - ); +const insertSearchStringTaxonomy = (options: { submission_feature_id: number; taxon_id?: number }) => ` + INSERT INTO search_string + ( + submission_feature_id, + feature_property_id, + value + ) + values + ( + ${options.submission_feature_id}, + (select feature_property_id from feature_property where name = 'taxon_id'), + $$${options.taxon_id ?? faker.number.int({ min: 10000, max: 99999 })}$$ + ); `; const insertSearchStartDatetime = (options: { submission_feature_id: number }) => ` @@ -520,6 +526,48 @@ const randomIntFromInterval = (min: number, max: number) => { return Math.floor(Math.random() * (max - min + 1) + min); }; +/** + * Ensure the taxonomy table has a set of mock taxon records (itis_tsn + scientific name). + */ +const ensureTaxonomySeed = async (knex: Knex) => { + const desiredCount = 5; + + const countRes = await knex.raw(`SELECT count(*)::int as c FROM taxon`); + const existing = countRes.rows?.[0]?.c || 0; + + if (existing >= desiredCount) { + return; + } + + const toCreate = desiredCount - existing; + const tsnSet = new Set(); + while (tsnSet.size < toCreate) { + tsnSet.add(faker.number.int({ min: 10000, max: 99999 })); + } + + const valuesSql = Array.from(tsnSet) + .map((tsn) => { + const sci = faker.lorem.word().replace(/'/g, "''"); + const common = faker.animal.type().replace(/'/g, "''"); + const itisData = JSON.stringify({ source: 'mock' }).replace(/'/g, "''"); + return `(${tsn}, $$${sci}$$, $$${common}$$, $$${itisData}$$::jsonb, now(), (SELECT system_user_id from "system_user" where user_identifier = 'SIMS'))`; + }) + .join(',\n'); + + const sql = ` + INSERT INTO taxon (itis_tsn, itis_scientific_name, common_name, itis_data, itis_update_date, create_user) + VALUES + ${valuesSql}; + `; + + await knex.raw(sql); +}; + +const getRandomTaxonId = async (knex: Knex): Promise => { + const res = await knex.raw(`SELECT itis_tsn FROM taxon ORDER BY random() LIMIT 1`); + return res.rows?.[0]?.itis_tsn ?? faker.number.int({ min: 10000, max: 99999 }); +}; + export const insertSubmissionFeatureSecurity = async ( knex: Knex, options: { submission_feature_id: number; security_rule_id: number } From 032eb21bb8a4c9b6315b3f1600d4f14e34862e4a Mon Sep 17 00:00:00 2001 From: Annika Meijer Date: Thu, 5 Mar 2026 16:39:58 -0800 Subject: [PATCH 22/22] adding taxonomy to observations view --- .../migrations/20251230000000_bcgw_materialised_views.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/database/src/migrations/20251230000000_bcgw_materialised_views.ts b/database/src/migrations/20251230000000_bcgw_materialised_views.ts index c856a956e..434dad633 100644 --- a/database/src/migrations/20251230000000_bcgw_materialised_views.ts +++ b/database/src/migrations/20251230000000_bcgw_materialised_views.ts @@ -133,6 +133,9 @@ SELECT public.ST_X(public.ST_GeomFromGeoJSON(sf.data->>'geometry')) AS Longitude, (sf.data->>'sign')::text AS sign, (sf.data->>'count')::int AS count, + (sf.data->>'taxon_id')::int AS taxon_id, + t.itis_scientific_name AS scientific_name, + t.common_name AS common_name, meas.sex, meas.life_stage FROM biohub.submission_feature sf @@ -140,6 +143,8 @@ JOIN biohub.feature_type ft ON sf.feature_type_id = ft.feature_type_id LEFT JOIN measurements meas ON meas.observation_id = sf.submission_feature_id +LEFT JOIN biohub.taxon t + ON t.itis_tsn = (sf.data->>'taxon_id')::int WHERE ft.name = 'species_observation' AND sf.record_end_date IS NULL AND sf.submission_feature_id NOT IN ( @@ -156,6 +161,9 @@ WHERE ft.name = 'species_observation' COMMENT ON COLUMN bcgw.observations_public.YEAR IS 'The year of the observation'; COMMENT ON COLUMN bcgw.observations_public.sign IS 'Type of sign associated with the observation'; COMMENT ON COLUMN bcgw.observations_public.count IS 'Count value for the observation'; + COMMENT ON COLUMN bcgw.observations_public.taxon_id IS 'Taxonomic identifier extracted from the observation payload'; + COMMENT ON COLUMN bcgw.observations_public.scientific_name IS 'Scientific name from taxon table linked via ITIS TSN'; + COMMENT ON COLUMN bcgw.observations_public.common_name IS 'Common name from taxon table linked via ITIS TSN'; `); }