-- ============================================================================ -- Water Project Database Schema -- PostgreSQL Migration Script -- ============================================================================ -- Enable required extensions CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================================================ -- TRIGGER FUNCTION: Auto-update updated_at timestamp -- ============================================================================ CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- ENUM TYPES -- ============================================================================ CREATE TYPE role_name AS ENUM ('ADMIN', 'OPERATOR', 'VIEWER'); CREATE TYPE project_status AS ENUM ('ACTIVE', 'INACTIVE', 'COMPLETED'); CREATE TYPE device_status AS ENUM ('ACTIVE', 'INACTIVE', 'OFFLINE', 'MAINTENANCE', 'ERROR'); CREATE TYPE meter_type AS ENUM ('WATER', 'GAS', 'ELECTRIC'); CREATE TYPE reading_type AS ENUM ('AUTOMATIC', 'MANUAL', 'SCHEDULED'); -- ============================================================================ -- TABLE 1: roles -- ============================================================================ CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name role_name NOT NULL UNIQUE, description TEXT, permissions JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_roles_name ON roles(name); CREATE TRIGGER trigger_roles_updated_at BEFORE UPDATE ON roles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE roles IS 'User roles with associated permissions'; -- ============================================================================ -- TABLE 2: users -- ============================================================================ CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, avatar_url TEXT, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, is_active BOOLEAN NOT NULL DEFAULT TRUE, last_login TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_role_id ON users(role_id); CREATE INDEX idx_users_is_active ON users(is_active); CREATE TRIGGER trigger_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE users IS 'Application users with authentication credentials'; -- ============================================================================ -- TABLE 3: projects -- ============================================================================ CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, description TEXT, area_name VARCHAR(255), location TEXT, status project_status NOT NULL DEFAULT 'ACTIVE', created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_projects_status ON projects(status); CREATE INDEX idx_projects_created_by ON projects(created_by); CREATE INDEX idx_projects_name ON projects(name); CREATE TRIGGER trigger_projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE projects IS 'Water monitoring projects'; -- ============================================================================ -- TABLE 4: concentrators -- ============================================================================ CREATE TABLE concentrators ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), serial_number VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, location TEXT, status device_status NOT NULL DEFAULT 'ACTIVE', ip_address INET, firmware_version VARCHAR(50), last_communication TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_concentrators_serial_number ON concentrators(serial_number); CREATE INDEX idx_concentrators_project_id ON concentrators(project_id); CREATE INDEX idx_concentrators_status ON concentrators(status); CREATE TRIGGER trigger_concentrators_updated_at BEFORE UPDATE ON concentrators FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE concentrators IS 'Data concentrators that aggregate gateway communications'; -- ============================================================================ -- TABLE 5: gateways -- ============================================================================ CREATE TABLE gateways ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), gateway_id VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, concentrator_id UUID REFERENCES concentrators(id) ON DELETE SET NULL, location TEXT, status device_status NOT NULL DEFAULT 'ACTIVE', tts_gateway_id VARCHAR(255), tts_status VARCHAR(50), tts_last_seen TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_gateways_gateway_id ON gateways(gateway_id); CREATE INDEX idx_gateways_project_id ON gateways(project_id); CREATE INDEX idx_gateways_concentrator_id ON gateways(concentrator_id); CREATE INDEX idx_gateways_status ON gateways(status); CREATE TRIGGER trigger_gateways_updated_at BEFORE UPDATE ON gateways FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE gateways IS 'LoRaWAN gateways for device communication'; -- ============================================================================ -- TABLE 6: devices -- ============================================================================ CREATE TABLE devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), dev_eui VARCHAR(16) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, device_type VARCHAR(100), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, gateway_id UUID REFERENCES gateways(id) ON DELETE SET NULL, status device_status NOT NULL DEFAULT 'ACTIVE', tts_device_id VARCHAR(255), tts_status VARCHAR(50), tts_last_seen TIMESTAMP WITH TIME ZONE, app_key VARCHAR(32), join_eui VARCHAR(16), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_devices_dev_eui ON devices(dev_eui); CREATE INDEX idx_devices_project_id ON devices(project_id); CREATE INDEX idx_devices_gateway_id ON devices(gateway_id); CREATE INDEX idx_devices_status ON devices(status); CREATE INDEX idx_devices_device_type ON devices(device_type); CREATE TRIGGER trigger_devices_updated_at BEFORE UPDATE ON devices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE devices IS 'LoRaWAN end devices (sensors/transmitters)'; -- ============================================================================ -- TABLE 7: meters -- ============================================================================ CREATE TABLE meters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), serial_number VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, device_id UUID REFERENCES devices(id) ON DELETE SET NULL, area_name VARCHAR(255), location TEXT, meter_type meter_type NOT NULL DEFAULT 'WATER', status device_status NOT NULL DEFAULT 'ACTIVE', last_reading_value NUMERIC(15, 4), last_reading_at TIMESTAMP WITH TIME ZONE, installation_date DATE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_meters_serial_number ON meters(serial_number); CREATE INDEX idx_meters_project_id ON meters(project_id); CREATE INDEX idx_meters_device_id ON meters(device_id); CREATE INDEX idx_meters_status ON meters(status); CREATE INDEX idx_meters_meter_type ON meters(meter_type); CREATE INDEX idx_meters_area_name ON meters(area_name); CREATE TRIGGER trigger_meters_updated_at BEFORE UPDATE ON meters FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMENT ON TABLE meters IS 'Physical water meters associated with devices'; -- ============================================================================ -- TABLE 8: meter_readings -- ============================================================================ CREATE TABLE meter_readings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), meter_id UUID NOT NULL REFERENCES meters(id) ON DELETE CASCADE, device_id UUID REFERENCES devices(id) ON DELETE SET NULL, reading_value NUMERIC(15, 4) NOT NULL, reading_type reading_type NOT NULL DEFAULT 'AUTOMATIC', battery_level SMALLINT, signal_strength SMALLINT, raw_payload TEXT, received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_meter_readings_meter_id ON meter_readings(meter_id); CREATE INDEX idx_meter_readings_device_id ON meter_readings(device_id); CREATE INDEX idx_meter_readings_received_at ON meter_readings(received_at); CREATE INDEX idx_meter_readings_meter_id_received_at ON meter_readings(meter_id, received_at DESC); COMMENT ON TABLE meter_readings IS 'Historical meter reading values'; -- ============================================================================ -- TABLE 9: tts_uplink_logs -- ============================================================================ CREATE TABLE tts_uplink_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID REFERENCES devices(id) ON DELETE SET NULL, dev_eui VARCHAR(16) NOT NULL, raw_payload JSONB NOT NULL, decoded_payload JSONB, gateway_ids TEXT[], rssi SMALLINT, snr NUMERIC(5, 2), processed BOOLEAN NOT NULL DEFAULT FALSE, error_message TEXT, received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_tts_uplink_logs_device_id ON tts_uplink_logs(device_id); CREATE INDEX idx_tts_uplink_logs_dev_eui ON tts_uplink_logs(dev_eui); CREATE INDEX idx_tts_uplink_logs_received_at ON tts_uplink_logs(received_at); CREATE INDEX idx_tts_uplink_logs_processed ON tts_uplink_logs(processed); CREATE INDEX idx_tts_uplink_logs_raw_payload ON tts_uplink_logs USING GIN (raw_payload); COMMENT ON TABLE tts_uplink_logs IS 'The Things Stack uplink message logs'; -- ============================================================================ -- TABLE 10: refresh_tokens -- ============================================================================ CREATE TABLE refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at TIMESTAMP WITH TIME ZONE ); CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for user sessions'; -- ============================================================================ -- VIEW: meter_stats_by_project -- ============================================================================ CREATE OR REPLACE VIEW meter_stats_by_project AS SELECT p.id AS project_id, p.name AS project_name, p.status AS project_status, COUNT(m.id) AS total_meters, COUNT(CASE WHEN m.status = 'ACTIVE' THEN 1 END) AS active_meters, COUNT(CASE WHEN m.status = 'INACTIVE' THEN 1 END) AS inactive_meters, COUNT(CASE WHEN m.status = 'OFFLINE' THEN 1 END) AS offline_meters, COUNT(CASE WHEN m.status = 'MAINTENANCE' THEN 1 END) AS maintenance_meters, COUNT(CASE WHEN m.status = 'ERROR' THEN 1 END) AS error_meters, ROUND(AVG(m.last_reading_value)::NUMERIC, 2) AS avg_last_reading, MAX(m.last_reading_at) AS most_recent_reading, COUNT(DISTINCT m.area_name) AS unique_areas FROM projects p LEFT JOIN meters m ON p.id = m.project_id GROUP BY p.id, p.name, p.status; COMMENT ON VIEW meter_stats_by_project IS 'Aggregated meter statistics per project'; -- ============================================================================ -- VIEW: device_status_summary -- ============================================================================ CREATE OR REPLACE VIEW device_status_summary AS SELECT p.id AS project_id, p.name AS project_name, 'concentrator' AS device_category, c.status, COUNT(*) AS count FROM projects p LEFT JOIN concentrators c ON p.id = c.project_id WHERE c.id IS NOT NULL GROUP BY p.id, p.name, c.status UNION ALL SELECT p.id AS project_id, p.name AS project_name, 'gateway' AS device_category, g.status, COUNT(*) AS count FROM projects p LEFT JOIN gateways g ON p.id = g.project_id WHERE g.id IS NOT NULL GROUP BY p.id, p.name, g.status UNION ALL SELECT p.id AS project_id, p.name AS project_name, 'device' AS device_category, d.status, COUNT(*) AS count FROM projects p LEFT JOIN devices d ON p.id = d.project_id WHERE d.id IS NOT NULL GROUP BY p.id, p.name, d.status UNION ALL SELECT p.id AS project_id, p.name AS project_name, 'meter' AS device_category, m.status, COUNT(*) AS count FROM projects p LEFT JOIN meters m ON p.id = m.project_id WHERE m.id IS NOT NULL GROUP BY p.id, p.name, m.status; COMMENT ON VIEW device_status_summary IS 'Summary of device statuses across all device types per project'; -- ============================================================================ -- SEED DATA: Default Roles -- ============================================================================ INSERT INTO roles (name, description, permissions) VALUES ( 'ADMIN', 'Full system administrator with all permissions', '{ "users": {"create": true, "read": true, "update": true, "delete": true}, "projects": {"create": true, "read": true, "update": true, "delete": true}, "devices": {"create": true, "read": true, "update": true, "delete": true}, "meters": {"create": true, "read": true, "update": true, "delete": true}, "readings": {"create": true, "read": true, "update": true, "delete": true}, "settings": {"create": true, "read": true, "update": true, "delete": true}, "reports": {"create": true, "read": true, "export": true} }'::JSONB ), ( 'OPERATOR', 'Operator with management permissions but no system settings', '{ "users": {"create": false, "read": true, "update": false, "delete": false}, "projects": {"create": true, "read": true, "update": true, "delete": false}, "devices": {"create": true, "read": true, "update": true, "delete": false}, "meters": {"create": true, "read": true, "update": true, "delete": false}, "readings": {"create": true, "read": true, "update": false, "delete": false}, "settings": {"create": false, "read": true, "update": false, "delete": false}, "reports": {"create": true, "read": true, "export": true} }'::JSONB ), ( 'VIEWER', 'Read-only access to view data and reports', '{ "users": {"create": false, "read": false, "update": false, "delete": false}, "projects": {"create": false, "read": true, "update": false, "delete": false}, "devices": {"create": false, "read": true, "update": false, "delete": false}, "meters": {"create": false, "read": true, "update": false, "delete": false}, "readings": {"create": false, "read": true, "update": false, "delete": false}, "settings": {"create": false, "read": false, "update": false, "delete": false}, "reports": {"create": false, "read": true, "export": false} }'::JSONB ); -- ============================================================================ -- SEED DATA: Default Admin User -- Password: admin123 (bcrypt hashed) -- ============================================================================ INSERT INTO users (email, password_hash, name, role_id, is_active) SELECT 'admin@waterproject.com', '$2b$12$RrlEdRsUiiQYxtUmjOjX.uZU/IpXUFsXsWxDcMny1RUl6RFc.etDm', 'System Administrator', r.id, TRUE FROM roles r WHERE r.name = 'ADMIN'; -- ============================================================================ -- END OF SCHEMA -- ============================================================================