Skip to content

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

typescript
export async function discoverProfiles(
  userId: string,
  page: number,
  limit: number,
  filters?: {
    gender?: Gender;
    verificationLevel?: string;
  }
): Promise<PaginatedResponse<ProfilePreview>>

Step 1: Get User's Gender

typescript
const user = await prisma.user.findUnique({
  where: { id: userId },
  select: { gender: true },
});

Step 2: Determine Target Gender

typescript
// 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

typescript
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):

typescript
orderBy: [
  { verificationLevel: 'desc' },  // FULL > IDENTITY > NONE
  { trustScore: 'desc' },          // Higher trust first
  { lastActiveAt: 'desc' },        // Most recently active
],
PriorityFieldRationale
1Verification LevelVerified users are more valuable
2Trust ScoreHigher trust = safer interaction
3Last ActiveActive users more likely to respond

Step 5: Return Profile Previews

typescript
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

typescript
export async function getProfileById(
  userId: string, 
  profileId: string
): Promise<ProfileWithMatchInfo>

Step 1: Fetch Profile

typescript
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

typescript
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

typescript
// 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 StatusPhoto Blur
Not matchedMin 80% (can be higher based on user setting)
MatchedUser's original setting (0-100%)

Step 4: Apply Online Status Privacy

typescript
return {
  ...profile,
  photos,
  isMutualMatch,
  lastActiveAt: profile.settings?.showOnlineStatus 
    ? profile.lastActiveAt 
    : null,
};

Interest Expression

File: src/services/match.service.ts

expressInterest

typescript
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

typescript
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

typescript
export async function getMatches(
  userId: string, 
  page: number, 
  limit: number
): Promise<PaginatedResponse<Match>>

Query:

typescript
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:

typescript
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

typescript
export async function declineMatch(
  userId: string, 
  matchId: string
): Promise<void>

Process:

  1. Verify match belongs to user (sender or recipient)
  2. Delete the interest record
typescript
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

FilterTypeDefaultNotes
genderGenderOpposite of userAutomatic
verificationLevelEnumNoneOptional
pageNumber1Pagination
limitNumber20Max 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

prisma
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

sql
-- 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

sql
-- 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

sql
-- 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:

typescript
skip: (page - 1) * limit,
take: limit,

For large user bases (100k+), consider cursor-based pagination for better performance.


API Integration

Discovery Endpoint

typescript
// GET /api/discover?page=1&limit=20&verificationLevel=FULL
router.get(
  '/',
  validate(paginationSchema),
  profileController.discover
);

Express Interest Endpoint

typescript
// POST /api/profiles/:id/interest
router.post(
  '/:id/interest',
  validate(idParamSchema),
  profileController.expressInterest
);

Response Examples

Discovery Response:

json
{
  "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):

json
{
  "success": true,
  "data": {
    "matched": true
  },
  "message": "It's a match!"
}

Interest Response (Pending):

json
{
  "success": true,
  "data": {
    "matched": false
  },
  "message": "Interest expressed"
}

One chat. Everything done.