diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index 6e9a35a298..28d62e4026 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -483,7 +483,7 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { title: 'Survey Block', type: 'object', additionalProperties: false, - required: ['name', 'description'], + required: ['name', 'description', 'survey_id', 'geojson'], properties: { survey_block_id: { description: 'Survey block id', @@ -494,18 +494,21 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { survey_id: { description: 'Survey id', type: 'integer', - nullable: true + minimum: 1 }, name: { description: 'Name', - type: 'string', - nullable: true + type: 'string' }, description: { description: 'Description', type: 'string', nullable: true }, + geojson: { + description: 'Geojson', + type: 'object' + }, sample_block_count: { description: 'Sample block count', type: 'number' diff --git a/api/src/repositories/survey-block-repository.test.ts b/api/src/repositories/survey-block-repository.test.ts index e8920174f9..607c669fe2 100644 --- a/api/src/repositories/survey-block-repository.test.ts +++ b/api/src/repositories/survey-block-repository.test.ts @@ -23,6 +23,7 @@ describe('SurveyBlockRepository', () => { survey_id: 1, name: '', description: '', + geojson: '', create_date: '', create_user: 1, update_date: '', @@ -82,7 +83,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: 1, survey_id: 1, name: 'Updated name', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: 1, + survey_id: 1, + name: 'Updated name', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; const response = await repo.updateSurveyBlock(block); expect(response.survey_block_id).to.be.eql(1); expect(response.name).to.be.eql('Updated name'); @@ -98,7 +110,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: null, + survey_id: 1, + name: 'new', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; try { await repo.updateSurveyBlock(block); expect.fail(); @@ -131,7 +154,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: null, + survey_id: 1, + name: 'new', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; const response = await repo.insertSurveyBlock(block); expect(response.name).to.be.eql('new'); @@ -143,18 +177,29 @@ describe('SurveyBlockRepository', () => { rows: [], rowCount: 0 } as any as Promise>; + const dbConnection = getMockDBConnection({ sql: () => mockResponse }); + const repo = new SurveyBlockRepository(dbConnection); + try { const block = { survey_block_id: null, survey_id: 1, name: null, - description: null + description: null, + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } } as any as PostSurveyBlock; + await repo.insertSurveyBlock(block); + expect.fail(); } catch (error) { expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to insert survey block'); diff --git a/api/src/repositories/survey-block-repository.ts b/api/src/repositories/survey-block-repository.ts index 83fe2acdba..0d9f37f3fc 100644 --- a/api/src/repositories/survey-block-repository.ts +++ b/api/src/repositories/survey-block-repository.ts @@ -1,6 +1,8 @@ +import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; import { ApiExecuteSQLError } from '../errors/api-error'; +import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; import { BaseRepository } from './base-repository'; export interface PostSurveyBlock { @@ -8,24 +10,22 @@ export interface PostSurveyBlock { survey_id: number; name: string; description: string; + geojson: Feature; } // This describes the a row in the database for Survey Block export const SurveyBlockRecord = z.object({ survey_block_id: z.number(), + survey_id: z.number(), name: z.string(), description: z.string(), + geojson: z.any(), revision_count: z.number() }); export type SurveyBlockRecord = z.infer; // This describes the a row in the database for Survey Block -export const SurveyBlockRecordWithCount = z.object({ - survey_block_id: z.number(), - survey_id: z.number(), - name: z.string(), - description: z.string(), - revision_count: z.number(), +export const SurveyBlockRecordWithCount = SurveyBlockRecord.extend({ sample_block_count: z.number() }); export type SurveyBlockRecordWithCount = z.infer; @@ -52,6 +52,7 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, + sb.geojson, sb.revision_count, COUNT(ssb.survey_block_id)::integer AS sample_block_count FROM @@ -65,6 +66,7 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, + sb.geojson, sb.revision_count; `; @@ -86,15 +88,23 @@ export class SurveyBlockRepository extends BaseRepository { SET name = ${block.name}, description = ${block.description}, - survey_id=${block.survey_id} + survey_id = ${block.survey_id}, + geojson = ${JSON.stringify(block.geojson)}, + geography = public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(block.geojson)).append(`, 4326) + ) + ) WHERE survey_block_id = ${block.survey_block_id} RETURNING survey_block_id, + survey_id, name, description, + geojson, revision_count; - `; + `); const response = await this.connection.sql(sql, SurveyBlockRecord); if (!response.rowCount) { @@ -119,18 +129,29 @@ export class SurveyBlockRepository extends BaseRepository { INSERT INTO survey_block ( survey_id, name, - description + description, + geojson, + geography ) VALUES ( ${block.survey_id}, ${block.name}, - ${block.description} - ) + ${block.description}, + ${JSON.stringify(block.geojson)}, + public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(block.geojson)).append(`, 4326) + ) + ) + ) RETURNING survey_block_id, + survey_id, name, description, + geojson, revision_count; - `; + `); + const response = await this.connection.sql(sql, SurveyBlockRecord); if (!response.rowCount) { diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts index a2e1017c06..9a5dd094a4 100644 --- a/api/src/services/survey-block-service.test.ts +++ b/api/src/services/survey-block-service.test.ts @@ -68,8 +68,30 @@ describe('SurveyBlockService', () => { const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); const blocks: PostSurveyBlock[] = [ - { survey_block_id: null, survey_id: 1, name: 'Old Block', description: 'Updated' }, - { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + { + survey_block_id: null, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }, + { + survey_block_id: null, + survey_id: 1, + name: 'New Block', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + } ]; await service.upsertSurveyBlocks(1, blocks); @@ -106,8 +128,30 @@ describe('SurveyBlockService', () => { const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); const blocks: PostSurveyBlock[] = [ - { survey_block_id: 10, survey_id: 1, name: 'Old Block', description: 'Updated' }, - { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + { + survey_block_id: 10, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }, + { + survey_block_id: null, + survey_id: 1, + name: 'New Block', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + } ]; await service.upsertSurveyBlocks(1, blocks); diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx index b6e0fa47ba..a7cd24123b 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx @@ -21,6 +21,17 @@ const CreateSurveyBlockDialog: React.FC = (props) => { survey_block_id: null, name: '', description: '', + geojson: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0] + }, + properties: { + name: 'Sample', + description: 'This is a placeholder.' + } + }, sample_block_count: 0 }, validationSchema: BlockCreateYupSchema diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx index 960e881d35..b91ae82a53 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx @@ -1,10 +1,10 @@ import EditDialog from 'components/dialog/EditDialog'; import BlockForm from './BlockForm'; -import { BlockEditYupSchema, ISurveyBlock } from './SurveyBlockForm'; +import { BlockEditYupSchema, IPostSurveyBlock } from './SurveyBlockForm'; interface IEditBlockProps { open: boolean; - initialData?: ISurveyBlock; + initialData?: IPostSurveyBlock; onSave: (data: any, index?: number) => void; onClose: () => void; } @@ -23,6 +23,7 @@ const EditSurveyBlockDialog: React.FC = (props) => { survey_block_id: initialData?.block.survey_block_id || null, name: initialData?.block.name || '', description: initialData?.block.description || '', + geojson: initialData?.block.geojson || '', sample_block_count: initialData?.block.sample_block_count }, validationSchema: BlockEditYupSchema diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx index 98779ebf7f..6c4c893d1e 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx @@ -12,6 +12,7 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import YesNoDialog from 'components/dialog/YesNoDialog'; import { useFormikContext } from 'formik'; +import { Feature } from 'geojson'; import { IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; import React, { useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -28,6 +29,7 @@ export const SurveyBlockInitialValues = { export const BlockCreateYupSchema = yup.object({ name: yup.string().required('Name is required').max(50, 'Maximum 50 characters'), description: yup.string().required('Description is required').max(250, 'Maximum 250 characters') + // TODO: Include geojson in validation after adding map control for blocks }); // Form validation for Block Item @@ -35,12 +37,13 @@ export const BlockEditYupSchema = BlockCreateYupSchema.shape({ sample_block_count: yup.number().required('Sample block count is required.') }); -export interface ISurveyBlock { +export interface IPostSurveyBlock { index: number; block: { survey_block_id: number | null; name: string; description: string; + geojson: Feature; sample_block_count: number; }; } @@ -50,7 +53,7 @@ const SurveyBlockForm: React.FC = () => { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isYesNoDialogOpen, setIsYesNoDialogOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState(undefined); + const [editData, setEditData] = useState(undefined); const formikProps = useFormikContext(); const { values, handleSubmit, setFieldValue } = formikProps; diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index fd99d9a7fe..0f6dfaa043 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -70,6 +70,7 @@ export interface ISurveyBlockForm { survey_block_id: number | null; name: string; description: string; + geojson: Feature; sample_block_count: number; }[]; } @@ -138,6 +139,7 @@ export interface IGetSurveyBlock { name: string; description: string; revision_count: number; + geojson: Feature; sample_block_count: number; } @@ -219,6 +221,7 @@ export type IUpdateSurveyRequest = ISurveyLocationForm & { survey_block_id?: number | null; name: string; description: string; + geojson: Feature | null; }[]; site_selection: { strategies: string[]; @@ -441,6 +444,7 @@ export interface IGetSurveyForUpdateResponse { survey_id: number; name: string; description: string; + geojson: Feature; sample_block_count: number; revision_count: number; }[]; diff --git a/database/src/migrations/20241101160200_survey_block_geojson.ts b/database/src/migrations/20241101160200_survey_block_geojson.ts new file mode 100644 index 0000000000..c5d2053f8a --- /dev/null +++ b/database/src/migrations/20241101160200_survey_block_geojson.ts @@ -0,0 +1,30 @@ +import { Knex } from 'knex'; + +/** + * Add geometry-related columns to the survey block table, allowing blocks to be spatial. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub, public; + + ALTER TABLE biohub.survey_block ADD COLUMN geojson JSONB NOT NULL; + ALTER TABLE biohub.survey_block ADD COLUMN geometry geometry(geometry, 3005); + ALTER TABLE biohub.survey_block ADD COLUMN geography geography(geometry, 4326); + + COMMENT ON COLUMN survey_block.geojson IS 'A JSON representation of the project boundary geometry that provides necessary details for shape manipulation in client side tools.'; + COMMENT ON COLUMN survey_block.geometry IS 'The containing geometry of the record.'; + COMMENT ON COLUMN survey_block.geography IS 'The containing geography of the record.'; + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW biohub_dapi_v1.survey_block AS SELECT * FROM biohub.survey_block; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}