-
Notifications
You must be signed in to change notification settings - Fork 0
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.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)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.
const { data } = await supabase
.from('colonies')
.select(`
*,
cats(*),
caretakers(user_id, profiles(display_name, avatar_url)),
timeline_events(*)
`)
.eq('id', colonyId)
.single()Static content in lib/articles.ts — no database query needed.
import { ARTICLES } from '@/lib/content/articles'
const article = ARTICLES.find(a => a.slug === slug)const { data } = await supabase
.from('community_contacts')
.select('id, city, name, phone, email, social, category, notes, created_at')
.order('city')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).
const { data } = await supabase
.from('feedings')
.insert({ colony_id: colonyId, type: 'food', user_id: userId }) // or 'water'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.
All RPCs validate permissions internally before executing — never trust a client-supplied role or flag.
const { data } = await supabase.rpc('get_colony_exact_location', {
p_colony_id: colonyId
})
// Returns: { latitude, longitude } or permission deniedRequires: Authenticated + linked caretaker or creator. Re-validates the link server-side on every call.
const { data } = await supabase.rpc('confirm_report', {
p_report_id: reportId
})Requires: Authenticated. Blocks: self-confirmation, duplicate confirmation from the same user.
const { data } = await supabase.rpc('mark_cat_seen_today', {
p_cat_id: catId
})Requires: Authenticated.
const { data } = await supabase.rpc('thank_action', {
p_timeline_event_id: eventId
})Requires: Authenticated. Blocks: self-thanking.
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.
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 (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.
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).
const { data } = await supabase.rpc('get_own_streak')
// Returns: { current_streak, longest_streak } for the calling user onlyRequires: Authenticated. The only way to read streak data — the underlying columns have no public grant.
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.
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)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.
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.