Nivaran
Potholes, garbage and broken streetlights get reported in WhatsApp groups and forgotten. Nivaran lets a citizen report one with a photo and their GPS location, and the database itself ranks it.
How it works
Key decisions
01 Priority set by the database
Which report should an officer fix first? Instead of sorting in the app, a Postgres trigger turns upvotes and downvotes into a score from 0 to 100 and a priority from Low to Critical. Every app and every query sees the same priority, and nobody can skip the rule from the browser.
See the codeHide the code Nivaran · supabase/migrations/20260328_priority_trigger.sql · 42 lines
-- Add priority and visibility_score columns to existing issues table
ALTER TABLE public.issues ADD COLUMN IF NOT EXISTS priority text DEFAULT 'MEDIUM';
ALTER TABLE public.issues ADD COLUMN IF NOT EXISTS visibility_score integer DEFAULT 42;
-- Function to calculate priority based on upvotes and downvotes
CREATE OR REPLACE FUNCTION public.calculate_issue_priority()
RETURNS TRIGGER AS $$
DECLARE
v_score integer;
BEGIN
-- Formula: 42 + (upvotes * 1.6 - downvotes * 1.1)
v_score := ROUND(42 + (NEW.upvotes * 1.6 - NEW.downvotes * 1.1));
-- Bound score between 0 and 100
IF v_score > 100 THEN v_score := 100; END IF;
IF v_score < 0 THEN v_score := 0; END IF;
NEW.visibility_score := v_score;
IF v_score > 80 THEN
NEW.priority := 'CRITICAL';
ELSIF v_score > 60 THEN
NEW.priority := 'HIGH';
ELSIF v_score > 40 THEN
NEW.priority := 'MEDIUM';
ELSE
NEW.priority := 'LOW';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to automatically update priority and score on upvote/downvote changes
DROP TRIGGER IF EXISTS trigger_update_issue_priority ON public.issues;
CREATE TRIGGER trigger_update_issue_priority
BEFORE INSERT OR UPDATE OF upvotes, downvotes ON public.issues
FOR EACH ROW
EXECUTE FUNCTION public.calculate_issue_priority();
-- Run a one-time update for existing rows
UPDATE public.issues SET upvotes = COALESCE(upvotes, 0) WHERE priority IS NULL;02 Push from the edge
A citizen should hear back when their report moves, without keeping the app open. Each status change writes a notification row, and a database webhook calls this Edge Function, which finds the citizen's FCM token and sends the push. The app never holds the Firebase server key.
See the codeHide the code Nivaran · supabase/functions/send-push/index.ts · 68 lines
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { JWT } from 'https://esm.sh/google-auth-library@8'
serve(async (req) => {
try {
// 1. Get the notification row from the Webhook payload
const payload = await req.json();
const notification = payload.record;
// 2. Initialize Supabase client to fetch the citizen's FCM token
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
);
const { data: profile } = await supabaseClient
.from('profiles')
.select('fcm_token')
.eq('id', notification.user_id)
.single();
if (!profile?.fcm_token) {
return new Response("Citizen has no FCM token. Skipping.", { status: 200 });
}
// 3. Authenticate with Firebase using your Service Account JSON
// (You will set FIREBASE_SERVICE_ACCOUNT as a Supabase Secret later)
const serviceAccount = JSON.parse(Deno.env.get('FIREBASE_SERVICE_ACCOUNT') ?? '{}');
const jwtClient = new JWT({
email: serviceAccount.client_email,
key: serviceAccount.private_key.replace(/\\n/g, '\n'),
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
const tokens = await jwtClient.getAccessToken();
// 4. Send the push via FCM v1 API
const fcmPayload = {
message: {
token: profile.fcm_token,
notification: {
title: notification.title,
body: notification.body,
},
data: {
issueId: notification.issue_id || "",
}
}
};
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${tokens.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(fcmPayload),
}
);
return new Response(JSON.stringify({ success: true }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
}
})03 Citizens verify the fix
An officer marking a report Resolved does not mean the pothole is fixed. So the reporter and the affected citizens vote: more than half saying fixed verifies it, and half or more saying not fixed sends it back to In Progress. The vote count lives in one database function, so it is counted the same way every time.
See the codeHide the code Nivaran · supabase/migrations/20260327_civic_resolution_engine.sql · 76 lines
-- 1. Add a JSONB column to track who voted what (e.g., {"user_id": true/false})
ALTER TABLE public.issues ADD COLUMN IF NOT EXISTS verification_votes jsonb DEFAULT '{}'::jsonb;
-- 2. Create the Consensus Engine (RPC Function)
-- … cut: comment line
CREATE OR REPLACE FUNCTION public.cast_verification_vote(p_issue_id UUID, p_is_fixed BOOLEAN)
RETURNS void AS $$
DECLARE
v_issue RECORD;
v_votes JSONB;
v_yes_count INT := 0;
v_no_count INT := 0;
v_total_jury INT := 0;
BEGIN
-- Get current issue
SELECT * INTO v_issue FROM public.issues WHERE id = p_issue_id;
-- Update the votes JSON array with the current user's vote
v_votes := COALESCE(v_issue.verification_votes, '{}'::jsonb);
v_votes := jsonb_set(v_votes, ARRAY[auth.uid()::text], to_jsonb(p_is_fixed));
-- Save the vote
UPDATE public.issues SET verification_votes = v_votes WHERE id = p_issue_id;
-- RULE 1: If the original reporter says "Yes", it is instantly Verified.
IF auth.uid() = v_issue.user_id AND p_is_fixed = true THEN
UPDATE public.issues SET status = 'Verified' WHERE id = p_issue_id;
RETURN;
END IF;
-- Calculate the current tallies
SELECT
COUNT(*) FILTER (WHERE value::text = 'true'),
COUNT(*) FILTER (WHERE value::text = 'false')
INTO v_yes_count, v_no_count
FROM jsonb_each(v_votes);
-- Calculate total possible jury members (Reporter + Affected Users)
v_total_jury := COALESCE(array_length(v_issue.affected_user_ids, 1), 0) + 1;
-- RULE 2: If > 50% say YES, it's Verified.
IF v_yes_count > (v_total_jury / 2.0) THEN
UPDATE public.issues SET status = 'Verified' WHERE id = p_issue_id;
-- RULE 3: If >= 50% say NO, it gets kicked back to In Progress!
ELSIF v_no_count >= (v_total_jury / 2.0) THEN
UPDATE public.issues SET status = 'In Progress' WHERE id = p_issue_id;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Update the Notification Trigger to alert the Jury
CREATE OR REPLACE FUNCTION public.notify_user_on_status_change()
RETURNS TRIGGER AS $$
DECLARE
jury_id UUID;
BEGIN
IF NEW.status <> OLD.status THEN
-- A. Notify Original Reporter
INSERT INTO public.notifications (user_id, issue_id, title, body, is_read)
VALUES (NEW.user_id, NEW.id, 'Issue Update: ' || NEW.status, 'Your issue has been updated to ' || NEW.status || '.', false);
-- B. If it's RESOLVED, notify the Jury!
IF NEW.status = 'Resolved' AND NEW.affected_user_ids IS NOT NULL THEN
FOREACH jury_id IN ARRAY NEW.affected_user_ids
LOOP
-- Don't double-notify the reporter if they are in both arrays
IF jury_id <> NEW.user_id THEN
INSERT INTO public.notifications (user_id, issue_id, title, body, is_read)
VALUES (jury_id, NEW.id, 'Verification Required', 'An issue you confirmed visibility for has been marked Resolved by the officer. Please verify if it is actually fixed!', false);
END IF;
END LOOP;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;What I'd do differently
- The priority numbers (start at 42, +1.6 per upvote, -1.1 per downvote) are my best guess. I'd keep them in a settings table and tune them with real reports.
- The push function does not check FCM's reply, so a dead token fails silently. I'd read the reply and clear tokens that no longer work.
Results
- Reports carry a photo and GPS location
- Priority and resolution logic lives in Postgres triggers and functions
- The reporter and affected citizens vote on whether a resolved report is really fixed
- Citizens get a push notification when their report changes status