Skip to main content

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.ts

Core Tables

wards - Ward Boundaries & Metadata

Description: Geographic ward boundaries and basic information.
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);
Common Queries:
-- 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.
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.
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);
Common Queries:
-- 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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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

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;
Usage:
SELECT * FROM get_ward_intelligence('WARD001');

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;
Usage:
SELECT * FROM search_businesses_nearby(-26.2041, 28.0473, 2000, 'Food & Beverage');

Common Queries

Find Wards by Location

-- 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

-- 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

-- 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

-- 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

-- 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

// 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

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

Key Tables:
  1. wards - Geographic boundaries and metadata
  2. profiles - User profiles with roles
  3. businesses - Business directory with geospatial data
  4. signals - User-generated alerts and incidents
  5. action_projects - Community projects
  6. governance_topics - Ward governance discussions
  7. donations - Ward and project donations
  8. knowledge_documents - AI knowledge base