Database Schema Reference
This document provides a complete reference for Visita’s Supabase PostgreSQL database schema, including tables, views, relationships, and common queries.Database: PostgreSQL 15+ with PostGIS extension
Platform: Supabase
Generated Types: Available in
types/supabase-generated.tsCore Tables
wards - Ward Boundaries & Metadata
Description: Geographic ward boundaries and basic information.
Copy
CREATE TABLE wards (
ward_code TEXT PRIMARY KEY, -- Format: WARD001, WARD002, etc.
name TEXT NOT NULL, -- "Ward 1 - Johannesburg Central"
municipality TEXT,
province TEXT,
geom GEOMETRY(POLYGON, 4326) NOT NULL, -- PostGIS geometry
centroid GEOMETRY(POINT, 4326), -- Pre-computed center point
area_sq_km DECIMAL(10, 2),
population INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_wards_geom ON wards USING GIST(geom);
CREATE INDEX idx_wards_centroid ON wards USING GIST(centroid);
CREATE INDEX idx_wards_municipality ON wards(municipality);
Copy
-- Find ward by location
SELECT ward_code, name
FROM wards
WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint(28.0473, -26.2041), 4326));
-- Get wards within radius
SELECT ward_code, name,
ST_Distance(centroid, ST_SetSRID(ST_MakePoint(28.0473, -26.2041), 4326)::geography) / 1000 as distance_km
FROM wards
WHERE ST_DWithin(centroid, ST_SetSRID(ST_MakePoint(28.0473, -26.2041), 4326)::geography, 5000)
ORDER BY distance_km;
profiles - User Profiles
Description: Extended user information beyond Supabase Auth.
Copy
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
full_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'citizen', -- citizen, business, analyst, admin
ward_code TEXT REFERENCES wards(ward_code),
bio TEXT,
website TEXT,
twitter TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row Level Security
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE USING (auth.uid() = id);
-- Indexes
CREATE INDEX idx_profiles_ward ON profiles(ward_code);
CREATE INDEX idx_profiles_role ON profiles(role);
businesses - Business Directory
Description: Local business listings with geospatial data.
Copy
CREATE TABLE businesses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE, -- URL-friendly name
description TEXT,
category TEXT, -- Food & Beverage, Retail, etc.
subcategory TEXT,
ward_code TEXT REFERENCES wards(ward_code),
location GEOMETRY(POINT, 4326) NOT NULL,
address TEXT,
contact_phone TEXT,
contact_email TEXT,
website TEXT,
social_facebook TEXT,
social_instagram TEXT,
opening_hours JSONB, -- {"monday": "08:00-18:00", ...}
photos TEXT[], -- Array of image URLs
verified BOOLEAN DEFAULT FALSE,
claimed_by UUID REFERENCES profiles(id),
rating DECIMAL(3, 2),
review_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_businesses_ward ON businesses(ward_code);
CREATE INDEX idx_businesses_location ON businesses USING GIST(location);
CREATE INDEX idx_businesses_category ON businesses(category);
CREATE INDEX idx_businesses_verified ON businesses(verified) WHERE verified = TRUE;
CREATE INDEX idx_businesses_slug ON businesses(slug);
-- Full-text search
ALTER TABLE businesses ADD COLUMN search_vector tsvector;
CREATE INDEX idx_businesses_search ON businesses USING GIN(search_vector);
Copy
-- Find businesses near location
SELECT id, name, category,
ST_Distance(location::geography, ST_SetSRID(ST_MakePoint(28.0473, -26.2041), 4326)::geography) as distance_m
FROM businesses
WHERE ST_DWithin(location::geography, ST_SetSRID(ST_MakePoint(28.0473, -26.2041), 4326)::geography, 2000)
AND verified = TRUE
ORDER BY distance_m;
-- Search businesses by name/description
SELECT id, name, description, category
FROM businesses
WHERE search_vector @@ plainto_tsquery('english', 'coffee shop')
ORDER BY ts_rank(search_vector, plainto_tsquery('english', 'coffee shop')) DESC;
business_service_areas - Business Service Areas
Description: Many-to-many relationship for businesses serving multiple wards.
Copy
CREATE TABLE business_service_areas (
business_id UUID REFERENCES businesses(id) ON DELETE CASCADE,
ward_code TEXT REFERENCES wards(ward_code),
service_type TEXT DEFAULT 'primary', -- primary, secondary, delivery
PRIMARY KEY (business_id, ward_code)
);
CREATE INDEX idx_business_service_wards ON business_service_areas(ward_code);
signals - Ward Signals & Alerts
Description: User-generated signals (alerts, incidents, updates).
Copy
CREATE TABLE signals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL, -- safety, community, weather, infrastructure
title TEXT NOT NULL,
description TEXT,
severity TEXT DEFAULT 'low', -- low, medium, high, critical
ward_code TEXT REFERENCES wards(ward_code),
location GEOMETRY(POINT, 4326),
creator_id UUID REFERENCES profiles(id),
source TEXT DEFAULT 'user', -- user, system, official
corroborations INTEGER DEFAULT 0,
status TEXT DEFAULT 'active', -- active, verified, resolved, closed
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX idx_signals_ward ON signals(ward_code);
CREATE INDEX idx_signals_type ON signals(type);
CREATE INDEX idx_signals_status ON signals(status);
CREATE INDEX idx_signals_created ON signals(created_at DESC);
CREATE INDEX idx_signals_location ON signals USING GIST(location);
-- RLS Policies
ALTER TABLE signals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view signals" ON signals
FOR SELECT USING (true);
CREATE POLICY "Users can create signals" ON signals
FOR INSERT WITH CHECK (auth.uid() = creator_id);
action_projects - Community Action Projects
Description: Community-driven projects and initiatives.
Copy
CREATE TABLE action_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
ward_code TEXT REFERENCES wards(ward_code),
category TEXT, -- environment, safety, infrastructure, etc.
status TEXT DEFAULT 'planned', -- planned, active, completed, cancelled
progress INTEGER DEFAULT 0, -- 0-100 percentage
creator_id UUID REFERENCES profiles(id),
participant_count INTEGER DEFAULT 0,
target_amount DECIMAL(12, 2), -- Fundraising target
currency TEXT DEFAULT 'ZAR',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX idx_projects_ward ON action_projects(ward_code);
CREATE INDEX idx_projects_status ON action_projects(status);
CREATE INDEX idx_projects_category ON action_projects(category);
CREATE INDEX idx_projects_creator ON action_projects(creator_id);
project_milestones - Project Milestones
Description: Milestones and tasks for community projects.
Copy
CREATE TABLE project_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES action_projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'pending', -- pending, in_progress, completed
due_date TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
completed_by UUID REFERENCES profiles(id),
order_index INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_milestones_project ON project_milestones(project_id);
CREATE INDEX idx_milestones_status ON project_milestones(status);
governance_topics - Governance Discussion Topics
Description: Ward-level governance discussions and consensus building.
Copy
CREATE TABLE governance_topics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ward_code TEXT REFERENCES wards(ward_code),
title TEXT NOT NULL,
description TEXT,
type TEXT, -- budget, policy, infrastructure, etc.
status TEXT DEFAULT 'open', -- open, closed, archived
creator_id UUID REFERENCES profiles(id),
consensus_score DECIMAL(5, 2), -- 0-100 consensus percentage
participant_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
closes_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX idx_topics_ward ON governance_topics(ward_code);
CREATE INDEX idx_topics_status ON governance_topics(status);
CREATE INDEX idx_topics_type ON governance_topics(type);
governance_statements - Topic Statements
Description: Individual statements/positions in governance discussions.
Copy
CREATE TABLE governance_statements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic_id UUID REFERENCES governance_topics(id) ON DELETE CASCADE,
author_id UUID REFERENCES profiles(id),
content TEXT NOT NULL,
order_index INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_statements_topic ON governance_statements(topic_id);
CREATE INDEX idx_statements_author ON governance_statements(author_id);
governance_votes - Statement Voting
Description: User votes on governance statements.
Copy
CREATE TABLE governance_votes (
statement_id UUID REFERENCES governance_statements(id) ON DELETE CASCADE,
voter_id UUID REFERENCES profiles(id),
vote_type TEXT NOT NULL, -- agree, disagree, abstain
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (statement_id, voter_id)
);
CREATE INDEX idx_votes_statement ON governance_votes(statement_id);
CREATE INDEX idx_votes_voter ON governance_votes(voter_id);
donations - Ward Donations
Description: Donations to wards and community projects.
Copy
CREATE TABLE donations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
amount DECIMAL(12, 2) NOT NULL,
currency TEXT DEFAULT 'ZAR',
donor_id UUID REFERENCES profiles(id),
recipient_type TEXT NOT NULL, -- ward, project
recipient_id TEXT NOT NULL, -- ward_code or project_id
payment_method TEXT, -- paystack, stripe, etc.
payment_id TEXT, -- External payment provider ID
status TEXT DEFAULT 'pending', -- pending, succeeded, failed, refunded
purpose TEXT, -- general, specific_project, emergency
reference TEXT, -- Custom reference
created_at TIMESTAMPTZ DEFAULT NOW(),
succeeded_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX idx_donations_donor ON donations(donor_id);
CREATE INDEX idx_donations_recipient ON donations(recipient_type, recipient_id);
CREATE INDEX idx_donations_status ON donations(status);
CREATE INDEX idx_donations_created ON donations(created_at DESC);
knowledge_documents - RAG Knowledge Base
Description: Documents for AI-powered question answering.
Copy
CREATE TABLE knowledge_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
snippet TEXT, -- First 200 chars for preview
url TEXT, -- Source URL
ward_code TEXT REFERENCES wards(ward_code),
document_type TEXT, -- report, article, notice, etc.
embedding vector(1536), -- OpenAI embedding vector
timestamp TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_knowledge_ward ON knowledge_documents(ward_code);
CREATE INDEX idx_knowledge_type ON knowledge_documents(document_type);
CREATE INDEX idx_knowledge_embedding ON knowledge_documents USING IVFFLAT (embedding vector_cosine_ops);
Views
ward_statistics - Ward Aggregated Statistics
Description: Pre-computed statistics for wards.
Copy
CREATE VIEW ward_statistics AS
SELECT
w.ward_code,
w.name,
COUNT(DISTINCT b.id) as business_count,
COUNT(DISTINCT p.id) as project_count,
COUNT(DISTINCT s.id) as signal_count,
COUNT(DISTINCT pr.id) as profile_count,
AVG(b.rating) as avg_business_rating,
SUM(d.amount) as total_donations,
COUNT(DISTINCT CASE WHEN s.type = 'safety' THEN s.id END) as safety_signals,
COUNT(DISTINCT CASE WHEN p.status = 'active' THEN p.id END) as active_projects
FROM wards w
LEFT JOIN businesses b ON w.ward_code = b.ward_code
LEFT JOIN action_projects p ON w.ward_code = p.ward_code
LEFT JOIN signals s ON w.ward_code = s.ward_code
LEFT JOIN profiles pr ON w.ward_code = pr.ward_code
LEFT JOIN donations d ON w.ward_code = d.recipient_id AND d.recipient_type = 'ward'
GROUP BY w.ward_code, w.name;
business_directory - Enhanced Business View
Description: Business directory with aggregated data.
Copy
CREATE VIEW business_directory AS
SELECT
b.id,
b.name,
b.slug,
b.description,
b.category,
b.subcategory,
b.ward_code,
b.location,
b.address,
b.contact_phone,
b.contact_email,
b.website,
b.opening_hours,
b.photos,
b.verified,
b.rating,
b.review_count,
ARRAY_AGG(DISTINCT s.ward_code) as service_wards,
p.full_name as owner_name
FROM businesses b
LEFT JOIN business_service_areas s ON b.id = s.business_id
LEFT JOIN profiles p ON b.claimed_by = p.id
GROUP BY b.id, b.name, b.slug, b.description, b.category, b.subcategory,
b.ward_code, b.location, b.address, b.contact_phone, b.contact_email,
b.website, b.opening_hours, b.photos, b.verified, b.rating,
b.review_count, p.full_name;
RPCs (Remote Procedure Calls)
get_ward_intelligence - Ward Intelligence Summary
Copy
CREATE OR REPLACE FUNCTION get_ward_intelligence(ward_code_param TEXT)
RETURNS JSONB AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'ward', (SELECT row_to_json(w) FROM wards w WHERE w.ward_code = ward_code_param),
'statistics', (SELECT row_to_json(ws) FROM ward_statistics ws WHERE ws.ward_code = ward_code_param),
'recent_signals', (SELECT jsonb_agg(row_to_json(s))
FROM signals s
WHERE s.ward_code = ward_code_param
ORDER BY s.created_at DESC
LIMIT 10),
'active_projects', (SELECT jsonb_agg(row_to_json(p))
FROM action_projects p
WHERE p.ward_code = ward_code_param
AND p.status = 'active'),
'open_topics', (SELECT jsonb_agg(row_to_json(t))
FROM governance_topics t
WHERE t.ward_code = ward_code_param
AND t.status = 'open')
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
Copy
SELECT * FROM get_ward_intelligence('WARD001');
search_businesses_nearby - Geospatial Business Search
Copy
CREATE OR REPLACE FUNCTION search_businesses_nearby(
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
radius_meters INTEGER DEFAULT 5000,
category_param TEXT DEFAULT NULL
)
RETURNS TABLE (
id UUID,
name TEXT,
category TEXT,
distance_m DOUBLE PRECISION
) AS $$
BEGIN
RETURN QUERY
SELECT
b.id,
b.name,
b.category,
ST_Distance(
b.location::geography,
ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography
) as distance_m
FROM businesses b
WHERE ST_DWithin(
b.location::geography,
ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography,
radius_meters
)
AND (category_param IS NULL OR b.category = category_param)
AND b.verified = TRUE
ORDER BY distance_m;
END;
$$ LANGUAGE plpgsql;
Copy
SELECT * FROM search_businesses_nearby(-26.2041, 28.0473, 2000, 'Food & Beverage');
Common Queries
Find Wards by Location
Copy
-- Find ward containing a point
SELECT ward_code, name
FROM wards
WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint(lng, lat), 4326));
Get Businesses in Ward
Copy
-- All businesses in ward
SELECT id, name, category, rating
FROM businesses
WHERE ward_code = 'WARD001'
AND verified = TRUE
ORDER BY rating DESC NULLS LAST;
Get Recent Signals
Copy
-- Recent signals with corroboration count
SELECT
s.id,
s.type,
s.title,
s.severity,
s.corroborations,
p.full_name as creator_name,
s.created_at
FROM signals s
JOIN profiles p ON s.creator_id = p.id
WHERE s.ward_code = 'WARD001'
AND s.status = 'active'
ORDER BY s.created_at DESC
LIMIT 20;
Get Active Projects
Copy
-- Active projects with progress
SELECT
p.id,
p.title,
p.progress,
p.participant_count,
COUNT(m.id) as milestone_count,
COUNT(CASE WHEN m.status = 'completed' THEN 1 END) as completed_milestones
FROM action_projects p
LEFT JOIN project_milestones m ON p.id = m.project_id
WHERE p.ward_code = 'WARD001'
AND p.status = 'active'
GROUP BY p.id, p.title, p.progress, p.participant_count
ORDER BY p.progress DESC;
Get Governance Consensus
Copy
-- Governance topic with consensus
SELECT
t.id,
t.title,
t.consensus_score,
t.participant_count,
s.content as leading_statement,
v.agree_count,
v.disagree_count
FROM governance_topics t
LEFT JOIN LATERAL (
SELECT content
FROM governance_statements st
WHERE st.topic_id = t.id
ORDER BY (
SELECT COUNT(*)
FROM governance_votes
WHERE statement_id = st.id AND vote_type = 'agree'
) DESC
LIMIT 1
) s ON true
LEFT JOIN LATERAL (
SELECT
COUNT(CASE WHEN vote_type = 'agree' THEN 1 END) as agree_count,
COUNT(CASE WHEN vote_type = 'disagree' THEN 1 END) as disagree_count
FROM governance_votes v
JOIN governance_statements st ON v.statement_id = st.id
WHERE st.topic_id = t.id
) v ON true
WHERE t.ward_code = 'WARD001'
AND t.status = 'open'
ORDER BY t.consensus_score DESC;
TypeScript Integration
Generated Types
Copy
// Generated by Supabase CLI
import { Tables, Enums } from '@/types/supabase-generated';
type Business = Tables<'businesses'>;
type Signal = Tables<'signals'>;
type Profile = Tables<'profiles'>;
// Usage
const business: Business = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'Maboneng Coffee',
category: 'Food & Beverage',
// ...
};
Query Examples
Copy
import { createClient } from '@/lib/supabase/client';
// Get ward overview
const { data: ward } = await supabase
.from('wards')
.select('*')
.eq('ward_code', 'WARD001')
.single();
// Search businesses
const { data: businesses } = await supabase
.from('businesses')
.select('id, name, category, rating')
.eq('ward_code', 'WARD001')
.eq('verified', true)
.order('rating', { ascending: false })
.limit(20);
// Call RPC
const { data: intelligence } = await supabase
.rpc('get_ward_intelligence', { ward_code_param: 'WARD001' });
Summary
Core Tables
Views
RPCs
TypeScript
- wards - Geographic boundaries and metadata
- profiles - User profiles with roles
- businesses - Business directory with geospatial data
- signals - User-generated alerts and incidents
- action_projects - Community projects
- governance_topics - Ward governance discussions
- donations - Ward and project donations
- knowledge_documents - AI knowledge base