Documentation Index
Fetch the complete documentation index at: https://mintlify.com/IvanchoDev89/maleku-system/llms.txt
Use this file to discover all available pages before exploring further.
Maleku System provides two complementary discovery surfaces: a full-text search engine built on PostgreSQL’s tsvector / tsquery system for keyword-driven queries, and a map search endpoint that returns geo-located property and tour data for interactive map rendering. Both surfaces are powered by the same FastAPI router (app/api/v1/search.py) and SearchService class, rate-limited via SlowAPI, and available without authentication for public browsing.
Search Endpoint
The global search endpoint scans properties, tours, destinations, and blog posts in parallel using ILIKE pattern matching on names and titles.
GET /api/v1/search/?q={query}&limit={n}
| Parameter | Type | Description |
|---|
q | string (required) | Search query, 1–200 characters. Sanitised and stripped before use. |
limit | integer | Maximum results per category (default 10, max 50). |
The response groups results by content type:
{
"properties": [{ "id": "...", "name": "...", "slug": "...", "type": "property", "image": "..." }],
"tours": [{ "id": "...", "name": "...", "slug": "...", "type": "tour", "image": "..." }],
"destinations": [...],
"blog": [...]
}
For property-specific full-text search with ranking, filters, and pagination use:
GET /api/v1/search/properties?q={query}&property_type=hotel&category=beach®ion=Guanacaste&min_price=50&max_price=300&limit=20
| Parameter | Type | Description |
|---|
q | string (required) | Full-text search query |
property_type | string | Filter by PropertyType enum value (e.g. hotel, villa, eco_lodge) |
category | string | Filter by PropertyCategory (e.g. beach, mountain, jungle) |
region | string | Partial-match filter on the property’s region field |
min_price | float | Minimum base_price |
max_price | float | Maximum base_price |
limit | integer | Max results (default 20, max 100) |
Full-Text Search
The SearchService class uses PostgreSQL’s native full-text search functions rather than LIKE queries, giving relevance-ranked results with language-aware stemming.
How it works:
- The search query is converted to a
tsquery via plainto_tsquery('spanish', query), which tokenises, stems, and handles phrase variants for Spanish-language content.
- A
tsvector is computed on-the-fly by concatenating the searchable columns (name, description, city, region for properties; name, description, location for tours; title, content, excerpt for blog posts).
- The
@@ operator checks for matches, and ts_rank(tsvector, tsquery) produces a relevance score used for ORDER BY rank DESC.
# From search_service.py (property search)
tsquery = func.plainto_tsquery("spanish", query)
tsvector = func.to_tsvector(
"spanish",
func.coalesce(Property.name, "")
+ " " + func.coalesce(Property.short_description, "")
+ " " + func.coalesce(Property.description, "")
+ " " + func.coalesce(Property.city, "")
+ " " + func.coalesce(Property.region, ""),
)
stmt = select(Property, func.ts_rank(tsvector, tsquery).label("rank")).where(
tsvector.op("@@")(tsquery),
Property.deleted_at.is_(None),
Property.is_active,
)
GIN Indexes are defined on Property, Tour, and BlogPost models to make these tsvector queries efficient at scale:
# From models/property.py
Index(
"idx_property_fts",
sa.text(
"to_tsvector('spanish', COALESCE(name, '') || ' ' || "
"COALESCE(short_description, '') || ' ' || "
"COALESCE(description, '') || ' ' || "
"COALESCE(city, '') || ' ' || COALESCE(region, ''))"
),
postgresql_using="gin",
)
Similar idx_tour_fts and blog GIN indexes are defined on their respective models.
The escape_like_pattern() utility from app/core/utils.py is used for all ILIKE queries throughout the codebase (tours list endpoint, global search, vehicle/boat search) to prevent SQL injection via unescaped % and _ characters.
Map Search
The map endpoint returns all geo-located properties and tours in a single paginated response optimised for rendering map pins.
GET /api/v1/search/map?property_type=&category=®ion=&min_price=&max_price=&page=1&page_size=50
| Parameter | Type | Description |
|---|
property_type | string | Optional filter by property type |
category | string | Optional filter by property or tour category |
region | string | Optional filter by region |
min_price / max_price | float | Price range filter |
page | integer | Page number (default 1) |
page_size | integer | Results per page (default 50, max 200) |
Only properties with non-null latitude and longitude are included in the map results. Tours are included regardless of coordinates (they use the location text field for display).
A companion endpoint GET /api/v1/search/map/count returns aggregate counts grouped by region and tour category, useful for rendering cluster badges on the map.
The Nuxt.js 3 frontend renders these map API responses using Leaflet.js (v1.9.4). Property and tour items from the /search/map endpoint are mapped directly to Leaflet markers. The latitude/longitude fields on MapPropertyItem drive pin placement, while cover_image, base_price, and rating populate the marker popup cards.
Search Result Schemas
MapPropertyItem
Returned by GET /api/v1/search/map for each property.
| Field | Type | Description |
|---|
id | string | Property UUID |
name | string | Property display name |
slug | string | URL-friendly identifier |
property_type | string | e.g. hotel, eco_lodge, villa |
category | string | null | Location category (e.g. beach, mountain) |
region / city | string | null | Geographic location fields |
address | string | null | Full street address |
latitude / longitude | float | null | Map coordinates |
cover_image | string | null | Primary image URL |
images | string[] | Up to 4 image URLs |
base_price | float | Starting nightly rate |
weekend_price | float | Weekend nightly rate |
rating | float | null | Average review rating (0–5) |
total_reviews | int | null | Number of reviews |
min_guests / max_guests | int | Minimum and maximum guest capacity |
amenities | string[] | Up to 8 amenity tags |
MapTourItem
| Field | Type | Description |
|---|
id | string | Tour UUID |
name | string | Tour display name |
slug | string | URL-friendly identifier |
category | string | null | e.g. adventure, nature, water |
difficulty | string | null | easy, medium, or hard |
location | string | null | Text location / region |
latitude / longitude | float | null | Coordinates (currently null; resolved from location) |
cover_image | string | null | Primary image URL |
images | string[] | Up to 4 image URLs |
price | float | Per-person price |
duration_hours | float | Tour duration in hours |
rating | float | null | Average review rating |
max_group_size | int | Maximum participants per booking |
Full-Text TextSearchItem
Returned by GET /api/v1/search/properties.
| Field | Type | Description |
|---|
id | string | Property UUID |
name | string | Property name |
slug | string | URL slug |
property_type / category | string | null | Type and category |
region / city | string | null | Location |
cover_image | string | null | Primary image URL |
base_price | float | Starting nightly rate |
rating | float | null | Average rating |
total_reviews | int | null | Review count |
match_type | string | Always "text" for full-text search results |
rank | float | PostgreSQL ts_rank relevance score |
Tour-Specific Filters
The GET /api/v1/tours/ listing endpoint supports a richer filter set specifically for tour discovery. These parameters were explicitly added and validated in the project changelog:
| Parameter | Type | Description |
|---|
q | string | Text search across name and description using ILIKE |
destination | string | Alias for location; partial match on the tour’s location field |
min_duration | float | Minimum duration_hours |
max_duration | float | Maximum duration_hours |
min_rating | float | Minimum average rating (alias for rating) |
sort | string | Sort order: popular, price_asc, price_desc, rating, newest |
Both rating and min_rating resolve to the same filter (Tour.rating >= value); the API accepts either parameter name for compatibility.
Rate Limiting
All search endpoints are rate-limited using SlowAPI (limiter from app/core/rate_limiter.py). The default limit is 30 requests per minute per IP address:
@router.get("/properties")
@limiter.limit("30/minute")
async def search_properties(request: Request, ...):
...
The same 30 req/min limit applies to GET /api/v1/search/map, GET /api/v1/search/map/count, and GET /api/v1/search/. Requests exceeding the limit receive a 429 Too Many Requests response.
The price preview endpoint (POST /api/v1/bookings/preview) uses a separate limit of 30 req/min. Other endpoints across the platform use the global default of 60 req/min.
Example Search Request
# Full-text property search with filters
curl -G "http://localhost:8000/api/v1/search/properties" \
--data-urlencode "q=jungle lodge" \
--data-urlencode "category=jungle" \
--data-urlencode "region=Osa" \
--data-urlencode "min_price=80" \
--data-urlencode "max_price=400" \
--data-urlencode "limit=10"
# Map data for Guanacaste, beach properties only
curl "http://localhost:8000/api/v1/search/map?region=Guanacaste&category=beach&page=1&page_size=50"
# Global search across all content types
curl -G "http://localhost:8000/api/v1/search/" \
--data-urlencode "q=Manuel Antonio" \
--data-urlencode "limit=5"