Feed Algorithm
| Status | Implementation |
|---|---|
| ✅ Shipped | src/feats/events/events.service.ts |
Feed geo queries use PostGIS ST_DWithin and ST_Distance functions.
The event feed contains published events with filtering and geo queries.
Query Logic
feed = events
.filter(status = 'published')
.filter(visibility = 'public')
.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
// The current API feed exposes public events only.
.where(eq(eventsTable.visibility, "public"))
The feed also excludes the viewer's own events, events where they are an
accepted author, and friends' events. friendsOnly is accepted for client
compatibility but is currently ignored.
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 | Accepted for compatibility; currently ignored |
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.