Feed Algorithm
The events module (and therefore this feed) is not yet shipped.
The algorithm below is the final design; implementation will live at
src/feats/events/events.service.ts on the new Hono + Supabase +
Drizzle stack and depends on PostGIS being enabled on Supabase.
The event feed contains published events with filtering and geo queries.
Query Logic
feed = events
.filter(status = 'published')
.filter(visibility = 'public' OR is_friend(creator))
.filter(time >= now()) -- not started yet
.filter(in_radius(lat, lng, radius))
.order_by(distance ASC, start_at ASC)
Filtering
1. Status Filter
Only published events appear in feed:
.where(eq(eventsTable.status, "published"))
.where(isNull(eventsTable.deletedAt))
2. Visibility Filter
// Either public, OR creator is a friend.
.where(
or(
eq(eventsTable.visibility, "public"),
sql`public.are_friends(${viewerId}, ${eventsTable.creatorId})`,
),
);
The public.are_friends(uuid, uuid) helper is the same SECURITY DEFINER
function described in Friendship Logic.
3. Time Filter
Exclude events that already started:
.where(gte(eventsTable.startAt, new Date()))
4. Geo Filter (PostGIS)
events.location is geography(Point, 4326) (same convention as
profiles.location and cities.location, see
api/docs/DATABASE.md).
ST_DWithin on geography returns true distances in metres regardless
of latitude:
SELECT
e.*,
ST_Distance(e.location, ST_GeogFromText($1)) AS distance_m
FROM events e
WHERE e.status = 'published'
AND e.deleted_at IS NULL
AND e.start_at >= now()
AND ST_DWithin(e.location, ST_GeogFromText($1), $2) -- $1 = 'SRID=4326;POINT(lng lat)', $2 = metres
AND (
e.visibility = 'public'
OR public.are_friends($3, e.creator_id) -- $3 = viewer
)
ORDER BY e.location <-> ST_GeogFromText($1) -- KNN, uses GiST index
LIMIT 20;
The KNN (<->) order uses the events_location_gix GiST index for
both the radius filter and the sort.
Sorting
Default Order
- Distance (nearest first)
- Time (soonest first)
.orderBy('distance', 'ASC')
.thenBy('startAt', 'ASC')
Alternative Sorts
| Sort | Field |
|---|---|
| Nearest | distance |
| Soonest | start_at |
| Newest | created_at |
| Popular | participants_count |
Query Parameters
| Parameter | Type | Description |
|---|---|---|
lat | number | Latitude |
lng | number | Longitude |
radius | number | Radius in meters (default: 5000) |
type | string | Event type filter |
friendsOnly | boolean | Only friends' events |
cursor | string | Pagination cursor |
limit | number | Page size (default: 20) |
startDate | date | Filter by date range |
endDate | date | Filter by date range |
Example Queries
Basic Feed
GET /events?lat=40.7128&lng=-74.0060&radius=5000
Friends Only
GET /events?lat=40.7128&lng=-74.0060&friendsOnly=true
By Type
GET /events?lat=40.7128&lng=-74.0060&type=party
Date Range
GET /events?lat=40.7128&lng=-74.0060&startDate=2026-05-01&endDate=2026-05-07
Response Format
{
"data": [
{
"id": "event-id",
"title": "Saturday Night Party",
"description": "Join us for a great time!",
"type": "party",
"startAt": "2026-05-15T20:00:00Z",
"location": {
"lat": 40.7128,
"lng": -74.006
},
"distance": 500,
"creator": {
"id": "user-id",
"name": "John"
}
}
],
"nextCursor": "encoded-cursor"
}
Personalization
Feed can be personalized based on user preferences:
interface UserEventPreferences {
preferredTypes: string[]; // e.g., ['party', 'dinner']
maleAgeRange: [number, number];
femaleAgeRange: [number, number];
radiusMeters: number;
organizerRadiusMeters: number;
}
Boosting Algorithm
Events matching user preferences get boosted:
boost_score = base_score * boost_factor
boost_factor = 1.0 + (matching_preferences * 0.1)
Max boost: 2x for matching all preferences.
Persistence
User filter preferences are stored per user. The table references
auth.users.id directly, like every other domain table:
CREATE TABLE user_event_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
start_date DATE,
end_date DATE,
start_time TIME,
end_time TIME,
male_age_from INT,
male_age_to INT,
female_age_from INT,
female_age_to INT,
range_meters_from INT,
range_meters INT,
organizer_range_from INT,
organizer_range INT,
types TEXT[], -- e.g. {'party','dinner'}
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Caching
There is no Redis in the stack. We rely on:
- Postgres + GiST + composite indexes for sub-100 ms feed queries.
- HTTP caching headers (
Cache-Control: private, max-age=…) on feed responses where appropriate. - Supabase Realtime to push deltas instead of having clients poll aggressively.
If a hot caching layer is later required, it will be added as a single explicit cache (e.g. Cloudflare KV / a single Postgres-backed materialized view), not as a hidden cross-cutting infrastructure dependency.