Skip to content

API Reference

Luara edited this page Jul 7, 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/content/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 / notify_nearby_caretakers

await supabase.rpc('notify_caretakers', {
  p_colony_id: colonyId,
  p_type: 'report_submitted',   // or 'area_alert'
  p_report_type: null,          // required only for 'area_alert'
  p_language: 'pt'              // 'pt' or 'en' — which template to use
})

await supabase.rpc('notify_nearby_caretakers', {
  p_latitude: -5.7945,
  p_longitude: -35.2110,
  p_radius_km: 5,
  p_report_type: 'suspected_poisoning',
  p_language: 'pt'
})

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

thank_action vs. the plain thanks insert

thank_action (above) is the only RPC of the two thank-you systems — it thanks one specific timeline event and notifies its author. The generic caretaker-level thanks table has no RPC at all: it's written with a plain authenticated insert, since it doesn't need to fan out a notification to someone else the way thank_action does. See Features — Two Thank-You Systems for why the two are kept separate.

respond_to_resource_post

await supabase.rpc('respond_to_resource_post', {
  p_resource_post_id: postId
})

Requires: Authenticated. Blocks a poster from expressing interest in their own post (mirrors the self-confirm/self-thank guards elsewhere) and notifies the post's author (resource_interest).

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.


Notification Types Reference

All 10 notification types, for quick lookup — full reasoning behind each is in Features:

Type Trigger Recipient
action_thanks thank_action RPC call Timeline-event author
help_request_response respond_to_help_request RPC call All colony caretakers
sighting_cluster Trigger-detected sighting cluster Caretakers within 500m
resource_interest respond_to_resource_post RPC call Resource post author
story_reaction Story reaction insert Story author
extreme_weather Client-side weather check on load Colony caretakers, 1/day/colony
cat_unseen Client-side staleness check on load Colony caretakers, 1/cat/day
area_alert notify_nearby_caretakers RPC call Caretakers within 5km
report_submitted notify_caretakers RPC call Colony caretakers
colony_removed_ban handle_false_pin_threshold trigger Colony creator

None of these run on a schedule — every one fires synchronously from the user action or page load that triggers it.


Storage

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

import { validatePhotoFile, buildSafeStoragePath, assertSafeStoragePath } from '@/lib/security/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')

lat/lon are always the map's current center, re-fetched every time the map is panned or zoomed — the weather card is never fixed to one city. lang is set from the same PT/EN toggle that drives the rest of the UI, so the condition text returned by the API (e.g. "scattered clouds") matches the visitor's language.

Nominatim (OpenStreetMap)

import { validateCoordinates } from '@/lib/security/validateCoordinates'
import { reverseGeocode } from '@/lib/external/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