Skip to content

[SEARCH] Add Typesense to docker-compose + backend proxy endpoint #40

Description

@BAWES

Context

We previously implemented Meilisearch as a self-hosted Algolia replacement (see meilisearch branch). The implementation worked — search results returned correctly — but the filter/facet UI was broken because Meilisearch's facetDistribution only returns counts for the unfiltered universe. Once a filter is applied, counts for other values of that same attribute disappear, making the filter drawer unusable.

Typesense is the correct replacement because:

  1. It returns facet counts for all values regardless of active filters — correct behavior for a filter drawer
  2. It has an Algolia-compatible API adapter (typesense-instantsearch-adapter) for future use
  3. Single Docker binary, same operational simplicity as Meilisearch
  4. The existing SearchState interface and CandidateSearchService are fully reusable — only the HTTP layer changes

This issue covers backend and infrastructure only. No frontend changes.

Scope

1. docker-compose.yml

Add Typesense service:

typesense:
  image: typesense/typesense:27.1
  restart: unless-stopped
  ports:
    - "8108:8108"
  volumes:
    - ./typesense-data:/data
  command: --data-dir /data --api-key=${TYPESENSE_API_KEY} --enable-cors
  environment:
    - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}

Add to .env.example:

TYPESENSE_API_KEY=your-admin-key-here
TYPESENSE_HOST=typesense
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http

2. Backend: /search/key endpoint

Replace /meilisearch/key with /search/key.

Returns a scoped search-only API key (never the admin key):

{
  "apiKey": "<scoped-search-key>",
  "apiKeyValidUntil": 1234567890,
  "host": "typesense.yourdomain.com",
  "port": 443,
  "protocol": "https"
}

Generate scoped key server-side using Typesense SDK:

const client = new Typesense.Client({ ... });
const scopedKey = client.keys().generateScopedSearchKey(
  process.env.TYPESENSE_API_KEY,
  { filter_by: '', expires_at: Math.floor(Date.now()/1000) + 3600 }
);

3. Backend: /search/search endpoint

Replace /meilisearch/search with /search/search.

Accepts the same request body shape that CandidateSearchService already sends:

{
  collectionName: string;  // was: indexName
  query?: string;
  filters?: Record<string, string[]>;
  sort?: string[];
  page?: number;
  hitsPerPage?: number;
  facets?: string[];
}

Filter syntax translation (backend responsibility):

// Input from frontend: { gender: ['Male'], university_id: ['1', '2'] }
// Output to Typesense:
const filterBy = Object.entries(filters)
  .map(([attr, vals]) => `${attr}:=[${vals.join(',')}]`)
  .join(' && ');
// Result: "gender:=[Male] && university_id:=[1,2]"

Returns normalized response:

{
  hits: any[];
  pagination: { total, page, hitsPerPage, totalPages };
  facets: Record<string, Record<string, number>>; // always populated regardless of active filters
  processingTimeMs: number;
  query: string;
}

4. Index schema for candidates

Define Typesense collection schema for candidates index.
Key fields to mark as facet: true:

  • candidate_gender
  • candidate_driving_license
  • university.university_id
  • bank.bank_id
  • have_video, have_resume, candidate_committed, assigned
  • candidate_mom_kuwaiti, isProfileCompleted
  • currency_code, country.country_id, store.store_id

5. Index sync script

Create a one-time migration script scripts/sync-to-typesense.ts that reads from the existing data source and bulk-imports into Typesense.

Acceptance Criteria

  • docker-compose up starts Typesense alongside the app
  • GET /search/key returns a valid scoped search key
  • POST /search/search with a query returns results in normalized format
  • Facet counts are returned for all facet values, including those currently filtered
  • Candidate collection schema is defined with all required facet fields
  • One-time sync script can import all candidates from existing DB to Typesense
  • No admin Typesense key is exposed to the frontend at any point

Branch

Branch from master. Name: feature/typesense-infra

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions