Skip to content

API Reference

Luara edited this page Jul 5, 2026 · 3 revisions

API Reference

Felines uses Supabase as its backend. Most data access happens through standard Supabase queries with Row Level Security enforcing permissions automatically. Sensitive operations use server-side RPCs that validate permissions before executing.


Authentication

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

Public Endpoints (no auth required)

Get colonies (blurred locations)

const { data } = await supabase
  .from('colonies')
  .select(`
    id, name, narrative,
    latitude_blurred, longitude_blurred,
    castration_status, health_status,
    cover_photo_url, verified_status, created_at
  `)
  .is('removed_at', null)

Returns blurred coordinates only. Exact coordinates are never returned by public queries. removed_at filters out any colony removed by the 3-false-pin-flag ban system.

Get colony detail

const { data } = await supabase
  .from('colonies')
  .select(`
    *,
    cats(*),
    caretakers(user_id, profiles(display_name, avatar_url)),
    timeline_events(*)
  `)
  .eq('id', colonyId)
  .single()

Get articles

Static content in lib/articles.ts — no database query needed.

import { ARTICLES } from '@/lib/articles'
const article = ARTICLES.find(a => a.slug === slug)

Get community contacts

const { data } = await supabase
  .from('community_contacts')
  .select('id, city, name, phone, email, social, category, notes, created_at')
  .order('city')

Authenticated Endpoints

Create colony

const { data } = await supabase
  .from('colonies')
  .insert({
    name: 'Colony name',
    narrative: 'Colony story',
    latitude: -5.7945,               // exact
    longitude: -35.2110,
    latitude_blurred: -5.800,        // ~500m offset (anon)
    longitude_blurred: -35.216,
    latitude_blurred_near: -5.7950,  // ~100m offset (authenticated)
    longitude_blurred_near: -35.2115,
    castration_status: 'unknown',
    created_by: session.user.id,
    cover_photo_url: 'https://...'
  })
  .select('id')
  .single()

Blocked if is_user_banned(auth.uid()) returns true (see Security).

Log feeding check-in

const { data } = await supabase
  .from('feedings')
  .insert({ colony_id: colonyId, type: 'food', user_id: userId }) // or 'water'

Create report

const { data } = await supabase
  .from('reports')
  .insert({
    colony_id: colonyId,     // optional
    type: 'sighting',        // one of 9 types
    description: '...',
    latitude: -5.7945,
    longitude: -35.2110,
  })

Report types: sighting, no_food_water, injured_cat, missing_cat, new_kitten, disease_outbreak, suspected_poisoning, suspected_abuse, threat_to_colony

Coordinates are rejected by a database CHECK constraint if out of geographic range, regardless of caller.


RPCs (Server-Side Functions)

All RPCs validate permissions internally before executing — never trust a client-supplied role or flag.

get_colony_exact_location

const { data } = await supabase.rpc('get_colony_exact_location', {
  p_colony_id: colonyId
})
// Returns: { latitude, longitude } or permission denied

Requires: Authenticated + linked caretaker or creator. Re-validates the link server-side on every call.

confirm_report

const { data } = await supabase.rpc('confirm_report', {
  p_report_id: reportId
})

Requires: Authenticated. Blocks: self-confirmation, duplicate confirmation from the same user.

mark_cat_seen_today

const { data } = await supabase.rpc('mark_cat_seen_today', {
  p_cat_id: catId
})

Requires: Authenticated.

thank_action

const { data } = await supabase.rpc('thank_action', {
  p_timeline_event_id: eventId
})

Requires: Authenticated. Blocks: self-thanking.

record_daily_visit / record_care_streak

await supabase.rpc('record_daily_visit')
await supabase.rpc('record_care_streak', { p_colony_id: colonyId })

Requires: Authenticated. Idempotent — safe to call more than once per day; resets to 1 (not 0) after a missed day.

notify_caretakers

await supabase.rpc('notify_caretakers', {
  p_colony_id: colonyId,
  p_type: 'report_submitted',
  p_report_type: null // required only for 'area_alert'
})

Requires: anon or authenticated (intentionally — anonymous report submission needs to trigger it). Message is built server-side from a fixed template; the caller can never inject free text. Rate-limited via a database circuit breaker (one batch per colony per 5 minutes).

get_own_streak

const { data } = await supabase.rpc('get_own_streak')
// Returns: { current_streak, longest_streak } for the calling user only

Requires: Authenticated. The only way to read streak data — the underlying columns have no public grant.


Storage

Photos are stored in Supabase Storage in the colony-photos bucket.

import { validatePhotoFile, buildSafeStoragePath, assertSafeStoragePath } from '@/lib/storage'

const error = validatePhotoFile(file) // MIME + size check
if (error) throw new Error(error)

const path = buildSafeStoragePath(`colonies/${colonyId}`, file)
assertSafeStoragePath(path) // belt-and-suspenders re-check

const { data } = await supabase.storage
  .from('colony-photos')
  .upload(path, file)

Public read URL:

const { data } = supabase.storage.from('colony-photos').getPublicUrl(path)

External APIs

OpenWeatherMap

const url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('lat', String(lat))
url.searchParams.set('lon', String(lon))
url.searchParams.set('appid', process.env.NEXT_PUBLIC_WEATHER_API_KEY!)
url.searchParams.set('units', 'metric')
url.searchParams.set('lang', language === 'pt' ? 'pt_br' : 'en')

Nominatim (OpenStreetMap)

import { validateCoordinates } from '@/lib/validateCoordinates'
import { reverseGeocode } from '@/lib/geocode'

const safe = validateCoordinates(lat, lon) // throws if invalid
const cityName = await reverseGeocode(safe.lat, safe.lon, 'en')

Coordinates are validated (range, NaN, Infinity) before any request is built, and the request uses URLSearchParams with redirect: "error" — see Security.

Clone this wiki locally