Discovery Module
Location: apps/api/src/modules/discovery/
Aggregate roots: ConnectionIntent
The Discovery context implements "Make Connections" (CON-01..03). Users declare a ConnectionIntent and are matched against compatible candidates based on shared interest count, intent compatibility, and optional geo/interest filters.
Intent Types
| Intent | Compatible With |
|---|---|
networking | networking |
friends | friends |
romantic | romantic |
mentor | mentee |
mentee | mentor |
collab | collab |
Prisma Model
connection_intents
| Field | Type | Notes |
|---|---|---|
| userId | UUID PK FK | One per user |
| intent | String | See intent types above |
| note | String? | Card text (shown in discovery feed) |
| isActive | Boolean | false = hidden from candidates |
| updatedAt | DateTime | |
| createdAt | DateTime |
Key Methods
| Method | Description |
|---|---|
upsertIntent(userId, dto) | Set/update intent |
getIntent(userId) | Get current intent |
getCandidates(userId, filters) | Ranked candidates by shared interest count |
getMentors(userId, filters) | Mentors with expertise gap ≥ minExpertiseGap |
getPeopleDirectory(userId, opts) | Browseable user directory with isFollowing field |
suggested(viewerId, limit) | "People you may know" via pgvector user-centroid ANN |
stream(viewerId, …) | Unified #Discover stream across all content types |
Candidate Ranking
getCandidates algorithm:
- Exclude: self, banned users, opted-out users (
isActive=false). - Filter by
intentCompatibility(viewerIntent). - Optional geo filter:
cityand/orcountry. - Optional interest filter:
interestSlug(must share that specific interest). - Count shared interests via
user_interestsjoin. - Sort: shared interest count DESC, then createdAt ASC.
- Paginate with cursor (userId-based).
Mentor Matching
getMentors finds users who:
- Have
ConnectionIntent.intent = 'mentor'andisActive = true. - Share at least one interest with the viewer.
- Mentor's
UserInterest.expertiseexceeds viewer's byminExpertiseGap(default 2, clamped 1..4). - Optional: filter by a specific
interestSlug.
HTTP Endpoints
All under /api/v1/discovery/. Require JwtAuthGuard.
| Method | Path | Description |
|---|---|---|
| PUT | /discovery/intent | Upsert my intent { intent, note?, isActive? } |
| GET | /discovery/intent | Get my intent |
| GET | /discovery/candidates | Ranked discovery candidates |
| GET | /discovery/mentors | Mentor matches ?interestSlug&minExpertiseGap |
| GET | /discovery/people | People directory ?q&cursor&limit |
| GET | /discovery/suggested | "People you may know" (centroid ANN) |
| GET | /discovery/talent | Talent directory |
| GET | /discovery/stream | Unified #Discover stream (9 content types) |
Query Parameters for /discovery/candidates
| Param | Type | Notes |
|---|---|---|
intent | String? | Override stored intent for query |
interestSlug | String? | Filter by shared interest |
country | String? | Geo filter |
city | String? | Geo filter |
minShared | Int? | Min shared interests (default 1) |
limit | Int? | Page size (default 20, max 50) |
cursor | String? | userId cursor for pagination |
People Directory
GET /discovery/people returns paginated users with:
id,username,firstName,lastName,profilePicUrlisFollowing: boolean— whether the viewer follows this usersharedInterests: number— count of shared interests
Used by the mobile "People" tab and contact picker.
Suggested People (centroid ANN)
GET /discovery/suggested powers "People you may know". It builds the viewer's preference centroid from their reactions + bookmarks and runs an approximate-nearest-neighbour search over user vectors (embeddings.similarUserIds). It gracefully degrades: when the viewer has no centroid yet (insufficient interaction history) or OPENAI_API_KEY is absent, the section simply hides — no error.
Semantic Feed Re-Rank
The home feed (in the Posts context) fetches a larger pool, scores it by engagement + depth alignment, then floats the top 40 % by semantic similarity (Math.ceil(limit * 0.4)) to the front via embeddings.reRankBySemantics. Embeddings use OpenAI text-embedding-3-small and the re-rank is a no-op when embeddings are disabled or the viewer has no preference vector — the engagement ranking stands on its own.
Unified #Discover Stream
GET /discovery/stream interleaves the Wave 3-6 content types into one chronological/ranked feed. The serializer emits 9 item types:
post · video · audio · event · debate · problem · course · project · offering
Each item carries actorId, interestId, createdAt, a sub badge, and (for media) a media URL. When video items are present, the serializer enriches them with a real engagement block — react / comment / repost counts plus the viewer's reacted / reposted state (computed via a group-by + viewer lookup, only when video items exist) — so feed video cards show live numbers with working toggles instead of a bare caption. Geo and trending filters apply across the stream.