FastAPI + Supabase (PostgreSQL). Run on
http://localhost:8000. Auth: Supabase JWT passed asAuthorization: Bearer <token>header.
All protected routes require a valid Supabase JWT. Middleware already set up — use the get_current_user dependency.
@router.get("/campaigns/mine")
async def my_campaigns(user = Depends(get_current_user)):
...Role check helper for admin routes:
def require_admin(user = Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return userPOST /campaigns Create a new campaign (auth)
GET /campaigns/{id} Campaign detail (public)
PUT /campaigns/{id} Update campaign (organizer only)
DELETE /campaigns/{id} Cancel/delete campaign (organizer only)
GET /campaigns/mine Campaigns I created (auth)
GET /campaigns/joined Campaigns I signed up for (auth)
POST /campaigns — Request body:
{
"title": "Hyde Park Flyering",
"description": "...",
"location": "Hyde Park",
"address": "5100 S Lake Shore Dr",
"latitude": 41.7943,
"longitude": -87.5907,
"date": "2026-03-21",
"start_time": "10:00",
"end_time": "13:00",
"max_volunteers": 10,
"target_flyers": 500,
"flyer_template_id": "uuid",
"food_pantry_id": "uuid | null",
"tags": ["families", "south_side", "weekend"]
}POST /campaigns/{id}/signup Volunteer registers (auth)
DELETE /campaigns/{id}/signup Volunteer withdraws (auth)
GET /campaigns/{id}/signups List signups (organizer only)
POST /campaigns/{id}/confirm/{uid} Organizer confirms volunteer attended (auth, organizer)
Attendance confirmation is required for points to be awarded. The organizer calls POST /campaigns/{id}/confirm/{uid} after the event — this triggers the reward logic.
GET /campaigns/{id}/tasks List tasks (public)
POST /campaigns/{id}/tasks Create task (organizer)
PUT /tasks/{id} Update/assign task (organizer)
DELETE /tasks/{id} Delete task (organizer)
POST /tasks/{id}/assign Volunteer self-assigns to a task (auth)
DELETE /tasks/{id}/assign Volunteer unassigns (auth)
POST /campaigns/{id}/impact Submit post-event report (organizer)
GET /campaigns/{id}/impact Get report (auth)
POST /campaigns/{id}/impact — Request body:
{
"flyers_distributed": 430,
"families_reached": 120,
"volunteers_attended": 8,
"notes": "Great turnout near the park entrance.",
"photos": ["https://storage.supabase.../photo1.jpg"]
}Submitting a report triggers:
- Award
10 ptsto organizer (action: 'report') - Check and award
impact_100badge if threshold crossed
GET /feed Personalized ranked events (auth)
GET /feed/trending Trending events (public)
GET /feed/nearby?lat=&lng=&radius_km= Events within radius (public)
GET /campaigns/search?q=&tags=&date_from=&date_to=&lat=&lng=
Score each published campaign for the requesting user. Sort descending.
from math import radians, sin, cos, sqrt, atan2
def haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
return R * 2 * atan2(sqrt(a), sqrt(1 - a))
def score_campaign(campaign, user, user_location, user_tags, viewed_ids) -> float:
# 1. Proximity (40%) — linear decay, 0 score beyond 15km
dist = haversine_km(user_location.lat, user_location.lng,
campaign.latitude, campaign.longitude)
proximity = max(0.0, 1.0 - dist / 15.0) * 0.40
# 2. Interest match (25%) — tag overlap ratio
if campaign.tags and user_tags:
overlap = len(set(campaign.tags) & set(user_tags))
interest = (overlap / len(campaign.tags)) * 0.25
else:
interest = 0.0
# 3. Urgency (20%) — peaks at events happening in 1-3 days
days_away = (campaign.date - date.today()).days
if days_away < 0:
urgency = 0.0
elif days_away <= 3:
urgency = 1.0 * 0.20
else:
urgency = max(0.0, 1.0 - days_away / 14.0) * 0.20
# 4. Social proof (10%) — % spots filled
if campaign.max_volunteers:
fill_rate = min(1.0, campaign.signup_count / campaign.max_volunteers)
else:
fill_rate = 0.5
social = fill_rate * 0.10
# 5. Novelty (5%) — unseen events rank higher
novelty = 0.0 if campaign.id in viewed_ids else 0.05
# Promotion boost — +20% for 24h window
boost = 0.20 if campaign.promoted_until and campaign.promoted_until > datetime.utcnow() else 0.0
return proximity + interest + urgency + social + novelty + boostTrending logic (/feed/trending):
# Events with the most signups in the last 48 hours
SELECT c.*, COUNT(s.id) as recent_signups
FROM campaigns c
JOIN signups s ON s.campaign_id = c.id
WHERE c.status = 'published'
AND s.joined_at > NOW() - INTERVAL '48 hours'
GROUP BY c.id
ORDER BY recent_signups DESC
LIMIT 10;POST /campaigns/{id}/promote Boost event — notify nearby users (organizer, once per campaign)
Logic on POST /campaigns/{id}/promote:
- Check
promoted_at IS NULL— reject if already promoted - Set
promoted_at = NOW(),promoted_until = NOW() + 24h - Query all users within 10km who haven't signed up for this campaign
- Enqueue notification job (email/push) for each user
- Boosts feed score automatically via
promoted_untilcheck in recommendation
GET /leaderboard?scope=global&period=all_time|monthly|weekly
GET /leaderboard/nearby?lat=&lng=&radius_km=
GET /me/points My total + transaction history
GET /me/badges My earned badges
GET /me/level Current level + % to next
Call this after each trigger event. Keep it idempotent — pass campaign_id so you can check for duplicates.
POINT_VALUES = {
"signup": 5,
"attend": 20,
"organize": 50,
"report": 10,
"invite": 15,
"streak_bonus": 30,
"first_event": 10, # one-time
}
async def award_points(user_id, action, campaign_id=None):
# Guard: 'first_event' only awarded once
if action == "first_event":
exists = await db.fetchval(
"SELECT 1 FROM user_points WHERE user_id=$1 AND action='first_event'", user_id)
if exists:
return
await db.execute(
"INSERT INTO user_points (user_id, campaign_id, action, points) VALUES ($1,$2,$3,$4)",
user_id, campaign_id, action, POINT_VALUES[action]
)
await check_and_award_badges(user_id)
await check_streak(user_id)LEVELS = [
(0, 1, "Seedling"),
(50, 2, "Sprout"),
(150, 3, "Bloom"),
(350, 4, "Branch"),
(700, 5, "Squeeze Champion"),
]
def get_level(total_points: int) -> dict:
current = LEVELS[0]
for min_pts, level, name in LEVELS:
if total_points >= min_pts:
current = (min_pts, level, name)
next_idx = current[1] # level index
if next_idx < len(LEVELS):
next_min = LEVELS[next_idx][0]
progress = (total_points - current[0]) / (next_min - current[0])
else:
progress = 1.0 # max level
return {"level": current[1], "name": current[2], "progress_pct": round(progress * 100)}async def check_streak(user_id: str):
rows = await db.fetch("""
SELECT DISTINCT DATE_TRUNC('month', s.joined_at)::date AS month
FROM signups s
WHERE s.user_id = $1 AND s.status = 'confirmed'
ORDER BY month DESC
""", user_id)
streak = 0
expected = date.today().replace(day=1)
for row in rows:
if row["month"] == expected:
streak += 1
expected = (expected - timedelta(days=1)).replace(day=1)
else:
break
if streak > 0 and streak % 3 == 0:
await award_points(user_id, "streak_bonus")async def check_and_award_badges(user_id: str):
total_attendances = await db.fetchval(
"SELECT COUNT(*) FROM signups WHERE user_id=$1 AND status='confirmed'", user_id)
total_campaigns = await db.fetchval(
"SELECT COUNT(*) FROM campaigns WHERE organizer_id=$1", user_id)
total_flyers = await db.fetchval(
"SELECT COALESCE(SUM(i.flyers_distributed),0) FROM impact_reports i "
"JOIN campaigns c ON c.id=i.campaign_id "
"JOIN signups s ON s.campaign_id=c.id "
"WHERE s.user_id=$1 AND s.status='confirmed'", user_id)
total_invites = await db.fetchval(
"SELECT COUNT(*) FROM invitations WHERE invited_by=$1 AND status='accepted'", user_id)
badge_rules = [
("first_event", total_attendances >= 1),
("5_events", total_attendances >= 5),
("organizer", total_campaigns >= 1),
("recruiter", total_invites >= 3),
("impact_100", total_flyers >= 100),
]
for slug, condition in badge_rules:
if condition:
await db.execute(
"INSERT INTO user_badges (user_id, badge_slug) VALUES ($1,$2) ON CONFLICT DO NOTHING",
user_id, slug)GET /map/campaigns?lat=&lng=&radius_km=&status=published
GET /map/food-pantries?lat=&lng=&radius_km=
GET /map/heatmap?start=&end= Impact heatmap data (admin)
Distance filter (no PostGIS needed):
SELECT *,
(6371 * acos(
cos(radians(:lat)) * cos(radians(latitude)) *
cos(radians(longitude) - radians(:lng)) +
sin(radians(:lat)) * sin(radians(latitude))
)) AS distance_km
FROM campaigns
WHERE status = 'published'
HAVING distance_km <= :radius_km
ORDER BY distance_km;Map pin response shape (keep lightweight):
{
"campaigns": [
{
"id": "uuid",
"title": "North Side Flyering",
"latitude": 41.8781,
"longitude": -87.6298,
"date": "2026-03-20",
"signup_count": 4,
"max_volunteers": 10,
"status": "published"
}
]
}GET /admin/analytics/overview
GET /admin/analytics/trends?period=weekly|monthly
GET /admin/analytics/impact-map
GET /admin/campaigns?status=&date_from=&date_to=
GET /admin/campaigns/{id}
PUT /admin/campaigns/{id}/status { "status": "cancelled" }
GET /admin/users?sort=points&role=
GET /admin/flyer-templates
POST /admin/flyer-templates
PUT /admin/flyer-templates/{id}
DELETE /admin/flyer-templates/{id}
GET /admin/food-pantries
PUT /admin/food-pantries/{id}/verify { "is_verified": true }
GET /admin/analytics/overview — Response:
{
"total_campaigns": 42,
"total_volunteers": 188,
"total_flyers_distributed": 12400,
"total_families_reached": 3200,
"campaigns_this_month": 8,
"active_campaigns": 5
}POST /pantry/register New pantry signup (creates user + pantry record)
GET /pantry/me My pantry profile (auth, pantry owner)
PUT /pantry/me Update profile (auth, pantry owner)
GET /pantry/me/campaigns Campaigns linked to my pantry
POST /pantry/me/campaigns/{id}/link Link an existing campaign
GET /pantries Public list
GET /pantries/{id} Public pantry detail
Always return this shape on errors:
{
"success": false,
"error": "Human-readable message",
"code": "CAMPAIGN_NOT_FOUND"
}{
"success": true,
"data": { ... }
}Paginated:
{
"success": true,
"data": [...],
"meta": { "total": 100, "page": 1, "limit": 20 }
}