-
Notifications
You must be signed in to change notification settings - Fork 0
System Database
This document provides a comprehensive reference for the PostgreSQL database used in the Cut Flowers Monitoring System. It covers schema design, table definitions, relationships, data flow, setup procedures, and operational guidance for developers and system administrators.
This documentation covers:
- Schema definitions and design decisions
- Table structures, constraints, and indexes
- Data flow between the IoT device, backend, and database
- Installation, access, and maintenance procedures
- Backend developers working on API or database features
- System administrators responsible for deployment and maintenance
- Future contributors who need to understand the system architecture
The Cut Flowers Monitoring System is an IoT-based application that tracks flower dehydration levels using field-deployed monitoring devices. Each device captures images at regular intervals and sends them to the backend, where an AI model computes a dehydration_score. The database is the central store for all application data.
The database stores:
- User accounts — grower credentials and contact information
- Monitoring devices — physical ESP32 units and their API keys
- Time-series records — per-capture dehydration scores and image paths
- Alert history — notifications triggered when thresholds are exceeded
Frontend (Next.js/React) → Backend API (Node.js/Express) → PostgreSQL Database
IoT Device (ESP32) → Backend API → PostgreSQL Database

| Decision | Rationale |
|---|---|
Many-to-many (users ↔ monitors) via users_monitors
|
Supports flexible device ownership while enforcing single-tenancy at the application level |
api_key on monitors with a unique index |
Enables secure, stateless device authentication without user credentials |
dehydration_score instead of generic value
|
Makes the column's meaning explicit and keeps alert threshold logic self-documenting (score > 0.20 → alert) |
Separate alerts table linked to records
|
Maintains full alert history and makes each alert traceable to the exact capture event |
alert_method as an ENUM (email, sms) |
Enforces valid delivery methods at the database level |
| Cascading deletes on all foreign keys | Ensures referential integrity when a user or device is removed |
Unique constraint on (monitor_id, time) in records
|
Prevents duplicate capture events from the same device |
| Table | Description |
|---|---|
users |
Registered growers with authentication credentials and contact info |
monitors |
Physical IoT monitoring devices with unique API keys |
users_monitors |
Junction table — links users to their assigned monitors |
records |
Time-series capture events: dehydration score + image path |
alerts |
Alert events triggered when dehydration_score drops below the health threshold |
Stores registered application users (growers).
| Column | Type | Constraints | Description |
|---|---|---|---|
user_id |
SERIAL |
PRIMARY KEY |
Auto-incrementing unique identifier |
email |
TEXT |
NOT NULL, UNIQUE
|
Login email address; also used for email notifications |
username |
TEXT |
NOT NULL, UNIQUE
|
Human-readable display name |
password |
TEXT |
NOT NULL |
BCrypt password hash — never stored in plain text |
first_name |
TEXT |
NOT NULL |
User's first name |
last_name |
TEXT |
NOT NULL |
User's last name |
phone_number |
TEXT |
nullable | Phone number for SMS notifications |
Represents a physical Cut Flowers monitoring device deployed in the field.
| Column | Type | Constraints | Description |
|---|---|---|---|
monitor_id |
SERIAL |
PRIMARY KEY |
Auto-incrementing unique identifier |
name |
TEXT |
NOT NULL |
Human-readable label (e.g., "Greenhouse A-1") |
api_key |
TEXT |
NOT NULL, UNIQUE
|
Secret key used by the device to authenticate API requests |
Index: idx_monitors_api_key ON monitors(api_key) — speeds up device authentication lookups.
Junction table implementing the many-to-many relationship between users and monitors.
| Column | Type | Constraints | Description |
|---|---|---|---|
monitor_id |
INTEGER |
NOT NULL, FK → monitors.monitor_id, CASCADE
|
References the assigned monitor |
user_id |
INTEGER |
NOT NULL, FK → users.user_id, CASCADE
|
References the owning user |
Primary Key: (monitor_id, user_id) — composite key prevents duplicate assignments.
Index: idx_users_monitors_user_id ON users_monitors(user_id) — speeds up user-to-monitor lookups.
Note: The schema supports multiple users per monitor, but single-tenancy is enforced at the application level — in production, each monitor is assigned to exactly one grower.
Stores time-series data generated by monitors. Each row corresponds to a single image capture event.
| Column | Type | Constraints | Description |
|---|---|---|---|
record_id |
SERIAL |
PRIMARY KEY |
Auto-incrementing unique identifier |
monitor_id |
INTEGER |
NOT NULL, FK → monitors.monitor_id, CASCADE
|
The device that produced this record |
time |
TIMESTAMPTZ |
NOT NULL |
Timestamp of the capture event (timezone-aware) |
dehydration_score |
DOUBLE PRECISION |
NOT NULL |
AI model output: 0.0 (fully dehydrated) to 1.0 (fully healthy). Alerts trigger when score < 0.80 (i.e., score > 0.20 risk). Only scores from predictions with >90% model confidence are stored. |
file_path |
TEXT |
NOT NULL |
Server-side path to the stored image (e.g., /imgs/1/2026-01-15T07-00-00.000Z.jpg) |
Unique constraint: (monitor_id, time) — prevents duplicate entries for the same device at the same timestamp.
Index: idx_records_time ON records(time) — improves performance on time-range queries for dashboard history views.
Stores alert events triggered when a record's dehydration_score falls below the health threshold.
| Column | Type | Constraints | Description |
|---|---|---|---|
alert_id |
SERIAL |
PRIMARY KEY |
Auto-incrementing unique identifier |
record_id |
INTEGER |
NOT NULL, FK → records.record_id, CASCADE
|
The capture event that triggered this alert |
alert_type |
TEXT |
NOT NULL |
Human-readable label for the alert (e.g., "Critical Dehydration", "Warning Dehydration") |
alert_method |
alert_method |
NOT NULL |
Delivery channel — ENUM: 'email' or 'sms'
|
triggered_at |
TIMESTAMPTZ |
DEFAULT NOW() |
Timestamp when the alert was created |
ENUM type: CREATE TYPE alert_method AS ENUM ('email', 'sms') — enforced at the database level.
users ──< users_monitors >── monitors ──< records ──< alerts
| Relationship | Cardinality | Foreign Key |
|---|---|---|
users → users_monitors
|
One-to-many | users_monitors.user_id → users.user_id |
monitors → users_monitors
|
One-to-many | users_monitors.monitor_id → monitors.monitor_id |
monitors → records
|
One-to-many | records.monitor_id → monitors.monitor_id |
records → alerts
|
One-to-many | alerts.record_id → records.record_id |
All foreign keys use ON DELETE CASCADE — deleting a parent row automatically removes all child rows.
1. ESP32 device captures an image (every 10 min, 6:00 AM – 8:00 PM EST)
2. AI model calculates dehydration_score (only stored if model confidence > 90%)
3. Device sends image + metadata to backend via HTTPS POST
4. Backend validates api_key from request body
5. Record is inserted into the records table
6. If dehydration_score < 0.80, an alert row is created in alerts
7. Notification is dispatched via email or SMS within 2–5 minutes
1. User logs in — backend validates credentials, issues JWT (stored in HTTP-only cookie)
2. User requests monitor data
3. Backend checks users_monitors to confirm authorization
4. Records are fetched (up to 4-week history) and returned to the dashboard
1. Record inserted with dehydration_score
2. Backend evaluates score against threshold (< 0.80)
3. If threshold exceeded → alert row created in alerts table
4. Notification sent via user's preferred method (email or SMS)
- PostgreSQL 13 or higher
- Node.js (for backend integration)
-
psqlcommand-line client
# 1. Create the database and apply the schema
psql -U postgres -f CutFlowerDb_F.sql
# 2. (Optional) Load sample data for development/testing
psql -U postgres -f SampleData.sqlThe schema file drops and recreates the
flowersdatabase, so run it only once or in a clean environment.
Add the following to your .env file:
DB_NAME=flowers
DB_USER=flowers
DB_PASSWORD=yourpassword
DB_HOST=localhost
DB_PORT=5432All database access is centralized in data_access.js. It uses a pg connection pool (max 20 clients, 30s idle timeout) and exposes named functions for every database operation. All functions are async and return null or [] on error rather than throwing — errors are logged to console.error.
These two functions underpin all other operations:
| Function | Signature | Description |
|---|---|---|
getData |
(table, columns, criteria, limit, orderBy) |
Flexible parameterized SELECT — supports column lists, WHERE, ORDER BY, and LIMIT
|
insertData |
(table, data) |
Dynamic parameterized INSERT ... RETURNING * — builds column/value lists from an object |
| Function | Signature | Description |
|---|---|---|
addRecord |
(monitorID, value, imgName) |
Inserts a new capture record; clamps dehydration_score to 0.0–1.0 and validates it is finite |
getPastRecords |
(monitorID, limit) |
Returns the most recent N records for a monitor, reversed to chronological order for chart display |
getRecordById |
(recordID) |
Returns a single record row by primary key |
getRecordsInRange |
(monitorID, startTime, endTime) |
Returns all records for a monitor within a time range, ordered chronologically |
countRecordsInRange |
(monitorID, startTime, endTime) |
Returns the count of records for a monitor within a time range |
getHourlyAverageRecordsInRange |
(monitorID, startTime, endTime) |
Returns hourly aggregates (avg, min, max, sample count) for a monitor within a time range; excludes zero scores from averages |
| Function | Signature | Description |
|---|---|---|
addAlert |
(recordID, alertType, alertMethod) |
Inserts a new alert row linked to a record |
hasRecentAlert |
(monitorID, alertType, alertMethod, cooldownHours) |
Returns true if an alert of the same type/method was already sent within the cooldown window (default 24h) — used to prevent duplicate notifications |
| Function | Signature | Description |
|---|---|---|
monitorExists |
(monitorID) |
Returns true if the monitor ID exists in the monitors table |
getMonitorByApiKey |
(apiKey) |
Returns the monitor row matching a device API key; used to authenticate incoming device uploads |
createMonitor |
(name, apiKey) |
Inserts a new monitor with a provided API key |
getMonitors |
(userID) |
Returns all monitors associated with a user via users_monitors
|
associateUserToMonitor |
(userID, monitorID) |
Links a user to a monitor in users_monitors; silently skips if the association already exists |
| Function | Signature | Description |
|---|---|---|
createUser |
(email, username, passwordHash, firstName, lastName, phoneNumber) |
Inserts a new user; phone_number is omitted from the insert if not provided |
getUserByUsername |
(username) |
Returns a user row by username; used during login |
getUserByEmail |
(email) |
Returns a user row by email address |
getUserById |
(userId) |
Returns a user row by ID — excludes password for security |
updateUserSettings |
(userId, settingsPatch) |
Merges a partial settings object into the user's settings JSONB column using PostgreSQL ` |
userCanAccessMonitor |
(userID, monitorID) |
Returns true if a users_monitors row exists for the given user/monitor pair |
| Function | Signature | Description |
|---|---|---|
getMonitorUserEmails |
(monitorID) |
Returns all email addresses of users assigned to a monitor where notifications.enabled is true (defaults to true if unset) |
isMonitorEmailNotificationEnabled |
(monitorID, email) |
Returns true if a specific recipient has notifications enabled for a given monitor |
getRecentAlertsForUser |
(userID, limit) |
Returns the most recent alerts (up to 100) for all monitors a user has access to, joined with record and monitor details |
- Passwords are hashed using BCrypt — no plain-text passwords are stored at any point
- Sessions are managed via JWT tokens stored in HTTP-only cookies
- Tokens are validated on every protected API request
- Each monitor has a unique
api_keystored in themonitorstable - The backend validates the
api_keyfrom the request body before accepting any data upload - The
idx_monitors_api_keyindex ensures fast key lookups under load
- Images are stored in a non-publicly served directory with restricted UNIX permissions
- All traffic is secured via HTTPS/TLS
- The system does not interface with the database root user
pg_dump flowers > backup_$(date +%Y%m%d).sqlRun this on a regular schedule (e.g., via cron). Store backups off-server.
psql -U postgres -f backup_YYYYMMDD.sql-
PRIMARY KEYconstraints ensure row uniqueness across all tables -
FOREIGN KEYconstraints withCASCADEenforce relational integrity -
UNIQUE (monitor_id, time)onrecordsprevents duplicate capture entries -
UNIQUEonmonitors.api_keyprevents key collisions
| Index | Table | Purpose |
|---|---|---|
idx_records_time |
records |
Speeds up 4-week history queries on the dashboard |
idx_monitors_api_key |
monitors |
Speeds up device authentication on every upload |
idx_users_monitors_user_id |
users_monitors |
Speeds up authorization checks per user |
- Real-time data streaming — WebSocket or SSE support for live dashboard updates
- Advanced alerting rules — configurable per-user thresholds and quiet hours
- Data archiving — automated archival of records older than the 4-week display window
- Role-based access control — Admin vs. Grower role enforcement at the database level
- Multi-tenancy — shared dashboards or organization-level device grouping
DROP DATABASE IF EXISTS flowers WITH (FORCE);
CREATE DATABASE flowers;
ALTER DATABASE flowers OWNER TO flowers;
\c flowers;
-- USERS
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone_number TEXT
);
-- MONITORS
CREATE TABLE monitors (
monitor_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE
);
CREATE INDEX idx_monitors_api_key ON monitors(api_key);
-- USERS_MONITORS (Many-to-Many)
CREATE TABLE users_monitors (
monitor_id INTEGER NOT NULL REFERENCES monitors(monitor_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, user_id)
);
CREATE INDEX idx_users_monitors_user_id ON users_monitors(user_id);
-- RECORDS
CREATE TABLE records (
record_id SERIAL PRIMARY KEY,
monitor_id INTEGER NOT NULL REFERENCES monitors(monitor_id) ON DELETE CASCADE,
time TIMESTAMPTZ NOT NULL,
dehydration_score DOUBLE PRECISION NOT NULL,
file_path TEXT NOT NULL,
UNIQUE (monitor_id, time)
);
CREATE INDEX idx_records_time ON records(time);
-- ALERT METHOD ENUM
CREATE TYPE alert_method AS ENUM ('email', 'sms');
-- ALERTS
CREATE TABLE alerts (
alert_id SERIAL PRIMARY KEY,
record_id INTEGER NOT NULL REFERENCES records(record_id) ON DELETE CASCADE,
alert_type TEXT NOT NULL,
alert_method alert_method NOT NULL,
triggered_at TIMESTAMPTZ DEFAULT NOW()
);The sample data file seeds three devices with distinct behavior profiles for development and testing:
| Device | monitor_id |
Behavior | Score Range |
|---|---|---|---|
healthy_device |
1 | Consistently healthy | 0.88 – 0.94 |
dehydrating_device |
2 | Progressive dehydration | 0.03 – 0.45 |
warning_device |
3 | Concerning decline | 0.29 – 0.65 |
Two users are seeded (green_valley, sunrise_farms). Two sample alerts are included — one critical, one warning — to verify the alerting pipeline.
# Load sample data (after schema setup)
psql -U postgres -f SampleData.sqlPasswords in the sample data are BCrypt hashes. Do not use sample credentials in production.
Last updated: April 2026 | Team Thunder Bay | ISTE 501