-
Notifications
You must be signed in to change notification settings - Fork 1
Home
- Project Summary
- Architecture
- Code Structure
- Data Workflow
- Environment Configuration
- Search & Download
The weekly csv datasync stores a significant amount of data to the PVC in OpenShift. We're currently capped at 40GB (a request has been made to increase this to 60GB).
You can check the utilization of the PVC in OpenShift here: https://console.apps.silver.devops.gov.bc.ca/k8s/ns/d23a53-prod/resourcequotas/storage-quota
Requests to increase resource limits are here: https://developer.gov.bc.ca/docs/default/component/platform-developer-docs/docs/automation-and-resiliency/request-quota-adjustment-for-openshift-project-set/
The Environmental Monitoring Data - WR (nr-enmods-wr) project is a full-stack application that manages environmental sampling observations and provides a searchable interface for retrieving water quality data. The system automates the ingestion of CSV observation data from AQI's S3 bucket, processes it through staging and operational tables, and exposes search capabilities through a React frontend backed by a NestJS API. It also exports spatial location data from AQI's APIs to NRS ObjectStore in multiple GIS formats.
Key Technologies:
- Frontend: React/TypeScript with Vite
- Backend: NestJS with TypeScript
- Database: PostgreSQL with PostGIS extensions
- Infrastructure: Docker, Kubernetes/OpenShift, Flyway migrations
- Automation: GitHub Actions with scheduled cron workflows
- Cloud Storage: AQI S3 (source data), NRS ObjectStore (spatial exports)
The system follows a modern microservices-inspired architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React/TypeScript) │
│ - Basic Search (Location, Permit, Media, Observation Type) │
│ - Advanced Search (Method, Agency, Classification, etc.) │
│ - CSV Download Results │
└──────────────────────────┬──────────────────────────────────┘
│ HTTP/REST
┌──────────────────────────▼──────────────────────────────────┐
│ Backend API (NestJS) │
│ ┌────────────────┬─────────────┬──────────────────┐ │
│ │ Search Svc │ Geodata │ S3 Sync Log │ │
│ │ (Query) │ (Location) │ (Metrics) │ │
│ └────────────────┴─────────────┴──────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│ TypeORM
┌──────────────────────────▼──────────────────────────────────┐
│ PostgreSQL Database │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ AQI_CSV_IMPORT_OPERATIONAL (Live Search Data) │ │
│ │ AQI_CSV_IMPORT_STAGING (Staging/Import Buffer) │ │
│ │ Materialized Views (Dropdown Lists) │ │
│ │ FileInfo, S3SyncLog, etc. (Metadata) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AQI S3 & NRS ObjectStore │
│ - CSV observation data files (from AQI S3) │
│ - GeoPackage, GDB, CSV spatial exports (to NRS ObjectStore)│
└─────────────────────────────────────────────────────────────┘
backend/src/
├── app.module.ts # Root NestJS module (imports all services)
├── app.controller.ts # Health checks, root endpoints
├── app.service.ts # Core application logic
├── main.ts # Application bootstrap
├── ormconfig.ts # Database configuration
├── metrics.controller.ts # Prometheus metrics endpoint
│
├── search/ # Observation Search Module
│ ├── search.service.ts # Query builder, CSV streaming, statistics
│ ├── search.controller.ts # REST endpoints for search/download
│ └── dto/
│ └── basicSearch.dto.ts # Search filter criteria
│
├── observations/ # Observations Data Module
│ ├── observations.service.ts # Observation CRUD operations
│ ├── observations.controller.ts # Observation endpoints
│ └── entities/
│ └── observation.entity.ts # Observation data model
│
├── geodata/ # Geodata Sync Module
│ ├── geodata.service.ts # Cron job for location sync
│ ├── geodata.controller.ts # Manual trigger endpoints
│ └── entities/
│ └── file-info.entity.ts # File metadata tracking
│
├── materialized-views/ # Dropdown Data Views
│ ├── mv_aqi_sampling_agency.entity.ts
│ ├── mv_aqi_project.entity.ts
│ ├── mv_aqi_location_type.entity.ts
│ ├── mv_aqi_medium.entity.ts
│ ├── mv_aqi_observed_property_*.entity.ts # Multiple views
│ └── ... (18 total materialized views)
│
├── aqi-csv-import-operational/ # CSV Import Status Module
│ ├── aqi-csv-import-operational.service.ts
│ └── entities/
│ └── aqi-csv-import-operational.entity.ts
│
├── s3_sync_log/ # S3 Sync Logging Module
│ ├── s3_sync_log.service.ts # Query sync history
│ └── entities/
│ └── s3_sync_log.entity.ts # Sync attempt tracking
│
├── auth/ # Authentication Module
│ ├── jwtauth.guard.ts # JWT token validation
│ └── ... (Keycloak/IDIR integration)
│
├── common/ # Shared Utilities
├── enum/ # Enums and Constants
├── middleware/ # HTTP Middleware (Logging, etc.)
├── util/ # Helper Functions
└── validation/ # Data Validation Rules
frontend/src/
├── main.tsx # React entry point
├── App.tsx # Root component
├── keycloak.ts # IDIR authentication config
├── config.js # API endpoints, environment config
│
├── pages/ # Route pages
│ ├── BasicSearch.tsx # Basic search interface
│ ├── AdvancedSearch.tsx # Advanced search interface
│ └── Results.tsx # Search results display
│
├── components/ # Reusable UI components
│ ├── SearchFilters/ # Filter form builders
│ ├── ResultsTable/ # Results display grid
│ └── ... (UI widgets)
│
├── service/ # API integration
│ ├── api.service.ts # REST API client
│ ├── auth.service.ts # Authentication logic
│ └── search.service.ts # Search API wrapper
│
├── store/ # State management
└── util/ # Frontend helpers
Data Tables:
-
aqi_csv_import_operational- Live searchable observations (20M+ rows, growing) -
aqi_csv_import_staging- Temporary staging area during imports -
aqi_csv_import_tmp- Swap buffer during staging→operational transition
Metadata Tables:
-
file_info- Tracks processed GeoPackage files -
s3_sync_log- Records each data synchronization attempt
Materialized Views (18 total):
Dropdown options are computed once per week via materialized views and cached in the database. This is necessary because the source aqi_csv_import_operational table contains 20+ million records and growing, making it computationally expensive to calculate unique values on-demand.
Examples:
-
mv_aqi_sampling_agency- Unique agencies collecting samples -
mv_aqi_project- Projects and their names -
mv_aqi_analyzing_agency- Labs analyzing samples -
mv_aqi_medium- Sample media types (water, soil, etc.) -
mv_aqi_observed_property_*- Various analytical properties -
mv_aqi_location_*- Location and location group hierarchies - And 6 more for methodology, QC, classification, and work orders
Why Materialized Views?
Querying 20 million operational records to extract unique values would be extremely slow if done on every page load. Instead:
- Weekly refresh: During the data sync job, all materialized views are rebuilt from fresh staging data
- Pre-computed distinctness: Each view contains only unique values for its category
- Instant queries: Frontend calls the materialized views API endpoint once at app startup
- Redux caching: Values are stored in Redux state and reused for the entire user session
- Result: Dropdown initialization takes milliseconds instead of seconds
The system has TWO independent scheduled jobs that work together:
- Daily Spatial/Location Export - Exports sampling location data and spatial analysis to GeoPackage files
- Weekly Observation Data Sync - Loads CSV observation data from external systems into the search database
Runs daily on a configurable schedule (default: 2 AM Pacific Time):
┌──────────────────────────────────────────────────────────────────┐
│ DAILY SPATIAL EXPORT WORKFLOW (GeodataService) │
│ Runs on: GEODATA_REFRESH_CRON schedule │
│ │
│ 1. FETCH EXTENDED ATTRIBUTES │
│ └─ Query AQI API for field metadata │
│ └─ Get Closed Date, Established Date, Well Tag IDs │
│ │
│ 2. FETCH PREVIOUS GEOPACKAGE │
│ └─ Load last successfully exported GeoPackage from S3 │
│ └─ Track timestamp of last export │
│ │
│ 3. FETCH UPDATED SAMPLING LOCATIONS │
│ └─ Query AQI API for locations modified since last export │
│ └─ Paginate through results (cursor-based) │
│ └─ Transform to GeoJSON format │
│ │
│ 4. GENERATE NEW GEOPACKAGE │
│ └─ Create GeoPackage file with sampling locations │
│ └─ Include extended attributes (closed date, etc.) │
│ └─ Generate File Geodatabase (GDB) format │
│ └─ Generate CSV export of locations │
│ │
│ 5. PERFORM SPATIAL ANALYSIS │
│ └─ Intersect new locations with existing boundaries │
│ └─ Generate derivative GeoPackage files │
│ └─ Create location group hierarchies │
│ │
│ 6. UPLOAD TO NRS OBJECTSTORE │
│ └─ Upload GeoPackage files to NRS ObjectStore │
│ └─ Upload Geodatabase exports to NRS ObjectStore │
│ └─ Upload CSV files to NRS ObjectStore │
│ └─ Store in: objectstore://bucket/folder/ │
│ │
│ 7. LOG FILE METADATA │
│ └─ Save file_name and date_created to FILE_INFO table │
│ └─ Track all exported files for next cycle │
│ └─ Clean up temporary files from /tmp/geodata/ │
│ │
└──────────────────────────────────────────────────────────────────┘
Key Details:
-
Service:
GeodataService.processAndUpload()(Cron decorator triggers it) -
Frequency: Daily (default 2 AM PT, configurable via
GEODATA_REFRESH_CRON) - Data Source: AQI APIs (sampling locations and extended attributes)
- Destination: NRS ObjectStore + PostgreSQL (FileInfo table)
- Time Zone: America/Vancouver (Pacific Time)
- Duration: Varies based on data volume (typically 15-30 minutes)
What Gets Exported:
- GeoPackage files uploaded to NRS ObjectStore
- File Geodatabase (.gdb) exports uploaded to NRS ObjectStore
- CSV location exports uploaded to NRS ObjectStore
- Location group hierarchies and spatial analysis derivatives
- Extended attributes (well tags, closure dates, etc.)
Runs weekly on a configurable schedule (typical: Sunday 1 AM Pacific Time):
┌──────────────────────────────────────────────────────────────────┐
│ WEEKLY OBSERVATION SYNC WORKFLOW │
│ CSV Files → Staging → Operational Table │
│ Runs on: OBS_REFRESH_CRON schedule │
│ │
│ 1. FETCH & LOAD (AQI S3 → PostgreSQL Staging) │
│ └─ Pull CSV files from AQI's S3 bucket │
│ └─ Stream into AQI_CSV_IMPORT_STAGING table if |
| files changed since last import │
│ └─ Log start time in S3_SYNC_LOG │
│ │
│ 2. REFRESH MATERIALIZED VIEWS (Generate Dropdown Data) │
│ └─ Execute REFRESH on all 18 materialized views │
│ └─ Updates occur in ~seconds (based on staging data) │
│ └─ Ensures UI dropdowns show latest values │
│ │
│ 3. LOG SUCCESS METRICS (Update S3_SYNC_LOG) │
│ └─ Record row counts, timestamps, processing duration │
│ └─ Calculate statistics (unique locations, date ranges, etc.)│
│ └─ Store in S3_SYNC_LOG for monitoring │
│ │
│ 4. SWAP TABLES (Staging → Operational) │
│ └─ Execute SQL function: run_aqi_table_swap() │
│ └─ Pre-build all indexes on STAGING table │
│ └─ Atomically rename tables: │
│ - OPERATIONAL → TMP (backup) │
│ - STAGING → OPERATIONAL (make live) │
│ - TMP → STAGING (reset for next week) │
│ └─ Search service now queries fresh data │
│ └─ Log completion time and swap status │
│ │
└──────────────────────────────────────────────────────────────────┘
Key Details:
-
Trigger: Cron schedule via NestJS
@nestjs/schedulemodule - Frequency: Weekly (configurable, default: Sunday 1 AM PT)
- Data Source: CSV files in AWS S3 bucket
- Destination: PostgreSQL observation tables + materialized views
- Atomicity: Zero-downtime atomic table swap
- Fallback: Previous operational data retained as backup during swap
Cron Expression: Controlled by GEODATA_REFRESH_CRON environment variable
Example Schedules:
-
0 2 * * *= Every day at 2 AM -
0 2 * * 0= Every Sunday at 2 AM -
0 2 * * 1-5= Every weekday at 2 AM
What It Does:
The GeodataService fetches the latest sampling location data from the external AQI system and exports it as spatial data formats:
-
Fetch Extended Attributes
- Queries AQI API for metadata attributes (closed dates, established dates, well tags)
- Stores in memory for enriching location data
- Used to track location lifecycle information
-
Download Previous Export (from S3)
- Queries
FILE_INFOtable for most recent export - Downloads that GeoPackage file from ObjectStore as baseline
- Compares timestamps to identify which locations changed since last run
- Queries
-
Fetch Updated Locations from AQI API
- Queries AQI system for locations modified since last export time
- Uses cursor-based pagination for large datasets (API returns 1000 records per request)
- Transforms location data to GeoJSON format
- Collects extended attributes for each location
-
Generate GeoPackage & Derivatives
- Creates new GeoPackage file with updated locations
- Generates File Geodatabase (.gdb) format for ArcGIS compatibility
- Exports location data as CSV
- Creates location group hierarchies and related datasets
- Performs spatial intersections with administrative/political boundaries
-
Upload to S3 (ObjectStore)
- Uploads all generated files to configured S3 bucket
- Bucket structure:
s3://BUCKET/FOLDER/[geopackage|gdb|csv] - Example:
s3://enmods_test/Data_Catalogue_TEST/sampling_locations_20250116_120515.gpkg
-
Log Metadata & Clean Up
- Records file name and creation timestamp in
FILE_INFOtable - Deletes temporary files from
/tmp/geodata/to free disk space - Logs total processing duration
- Records file name and creation timestamp in
Configuration Environment Variables:
# Cron Schedule
GEODATA_REFRESH_CRON="0 2 * * *" # Daily at 2 AM
# External AQI APIs
BASE_URL_BC_API="https://bcenv-enmods-test.aqsamples.ca/api/"
SAMPLING_LOCATIONS_ENDPOINT="v1/samplinglocations"
EXTENDED_ATTRIBUTES_ENDPOINT="v1/extendedattributes"
# NRS ObjectStore (destination for spatial exports)
OBJECTSTORE_URL="https://nrs.objectstore.gov.bc.ca"
OBJECTSTORE_BUCKET="enmods_test" # Bucket name (test vs prod)
OBJECTSTORE_FOLDER="Data_Catalogue_TEST" # Folder within bucketOutputs:
- GeoPackage files uploaded to NRS ObjectStore
- File Geodatabase exports uploaded to NRS ObjectStore
- CSV location exports uploaded to NRS ObjectStore
-
FILE_INFOdatabase table entries - Application logs with timing/diagnostics
Typical Duration: 15-30 minutes (depends on number of location updates)
Cron Expression: Controlled by OBS_REFRESH_CRON environment variable
Example Schedules:
-
0 1 * * 0= Every Sunday at 1 AM -
0 8 * * 1= Every Monday at 8 AM -
0 */6 * * *= Every 6 hours
What It Does:
The observation sync pulls CSV files from AQI's S3 bucket, loads them into a staging table, refreshes dropdown data, and then atomically swaps the staging and operational tables to make the new data live.
-
Stream CSV from AQI S3 to Staging Table
- Application reads CSV files from AQI's S3 bucket
- Parses CSV content
- Transforms to match
AQI_CSV_IMPORT_STAGINGschema - Bulk inserts rows via TypeORM repository
- Tracks import start time in
S3_SYNC_LOGtable
Key Services Involved:
- Main cron handler that orchestrates import
-
ObservationsService.bulkUpsert()- Database insertion
Database State After Step 1:
-
AQI_CSV_IMPORT_STAGINGcontains fresh data - Original
AQI_CSV_IMPORT_OPERATIONALunchanged
-
Refresh Materialized Views
- After data loads into staging, the system executes REFRESH on all 18 materialized views
- Each view rebuilds from fresh staging data in seconds
Why this matters:
The operational table contains 20+ million records. Computing unique values on-demand would require expensive DISTINCT queries across the entire dataset each time a user opens the app. Materialized views pre-compute these unique values during the weekly sync, allowing the frontend to fetch them instantly.
Examples of what refreshes:
-
mv_aqi_sampling_agency→ Unique agencies collecting samples -
mv_aqi_medium→ All media types (water, soil, etc.) -
mv_aqi_project→ Distinct projects and project names - And 15 more views for methods, locations, agencies, classifications, etc.
Database Operations:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_aqi_sampling_agency; REFRESH MATERIALIZED VIEW CONCURRENTLY mv_aqi_medium; -- ... (18 views total, referenced in V1.0.5__aqi_materialized_vws.sql)Performance Result:
- Without materialized views: Frontend would need to query 20M+ record table for each dropdown → seconds of latency
- With materialized views: Frontend fetches pre-computed unique values → milliseconds
- Redux caching: Values cached in Redux store on app startup → instant filter selections with zero network calls
User Impact:
- Frontend dropdowns populated with latest options (refreshed weekly)
- No stale/removed values remain in filters
- Basic Search and Advanced Search stay current
- Snappy dropdown performance thanks to Redux in-memory caching
-
Log Success Metrics to S3_SYNC_LOG
- Throughout import, system collects:
- Row counts: How many observations loaded
- Processing duration: Total cycle time
- Status transitions: IN_PROGRESS → SUCCESS or FAILED
- Timestamps: start_time and finish_time for monitoring
- Source folder: Which S3 folder was processed
Example S3_SYNC_LOG records:
id | process_name | status | start_time | finish_time | rows_affected --- | ------------------------- | --------- | ---------------- | ---------------- | -------- 1 | public.AQI_AWS_S3_SYNC | SUCCESS | 2025-01-15 08:00 | 2025-01-15 08:15 | 45,234 2 | public.AQI_AWS_S3_SYNC | SUCCESS | 2025-01-08 08:00 | 2025-01-08 08:14 | 44,891Monitoring API Endpoint:
-
GET /s3-sync-logs- Lists all sync attempts with status/timing
- Throughout import, system collects:
-
Swap Staging to Operational
- After verification of staging data quality, atomic swap occurs
- Ensures zero downtime during transition
The Swap Process:
-- Pre-swap state OPERATIONAL (indexed) STAGING (new data) TEMP (empty) ├─ 44.8M rows ├─ 45.2M rows ├─ All indexes built ├─ All indexes built └─ Being queried └─ Ready to go live ↓ Atomic Swap (milliseconds) -- Post-swap state OPERATIONAL (indexed) STAGING (backup) TEMP (old data) ├─ 45.2M rows ├─ 44.8M rows ├─ All indexes built └─ Previous live data └─ Search queries run here
SQL Function:
run_aqi_table_swap()in migrations/sql/V1.0.8__staging_operational_swap.sqlStep-by-step Atomicity:
-
Verify staging readiness
- Check row counts are reasonable
- Confirm all indexes rebuilt successfully
- No corruption or errors detected
-
Rename tables (atomic operation)
ALTER TABLE public.AQI_CSV_IMPORT_OPERATIONAL RENAME TO aqi_csv_import_tmp; ALTER TABLE public.AQI_CSV_IMPORT_STAGING RENAME TO aqi_csv_import_operational; ALTER TABLE public.aqi_csv_import_tmp RENAME TO aqi_csv_import_staging;
- Happens in ~1-2 milliseconds
- All indexes follow the table rename
- No data moved, only metadata changed
- Search queries automatically hit fresh data
-
Truncate old data from new staging
TRUNCATE TABLE public.aqi_csv_import_staging;
- Reset staging to empty state
- Ready for next week's import
-
Log completion
- Record swap status as SUCCESS
- Set finish_time in S3_SYNC_LOG
- Alert on any failures
Why This Design:
- Atomicity: Either swap fully succeeds or rolls back completely
- Zero downtime: Readers never locked; queries seamlessly transition
- No data duplication: Single set of data in one table at a time
- Fast recovery: Previous data remains in backup until overwritten
Configuration Environment Variables:
# Cron Schedule
OBS_REFRESH_CRON="0 1 * * 0" # Weekly Sunday 1 AM
# AQI S3 (CSV observation data source)
# CSV files are accessed from AQI's S3 bucket
# Credentials configured separately for AQI S3 accessOutputs:
- Populated
AQI_CSV_IMPORT_OPERATIONALtable (live search data) - Refreshed materialized views (dropdown options)
-
S3_SYNC_LOGentries (audit trail) - Application logs with timing
Typical Duration: 5-20 minutes (depends on CSV file size and row count)
- User opens Frontend → React app loads, displays search page
- Redux initializes dropdown data → On first load, fetch all dropdown options once from materialized views
- Dropdown state cached in Redux store → All subsequent filter selections use in-memory Redux state (no API calls)
- User selects from dropdowns → Values retrieved from Redux store instantly
-
User clicks Search → Frontend sends query to
/searchendpoint with selected filters -
Backend queries → SearchService hits
AQI_CSV_IMPORT_OPERATIONALtable - Results stream to CSV → Browser downloads file with matching observations
- File contains data → From last week's successful swap + refresh cycle
Redux Dropdown Strategy:
Dropdown values are cached in Redux state to provide optimal performance:
- Single query per session: Dropdown data is fetched from materialized views once when the app loads
- In-memory caching: Redux store holds all dropdown options (agencies, media types, methods, etc.)
- Instant UI updates: Filter selections are snappy with no network latency
- Weekly updates: New dropdown options appear after the weekly materialized view refresh
- Minimal API calls: Reduces database load by eliminating repeated dropdown queries
During data sync, some timestamp fields contained values that were not in a valid IOS 8601 timestamp format. As a result, the system sttempted to interpret part of the timestamp(for example, -16:05) as a timezone offset. Since -16.05 is not a valid timezone, the sync job failed when attempting to insert the data into a TIMESTAMPZ column. The following timestamp fields required validation ans conversion: field_visit_start_time field_visit_end_time observed_date_time observed_date_time_start observed_date_time_end anaylzed_date_time lab_arrival_date_time lab_prepared_date_time
During the copying process, each timestamp value is validated to ensure it conforms to the ISO 8601 standard. If a timestamp is found to be invalid it is automaticalyy transformed into a valid timestamp before written to database.
Example Input: 2026-03-05T16:00-16:05
Output: 2026-03-05T16:00:00-07:00
In the example the invalid timezone offset is corrected and the timestamp is reformatted into a valid ISO 8601 representation.
The transformation is performed using Miller, a command-line tool that can process and transform CSV data as it is streamed. As each row is processed: Timestamp fields are validated. Invalid timestamp values are converted into a valid ISO 8601 format. The corrected timestamp is parsed and stored in the database as a TIMESTAMPTZ value, ensuring consistent UTC-based storage. This approach allows data synchronization to continue successfully.
All behavior is parameterized through .env file variables. This allows different configurations per deployment environment (dev, test, prod).
# PostgreSQL Connection
POSTGRES_PASSWORD="enmodswr_password" # DB password
POSTGRES_USER="enmodswr_user" # DB user
POSTGRES_DATABASE="enmodswr" # Database name
POSTGRES_HOST="localhost" # Host (localhost in dev, service name in k8s)When to use:
- Development:
localhostwith local PostgreSQL - Docker Compose: Service name (e.g.,
postgres) - OpenShift/K8s: Service DNS name (e.g.,
postgres.default.svc.cluster.local)
# AQI System API
BASE_URL_BC_API="https://bcenv-enmods-test.aqsamples.ca/api/" # External AQI API base
API_KEY="API_KEY_VALUE" # API key for AQI system (secret!)
AUTH_TOKEN="AUTH_TOKEN_VALUE" # User auth token (secret!)
# Different keys for test vs production environmentsWhat this does:
- Defines which external AQI system to pull from
- Different API keys per environment (test vs prod)
- Used by
GeodataServiceto fetch sampling locations and extended attributes -
Note:
API_KEYandAUTH_TOKENare sensitive—store in secure environment variables only
# NRS ObjectStore (for spatial data exports)
OBJECTSTORE_URL="https://nrs.objectstore.gov.bc.ca" # NRS ObjectStore endpoint
OBJECTSTORE_ACCESS_KEY="ACCESS_KEY" # ObjectStore access key (secret!)
OBJECTSTORE_SECRET_KEY="SECRET_KEY" # ObjectStore secret key (secret!)
OBJECTSTORE_BUCKET="enmods_test" # Bucket name (enmods_test, enmods_prod, etc.)
OBJECTSTORE_FOLDER="Data_Catalogue_TEST" # Folder path within bucket
# AQI S3 (for CSV observation data source)
# AQI provides CSV files in their S3 instance
# Credentials configured separately for AQI S3 access
# Location data endpoints (from AQI APIs)
SAMPLING_LOCATIONS_ENDPOINT="v1/samplinglocations"
SAMPLING_LOCATION_GROUPS_ENDPOINT="v1/samplinglocationgroups"
EXTENDED_ATTRIBUTES_ENDPOINT="v1/extendedattributes"What this does:
-
NRS ObjectStore: Where spatial exports are uploaded (GeoPackage, GDB, CSV from
GeodataService) -
AQI S3: Source of CSV observation data files (imported weekly via
ObservationsService) -
AQI APIs: Source of sampling location and extended attribute data (queried daily by
GeodataService) - Different credentials and bucket names per environment (test vs prod)
-
Note:
OBJECTSTORE_ACCESS_KEYandOBJECTSTORE_SECRET_KEYare sensitive—store in secure environment variables only
# *** These are NOT in the provided .env but would be set in deployment configs ***
# See values.yaml for production and test environments
OBS_REFRESH_CRON="0 1 * * 0" # Sync CSV observations from AQI S3 every Sunday 1 AM
GEODATA_REFRESH_CRON="0 2 * * *" # Export spatial data to NRS ObjectStore daily 2 AMCron Format: minute hour day month dayofweek
Examples:
-
0 2 * * 0= Sunday 2 AM -
0 8 * * 1= Monday 8 AM -
0 */6 * * *= Every 6 hours -
0 0 * * *= Daily midnight -
@weekly= Every Sunday midnight
Where configured:
-
Dev/Local:
.envfile -
Test/Prod:
charts/app/values.yaml(Helm values) -
GitHub Actions:
.github/workflows/scheduled.yml
What happens:
- Backend uses
@nestjs/schedulemodule to register cron jobs -
GeodataService.processAndUpload()- Runs onGEODATA_REFRESH_CRON - Orchestrates full ETL: load → refresh views → swap tables → log metrics
The search system provides two interfaces. All dropdown options are populated from materialized views on app startup and cached in Redux for instant interactions.
Filter by commonly used fields (values from materialized views):
- Location Name
- Location Type (from
mv_aqi_location_type) - Permit Number
- Media Type (from
mv_aqi_medium) - water, soil, etc. - Observed Property (from
mv_aqi_observed_property_*)
Additional filtering (values from materialized views):
- Analytical Method (from
mv_aqi_analysis_method) - Analyzing Agency (lab) (from
mv_aqi_analyzing_agency) - Observed Property ID
- Work Order Number
- Sampling Agency (from
mv_aqi_sampling_agency) - Collection Method (from
mv_aqi_collection_method) - QC Sample Type (from
mv_aqi_qc_type) - Data Classification (from
mv_aqi_data_classification) - Sample Depth (from
mv_aqi_sample_depth) - Units (from
mv_aqi_units) - Specimen ID
- Projects (from
mv_aqi_project) - Date Range
Dropdown Loading:
Dropdown values are fetched once when the app loads and cached in Redux state. This single API call replaces what would otherwise be hundreds of individual dropdown API calls throughout the user's session.
Endpoint: POST /search
Process:
- Frontend sends
BasicSearchDtowith selected filters -
SearchService.formulateSqlQuery()builds parameterized WHERE clause - Constructs safe SQL:
SELECT * FROM aqi_csv_import_operational WHERE location_id = ANY($1) AND locationType = $2 AND medium = ANY($3) AND observed_date_time_start >= $4 -- ... (and more conditions) LIMIT 100,000
- Queries execute against
AQI_CSV_IMPORT_OPERATIONALtable - Results stream to CSV file in
/data/directory - Browser downloads CSV
Return value: CSV file with matching observations (max 100,000 rows)
While streaming results to CSV, the system collects:
- recordCount: Total rows matched
- uniqueLocations: How many unique location IDs
- minObservationDate: Earliest date in results
- maxObservationDate: Latest date in results
These are returned in HTTP response headers for audit/monitoring.
Architecture:
PostgreSQL Frontend (React/Redux)
─────────────────────── ──────────────────────
20M+ obs records 1. App loads
2. Fetch dropdown data
18 Materialized ────→ 3. Redux store initialized
Views w/ unique 4. Dropdown options cached
values 5. User selects filters
6. Redux provides values
(No API calls for filter selections)
Data Flow:
- Weekly: Materialized views refresh during data sync job
- Weekly: All unique values pre-computed and stored in views
- App startup: Frontend queries materialized views API endpoint once
- Redux: All dropdown options stored in Redux state machine
- User interaction: Filter selections read from Redux store (instant, no network calls)
- Weekly: New/updated dropdown options appear after next materialized view refresh
Why Redux?
- Performance: No network call for every dropdown interaction
- UX: Instant filter selections
- Scalability: Eliminates 100+ potential API calls per user session
- Reliability: Works offline after initial load
Indexes on OPERATIONAL table:
- Rebuilt weekly after swap (in
V1.0.8__staging_operational_swap.sql) - Cover frequently searched columns:
- location_id
- locationType
- medium
- observed_date_time_start/end
- observed_property_id
- sampling_agency
- analyzing_agency
- project
- analysis_method
- collection_method
- qc_type
- data_classification
- depth_upper
- lab_batch_id
- specimen_name
Result: Queries on 20M+ rows complete in <1 second
All the APIs that have been implemented for WR can be found at: https://nr-enmods-wr-test-frontend.apps.silver.devops.gov.bc.ca/api/docs
This page will list all the APIs and give some usage examples. Below you will find more detailed description about the following APIs as they are the most important ones:
- Search and Download
- Spatial Reporting
The Search API allows clients to query the backend for specific data records based on various filters and criteria. It is typically used to retrieve environmental monitoring data, observations, or other domain-specific records stored in the system. The API supports query parameters for filtering, sorting, and pagination.
The general format for this search API is ${WR-URL}/api/v1/search/downloadReport
In order to use this API, a user can either use Postman to consume it or they can use the cURL command. The cURL command is as follows:
curl --location ${WR-URL}/api/v1/search/downloadReport.
If you copy-paste the command from above then you will be given an error saying "Please provide at least one search criteria." If you are wondering how to add search criteria to the API then follow the instructions below.
- locationTypeCustomId
- locationName -- note that the parameter name is locationName but you MUST use the value of location ID, i.e., for the location name use location id
- permitNumber
- fromDate -- the format should be YYYY-MM-DDT08:00:00.000Z
- toDate -- the format should be YYYY-MM-DDT08:00:00.000Z
- media
- projects
- observedProperty
- workOrderNoText
- samplingAgency
- analyzingAgency
- analyticalMethod -- note that the parameter name is analyticalMethod but you MUST use the value of analytical method ID, i.e., for the analytical method name use analytical method id
- collectionMethod
- qcSampleType
- dataClassification
- sampleDepth
- labBatchId
- specimenId
Here is an example of how to add a search criteria to your cURL statement
General format: curl --location ${WR-URL}/api/v1/search/downloadReport?criteriaName={valueAsText}
Real-world examples:
curl --location ${WR-URL}/api/v1/search/downloadReport?locationName=E273189
curl --location ${WR-URL}/api/v1/search/downloadReport?analyticalMethod=X138
curl --location ${WR-URL}/api/v1/search/downloadReport?locationName=E273189&analyticalMethod=X138
If you were to use the commands from above, then the result set would just be printed in the terminal session or wherever you made the cURL command from. In order to download the file all you have to do is append a > {fileName}.csv to the command. So, the command will now look like
curl --location ${WR-URL}/api/v1/search/downloadReport?locationName=E273189&analyticalMethod=X138 > ${fileName}.csv
There is another way to download reports, this method is as easy as pasting a link in your browser and it will download a file. It allows the user to make multiple selections of the same parameter (see example below). If you wish to add more parameters then you can follow the same pattern described above. Essentially, simply using the URL will respond with the contents of the CSV instead of having to click the link to download the file.
e.g. clicking this should result in the file downloading https://nr-enmods-wr-test-frontend.apps.silver.devops.gov.bc.ca/api/v1/search/downloadReport?analyticalMethod=0550,0105,X316,0200
Note that with this method of downloading a file, we're keeping an HTTP connection active and waiting for the file to be created, so there is a limit as to how long we can keep the connection open. If it's open for too long, the response "Request timeout - export took too long" is returned. The max wait time is 5 minutes.
-
GET /health- Liveness probe (is app running?) -
GET /health/ready- Readiness probe (can accept requests?) -
GET /metrics- Prometheus metrics (CPU, memory, request latency, database pool status)
-
GET /s3-sync-logs- Lists all sync attempts with status/timing
- Application logs in stdout (captured by container runtime)
- Query logs (when
PRISMA_LOGGING=query) show all SQL - Cron job progress logged with timestamps and error details
- Local PostgreSQL on
localhost:5432 -
.envfile with all configuration - Manual cron job triggers (via API endpoint)
- OpenShift pod with Postgres service
- Helm values in
charts/app/values-test.yaml - OpenShift Secret:
nr-enmods-wr-test-backend(contains all environment variables) - Scheduled workflows via GitHub Actions
- S3 bucket:
enmods_test
- OpenShift pod with Postgres service
- Helm values in
charts/app/values-prod.yaml - OpenShift Secret:
nr-enmods-wr-prod-backend(contains all environment variables) - Scheduled workflows via GitHub Actions (with approval gate)
- S3 bucket:
enmods_prod - Database backups enabled
- Rolling deployments (zero downtime) ✅ Container Native - Docker, Kubernetes ready
For questions or to contribute, see CONTRIBUTING.md.