Zaptam Matching Algorithm
Deep dive into how users discover and match with each other.
Overview
Zaptam uses a mutual interest matching system. Both users must express interest in each other to become a "match" and unlock messaging.
┌─────────┐ Interest ┌─────────┐
│ User A │ ────────────────────────▶│ User B │
│ │ │ │
│ │◀──────────────────────── │ │
└─────────┘ Reciprocal Interest └─────────┘
│
▼
┌─────────────┐
│ MATCH! │
│ Conversation │
│ Created │
└─────────────┘Discovery Algorithm
File: src/services/match.service.ts
Profile Discovery Flow
export async function discoverProfiles(
userId: string,
page: number,
limit: number,
filters?: {
gender?: Gender;
verificationLevel?: string;
}
): Promise<PaginatedResponse<ProfilePreview>>Step 1: Get User's Gender
const user = await prisma.user.findUnique({
where: { id: userId },
select: { gender: true },
});Step 2: Determine Target Gender
// Default: show opposite gender
const targetGender = filters?.gender ||
(user.gender === 'MALE' ? 'FEMALE' : 'MALE');Logic:
- Male users see female profiles by default
- Female users see male profiles by default
- Filter can override (but this is not exposed in UI currently)
Step 3: Build Query Filter
const whereClause = {
id: { not: userId }, // Exclude self
status: 'ACTIVE', // Only active users
gender: targetGender, // Target gender
...(filters?.verificationLevel && {
verificationLevel: filters.verificationLevel,
}),
};Step 4: Order Results
Priority order (most important first):
orderBy: [
{ verificationLevel: 'desc' }, // FULL > IDENTITY > NONE
{ trustScore: 'desc' }, // Higher trust first
{ lastActiveAt: 'desc' }, // Most recently active
],| Priority | Field | Rationale |
|---|---|---|
| 1 | Verification Level | Verified users are more valuable |
| 2 | Trust Score | Higher trust = safer interaction |
| 3 | Last Active | Active users more likely to respond |
Step 5: Return Profile Previews
const profiles = await prisma.user.findMany({
where: whereClause,
select: {
id: true,
alias: true,
gender: true,
bio: true,
trustScore: true,
verificationLevel: true,
photos: {
select: {
id: true,
url: true,
blurLevel: true,
isPrimary: true,
},
orderBy: { order: 'asc' },
},
},
orderBy: [...],
skip: (page - 1) * limit,
take: limit,
});Profile View
File: src/services/match.service.ts
getProfileById
export async function getProfileById(
userId: string,
profileId: string
): Promise<ProfileWithMatchInfo>Step 1: Fetch Profile
const profile = await prisma.user.findUnique({
where: { id: profileId, status: 'ACTIVE' },
select: {
id: true,
alias: true,
gender: true,
bio: true,
trustScore: true,
verificationLevel: true,
lastActiveAt: true,
photos: { ... },
settings: {
select: {
showOnlineStatus: true,
regionMasking: true,
},
},
},
});Step 2: Check Match Status
const mutualInterest = await prisma.interest.findFirst({
where: {
OR: [
{ senderId: userId, recipientId: profileId, status: 'ACCEPTED' },
{ senderId: profileId, recipientId: userId, status: 'ACCEPTED' },
],
},
});
const isMutualMatch = !!mutualInterest;Step 3: Apply Photo Privacy
// If no match, enforce minimum blur
const photos = isMutualMatch
? profile.photos // Original blur levels
: profile.photos.map((photo) => ({
...photo,
blurLevel: Math.max(photo.blurLevel, 80), // At least 80% blur
}));Photo Blur Logic:
| Match Status | Photo Blur |
|---|---|
| Not matched | Min 80% (can be higher based on user setting) |
| Matched | User's original setting (0-100%) |
Step 4: Apply Online Status Privacy
return {
...profile,
photos,
isMutualMatch,
lastActiveAt: profile.settings?.showOnlineStatus
? profile.lastActiveAt
: null,
};Interest Expression
File: src/services/match.service.ts
expressInterest
export async function expressInterest(
senderId: string,
recipientId: string
): Promise<{ matched: boolean }>Flow Diagram
expressInterest(A, B)
│
▼
┌───────────────────────┐
│ Self-interest? │──── YES ──▶ BadRequestError
│ senderId == recipId │
└───────────────────────┘
│ NO
▼
┌───────────────────────┐
│ Recipient exists? │──── NO ──▶ NotFoundError
│ status == ACTIVE │
└───────────────────────┘
│ YES
▼
┌───────────────────────┐
│ Already expressed? │──── YES ──▶ BadRequestError
│ Interest(A → B) exists│ "Already expressed interest"
└───────────────────────┘
│ NO
▼
┌───────────────────────┐
│ Reciprocal exists? │
│ Interest(B → A) && │
│ status == PENDING │
└───────────────────────┘
│ │
YES NO
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ MATCH! │ │ CREATE │
│ │ │ PENDING │
│ 1. Update │ │ Interest │
│ B→A to │ │ │
│ ACCEPTED │ │ │
│ │ │ │
│ 2. Create │ │ │
│ A→B as │ │ │
│ ACCEPTED │ │ │
│ │ │ │
│ 3. Create │ │ │
│ Convo │ │ │
└─────────────┘ └─────────────┘
│ │
▼ ▼
return { matched: true } return { matched: false }Match Transaction
if (reciprocalInterest && reciprocalInterest.status === 'PENDING') {
// Mutual match!
await prisma.$transaction([
// Accept the reciprocal interest
prisma.interest.update({
where: { id: reciprocalInterest.id },
data: { status: 'ACCEPTED' },
}),
// Create new interest as ACCEPTED
prisma.interest.create({
data: {
senderId,
recipientId,
status: 'ACCEPTED',
},
}),
// Create conversation for matched users
prisma.conversation.create({
data: {
participant1Id: senderId,
participant2Id: recipientId,
},
}),
]);
return { matched: true };
}
// No reciprocal — create pending
await prisma.interest.create({
data: {
senderId,
recipientId,
status: 'PENDING',
},
});
return { matched: false };Match Management
Getting Matches
export async function getMatches(
userId: string,
page: number,
limit: number
): Promise<PaginatedResponse<Match>>Query:
const matches = await prisma.interest.findMany({
where: {
OR: [
{ senderId: userId, status: 'ACCEPTED' },
{ recipientId: userId, status: 'ACCEPTED' },
],
},
include: {
sender: {
select: {
id: true,
alias: true,
bio: true,
trustScore: true,
verificationLevel: true,
photos: {
where: { isPrimary: true },
take: 1,
},
},
},
recipient: {
select: { ... },
},
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
});Transform to show other user:
const items = matches.map((match) => {
const otherUser = match.senderId === userId
? match.recipient
: match.sender;
return {
id: match.id,
matchedAt: match.createdAt,
user: otherUser,
};
});Declining/Unmatching
export async function declineMatch(
userId: string,
matchId: string
): Promise<void>Process:
- Verify match belongs to user (sender or recipient)
- Delete the interest record
const interest = await prisma.interest.findFirst({
where: {
id: matchId,
OR: [{ senderId: userId }, { recipientId: userId }],
},
});
if (!interest) {
throw new NotFoundError('Match not found');
}
await prisma.interest.delete({ where: { id: matchId } });Note: This only deletes one interest record. The conversation and messages remain (user can still view history but won't be able to send new messages unless they match again).
Discovery Filters
Current Implementation
| Filter | Type | Default | Notes |
|---|---|---|---|
| gender | Gender | Opposite of user | Automatic |
| verificationLevel | Enum | None | Optional |
| page | Number | 1 | Pagination |
| limit | Number | 20 | Max 100 |
Future Considerations
Potential filters not yet implemented:
- Age range (from dateOfBirth)
- Location/region (requires location data)
- Net worth range (for male profiles)
- Trust score minimum
- Last active within X days
Interest States
InterestStatus Enum
enum InterestStatus {
PENDING // Interest expressed, awaiting reciprocation
ACCEPTED // Mutual match
DECLINED // Rejected (not currently used)
}State Transitions
express interest
NONE ────────────────────────▶ PENDING
reciprocate
PENDING ─────────────────────▶ ACCEPTED
unmatch
ACCEPTED ────────────────────▶ (deleted)Database Constraints
Interest Table
-- Unique constraint prevents duplicate interests
@@unique([senderId, recipientId])This means:
- User A can only express interest in User B once
- User B can independently express interest in User A
- Both records together represent a "match"
Conversation Table
-- Unique constraint prevents duplicate conversations
@@unique([participant1Id, participant2Id])This means:
- Only one conversation per user pair
- Ordering doesn't matter (participant1 vs participant2)
Performance Considerations
Indexes
-- Users table
@@index([status]) -- Filter active users
@@index([gender]) -- Filter by gender
@@index([phoneNumber]) -- Login lookup
-- Discovery query benefits from these indexes
-- ORDER BY uses: verificationLevel, trustScore, lastActiveAt
-- Consider adding composite index for discovery:
-- @@index([status, gender, verificationLevel, trustScore, lastActiveAt])Pagination
Discovery uses offset-based pagination:
skip: (page - 1) * limit,
take: limit,For large user bases (100k+), consider cursor-based pagination for better performance.
API Integration
Discovery Endpoint
// GET /api/discover?page=1&limit=20&verificationLevel=FULL
router.get(
'/',
validate(paginationSchema),
profileController.discover
);Express Interest Endpoint
// POST /api/profiles/:id/interest
router.post(
'/:id/interest',
validate(idParamSchema),
profileController.expressInterest
);Response Examples
Discovery Response:
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"alias": "mysterygirl",
"gender": "FEMALE",
"bio": "Looking for meaningful connections",
"trustScore": 85,
"verificationLevel": "FULL",
"photos": [
{
"id": "uuid",
"url": "https://r2.zaptam.com/photos/...",
"blurLevel": 100,
"isPrimary": true
}
]
}
],
"total": 150,
"page": 1,
"limit": 20,
"totalPages": 8
}
}Interest Response (Match):
{
"success": true,
"data": {
"matched": true
},
"message": "It's a match!"
}Interest Response (Pending):
{
"success": true,
"data": {
"matched": false
},
"message": "Interest expressed"
}