Database Schema
Database Schema
Full PostgreSQL DDL for the Actual data store. All tables use the actual schema.
PostGIS extension is required for the parcels table.
Setup
-- Run once on database creation
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE SCHEMA IF NOT EXISTS actual;
SET search_path = actual, public;
Tables
devices
Registry of all provisioned badges. One row per badge, created at provisioning time.
CREATE TABLE actual.devices (
device_id TEXT PRIMARY KEY,
-- Format: ACT-XXXXXXXX (8 hex chars, uppercase)
-- Assigned at manufacturing/provisioning; immutable
company_id TEXT NOT NULL,
-- Tenant identifier; all queries must filter by this column
notes_device_uid TEXT,
-- Blues Wireless Notecard DeviceUID; used for Notehub provisioning
provisioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'inventory',
-- One of: inventory | deployed | returned | refurbished | retired | rma
current_worker_id TEXT,
-- Denormalized: current shift assignment; updated on shift_start / shift_end
-- NULL when badge is unassigned
firmware_version TEXT,
-- Last seen firmware version from most recent upload
last_seen_at TIMESTAMPTZ,
-- Timestamp of most recent event received from this device
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX devices_company_id_idx ON actual.devices (company_id);
CREATE INDEX devices_status_idx ON actual.devices (status);
equipment_tags
Registry of BLE equipment tags, mapped to customer equipment.
CREATE TABLE actual.equipment_tags (
tag_mac TEXT PRIMARY KEY,
-- Bluetooth MAC address; format: 'AA:BB:CC:DD:EE:FF' (uppercase)
company_id TEXT NOT NULL,
equipment_name TEXT NOT NULL,
-- Human-readable name; e.g. 'Truck 03', 'Zero-Turn Mower 1'
equipment_type TEXT,
-- e.g. 'truck', 'trailer', 'mower', 'other'
status TEXT NOT NULL DEFAULT 'deployed',
-- One of: inventory | deployed | retired
deployed_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX equipment_tags_company_id_idx ON actual.equipment_tags (company_id);
workers
Registry of worker IDs per customer. Worker IDs are operator-assigned strings.
CREATE TABLE actual.workers (
id BIGSERIAL PRIMARY KEY,
company_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
-- Operator-assigned identifier; must be unique per company
-- Examples: 'W-042', 'jsmith', 'Crew Lead 1'
display_name TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (company_id, worker_id)
);
CREATE INDEX workers_company_id_idx ON actual.workers (company_id);
parcels
Polygon geometries for serviceable properties. Loaded from county GIS parcel data. PostGIS is required.
CREATE TABLE actual.parcels (
parcel_id TEXT NOT NULL,
-- County assessor parcel ID; format varies by county
company_id TEXT NOT NULL,
-- A parcel is associated with a specific customer tenant
-- (a customer may service only a subset of parcels in a county)
geom GEOMETRY(POLYGON, 4326) NOT NULL,
-- WGS84 geographic coordinates
-- SRID 4326 must match GPS coordinates in raw_events
address TEXT,
county TEXT,
state TEXT DEFAULT 'GA',
customer_label TEXT,
-- Optional: customer's own name for this property (e.g. 'HOA - North Campus')
active BOOLEAN NOT NULL DEFAULT TRUE,
loaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (parcel_id, company_id)
);
-- Spatial index — required for point-in-polygon query performance
CREATE INDEX parcels_geom_idx ON actual.parcels USING GIST (geom);
CREATE INDEX parcels_company_id_idx ON actual.parcels (company_id);
raw_events
Append-only store for all events received from badges exactly as received. No transformation. No deletion.
CREATE TABLE actual.raw_events (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES actual.devices (device_id),
company_id TEXT NOT NULL,
-- Denormalized from devices table for query efficiency
firmware_version TEXT NOT NULL,
event_type TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
-- Timestamp as reported by the badge (not the server receive time)
sequence_number BIGINT NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Server-side receive time; used for upload latency tracking
UNIQUE (device_id, sequence_number)
-- Deduplication constraint; ON CONFLICT DO NOTHING on insert
);
CREATE INDEX raw_events_device_id_idx ON actual.raw_events (device_id);
CREATE INDEX raw_events_company_id_idx ON actual.raw_events (company_id);
CREATE INDEX raw_events_event_type_idx ON actual.raw_events (event_type);
CREATE INDEX raw_events_event_timestamp_idx ON actual.raw_events (event_timestamp);
-- Partial index for unprocessed GPS events (geofencing job target)
CREATE INDEX raw_events_unbound_gps_idx ON actual.raw_events (id)
WHERE event_type = 'gps' AND id NOT IN (
SELECT raw_event_id FROM actual.canonical_events
WHERE raw_event_id IS NOT NULL
);
-- Note: replace with a processed_at column approach at scale (partial index
-- re-computation is expensive on large tables)
canonical_events
Processed events: GPS events bound to parcels, or marked as unbound. One canonical event per raw event that requires binding.
CREATE TABLE actual.canonical_events (
id BIGSERIAL PRIMARY KEY,
raw_event_id BIGINT REFERENCES actual.raw_events (id),
device_id TEXT NOT NULL,
company_id TEXT NOT NULL,
worker_id TEXT,
-- Populated by resolving the active shift assignment at event_timestamp
event_type TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
parcel_id TEXT,
-- NULL if event falls outside all known parcels (unbound)
unbound BOOLEAN NOT NULL DEFAULT FALSE,
-- TRUE when parcel_id IS NULL and binding was attempted
payload JSONB NOT NULL,
bound_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX canonical_events_device_id_idx ON actual.canonical_events (device_id);
CREATE INDEX canonical_events_company_id_idx ON actual.canonical_events (company_id);
CREATE INDEX canonical_events_worker_id_idx ON actual.canonical_events (worker_id);
CREATE INDEX canonical_events_parcel_id_idx ON actual.canonical_events (parcel_id);
CREATE INDEX canonical_events_event_timestamp_idx ON actual.canonical_events (event_timestamp);
work_sessions
Materialized view that aggregates canonical events into human-readable work sessions. Refreshed on a schedule (every 30 minutes) or on demand.
CREATE MATERIALIZED VIEW actual.work_sessions AS
WITH session_bounds AS (
-- Detect parcel entry and exit events by looking at consecutive GPS events
-- A session starts when parcel_id is non-null after a null or different parcel_id
-- A session ends when parcel_id changes or a shift_end event is received
SELECT
ce.company_id,
ce.worker_id,
ce.parcel_id,
ce.device_id,
MIN(ce.event_timestamp) AS session_start,
MAX(ce.event_timestamp) AS session_end,
EXTRACT(EPOCH FROM (MAX(ce.event_timestamp) - MIN(ce.event_timestamp))) / 60
AS duration_minutes
FROM actual.canonical_events ce
WHERE ce.event_type = 'gps'
AND ce.parcel_id IS NOT NULL
AND ce.worker_id IS NOT NULL
GROUP BY
ce.company_id,
ce.worker_id,
ce.parcel_id,
ce.device_id,
-- Session grouping: consecutive GPS events within 20 minutes are the same session
-- (gap-based session detection; refine with window functions at scale)
DATE_TRUNC('hour', ce.event_timestamp)
-- NOTE: this is a simplified grouping. The cloud engineer must replace
-- this with proper gap-detection using LAG() window functions.
),
equipment_presence AS (
SELECT
ce.device_id,
ce.worker_id,
DATE_TRUNC('hour', ce.event_timestamp) AS hour_bucket,
ARRAY_AGG(DISTINCT et.equipment_name) AS equipment_detected
FROM actual.canonical_events ce
JOIN actual.equipment_tags et
ON et.tag_mac = ce.payload->>'tag_mac'
AND et.company_id = ce.company_id
WHERE ce.event_type = 'ble_proximity'
AND ce.worker_id IS NOT NULL
GROUP BY ce.device_id, ce.worker_id, DATE_TRUNC('hour', ce.event_timestamp)
)
SELECT
sb.company_id,
sb.worker_id,
sb.parcel_id,
sb.device_id,
sb.session_start,
sb.session_end,
sb.duration_minutes,
COALESCE(ep.equipment_detected, ARRAY[]::TEXT[]) AS equipment_present,
(ep.equipment_detected IS NOT NULL) AS equipment_verified
FROM session_bounds sb
LEFT JOIN equipment_presence ep
ON ep.device_id = sb.device_id
AND ep.worker_id = sb.worker_id
AND ep.hour_bucket = DATE_TRUNC('hour', sb.session_start);
-- Refresh index
CREATE UNIQUE INDEX work_sessions_unique_idx
ON actual.work_sessions (company_id, worker_id, parcel_id, session_start);
Note to cloud engineer: The session grouping in this view uses a simplified
DATE_TRUNC approach. Replace with proper LAG()-based gap detection before the
first paying customer is live. See the cloud architecture doc for the gap-detection
pattern.
Indexes Summary
| Table | Index | Type | Purpose |
|---|---|---|---|
| devices | company_id | B-tree | Tenant isolation |
| equipment_tags | company_id | B-tree | Tenant isolation |
| parcels | geom | GIST | Point-in-polygon (PostGIS) |
| parcels | company_id | B-tree | Tenant isolation |
| raw_events | device_id | B-tree | Per-device queries |
| raw_events | company_id | B-tree | Tenant isolation |
| raw_events | event_timestamp | B-tree | Time-range queries |
| raw_events | (device_id, seq_num) | Unique | Deduplication constraint |
| canonical_events | worker_id | B-tree | Worker-level reporting |
| canonical_events | parcel_id | B-tree | Property-level reporting |
| canonical_events | event_timestamp | B-tree | Time-range queries |
Multi-Tenant Design Notes
Every table that contains customer data includes company_id. Every application
query must include a WHERE company_id = $1 clause. There is no database-level
row-level security (RLS) at prototype; this must be added before the cloud engineer
considers the platform production-ready.
Add RLS policies when the customer count exceeds 5:
-- Example RLS policy on raw_events
ALTER TABLE actual.raw_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON actual.raw_events
USING (company_id = current_setting('app.current_company_id'));
The application sets app.current_company_id at the start of each request.