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.