Skip to main content

Feed Algorithm

Planned module

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

  1. Distance (nearest first)
  2. Time (soonest first)
.orderBy('distance', 'ASC')
.thenBy('startAt', 'ASC')

Alternative Sorts

SortField
Nearestdistance
Sooneststart_at
Newestcreated_at
Popularparticipants_count

Query Parameters

ParameterTypeDescription
latnumberLatitude
lngnumberLongitude
radiusnumberRadius in meters (default: 5000)
typestringEvent type filter
friendsOnlybooleanOnly friends' events
cursorstringPagination cursor
limitnumberPage size (default: 20)
startDatedateFilter by date range
endDatedateFilter 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.