Global Search — Architecture
Overview
This document covers the technical design of the Typesense-backed search system: collection schemas, the BullMQ sync pipeline, the Fastify API contract, and multi-tenant isolation. For the UI component and portal integration, see ui.md. For build steps and file structure, see implementation.md.
Typesense Concepts
| Concept | Analogy | Role here |
|---|---|---|
| Collection | Postgres table | One per entity type (e.g. blogs, contacts) |
| Document | Table row | One Typesense document per Postgres record |
| Schema | Column definitions | Declares which fields are searchable vs filterable |
| Multi-search | Joins / UNION | Single HTTP request that fans out across N collections |
| Filter field | WHERE clause | tenantId — used for isolation, never ranked |
Collection Schemas
Every collection includes tenantId as a facet: true (filterable but not ranked) field
and updatedAt as a sortable field. Text fields used for search have infix: true to
enable partial-word matching.
blogs
{
"name": "blogs",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "title", "type": "string", "infix": true },
{ "name": "metaDescription", "type": "string", "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}social_posts
{
"name": "social_posts",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "bodyText", "type": "string", "infix": true },
{ "name": "engagementHook", "type": "string", "optional": true },
{ "name": "platform", "type": "string", "facet": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}landing_pages
{
"name": "landing_pages",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "title", "type": "string", "infix": true },
{ "name": "metaDescription", "type": "string", "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}newsletters
{
"name": "newsletters",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "subject", "type": "string", "infix": true },
{ "name": "previewText", "type": "string", "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}activities
{
"name": "activities",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "label", "type": "string", "infix": true },
{ "name": "notes", "type": "string", "optional": true },
{ "name": "type", "type": "string", "facet": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}campaigns
{
"name": "campaigns",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "name", "type": "string", "infix": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}content_briefs
{
"name": "content_briefs",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "title", "type": "string", "infix": true },
{ "name": "topic", "type": "string", "optional": true },
{ "name": "angle", "type": "string", "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}contacts
{
"name": "contacts",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "name", "type": "string", "infix": true },
{ "name": "email", "type": "string", "infix": true, "optional": true },
{ "name": "company", "type": "string", "infix": true, "optional": true },
{ "name": "stage", "type": "string", "facet": true, "optional": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}leads
{
"name": "leads",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "name", "type": "string", "infix": true },
{ "name": "company", "type": "string", "infix": true, "optional": true },
{ "name": "jobTitle", "type": "string", "infix": true, "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}keywords
{
"name": "keywords",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "keyword", "type": "string", "infix": true },
{ "name": "source", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}reports
{
"name": "reports",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "label", "type": "string", "infix": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}backlinks
{
"name": "backlinks",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "tenantId", "type": "string", "facet": true },
{ "name": "sourceDomain", "type": "string", "infix": true },
{ "name": "anchorText", "type": "string", "infix": true, "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt", "type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}tenants (Manage only)
{
"name": "tenants",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "name", "type": "string", "infix": true },
{ "name": "website", "type": "string", "infix": true, "optional": true },
{ "name": "pocName", "type": "string", "infix": true, "optional": true },
{ "name": "industry", "type": "string", "facet": true, "optional": true },
{ "name": "status", "type": "string", "facet": true },
{ "name": "updatedAt","type": "int64", "sort": true }
],
"default_sorting_field": "updatedAt"
}Sync Pipeline
Queue
A new BullMQ queue search__sync handles all Typesense sync jobs. The job payload is
intentionally minimal — the worker fetches the full record from Postgres itself to avoid
stale data if the job is delayed.
// packages/queue/src/types.ts (add to existing SearchSyncJob type)
export type SearchSyncJob = {
collection: SearchCollection // "blogs" | "contacts" | ...
operation: "upsert" | "delete"
recordId: string
tenantId: string
}
export type SearchCollection =
| "blogs" | "social_posts" | "landing_pages" | "newsletters"
| "activities" | "campaigns" | "content_briefs" | "contacts"
| "leads" | "keywords" | "reports" | "backlinks" | "tenants"Worker flow
search__sync job received
↓
if operation === "upsert"
→ fetch record from Postgres (db.blogPost.findUnique etc.)
→ if not found: skip (record deleted before job ran)
→ map Postgres record → Typesense document shape
→ client.collections("blogs").documents().upsert(document)
if operation === "delete"
→ client.collections("blogs").documents(recordId).delete()
→ catch 404: ignore (already deleted)Where sync calls are added
Every location that creates, updates, or deletes a searchable entity must call
enqueueSearchSync after the Prisma write. The key locations are:
| File | Entities touched |
|---|---|
apps/api/src/routers/blog.ts | BlogPost |
apps/api/src/routers/social.ts | SocialPost |
apps/api/src/routers/landing-pages.ts | LandingPage |
apps/api/src/routers/newsletter.ts | EmailNewsletter |
apps/api/src/routers/campaigns.ts | Campaign |
apps/api/src/routers/contacts.ts | Contact |
apps/api/src/routers/leads.ts | Lead |
apps/api/src/routers/keywords.ts | Keyword |
apps/api/src/routers/dm/blog.ts | BlogPost (DM mutations) |
apps/api/src/routers/dm/campaigns.ts | Campaign (DM mutations) |
apps/api/src/routers/dm/contacts.ts | Contact (DM mutations) |
apps/api/src/routers/dm/content-briefs.ts | ContentBrief |
apps/api/src/routers/admin/tenants.ts | Tenant |
packages/agents/src/workers/blog-writer.worker.ts | BlogPost (agent output) |
packages/agents/src/workers/social-post-writer.worker.ts | SocialPost (agent output) |
packages/agents/src/workers/landing-page-writer.worker.ts | LandingPage (agent output) |
packages/agents/src/workers/report-writer.worker.ts | Report (agent output) |
apps/dashboard/src/app/(dashboard)/*/actions.ts | Any server action that mutates above entities |
Pattern for API routers:
// After the Prisma write
await enqueueSearchSync({
collection: "blogs",
operation: "upsert", // or "delete"
recordId: post.id,
tenantId: tenantId,
})
// Fire-and-forget — never await in request handler; queue handles retriesAPI Contract
Tenant-scoped search — Dashboard and DM
POST /v1/search
Authorization: Bearer <jwt>
Body:
{
"query": "acme blog strategy",
"entityTypes": ["blogs", "contacts"], // optional; omit to search all
"limit": 20, // default 20, max 50
"offset": 0
}
Response 200:
{
"results": [
{
"type": "blogs",
"id": "clxxx",
"title": "Q2 SEO Strategy for Acme Corp",
"subtitle": "draft · updated 2 days ago",
"href": "/blog/clxxx",
"status": "draft",
"updatedAt": "2026-04-22T10:30:00Z"
}
],
"total": 47,
"hasMore": true
}tenantId is never accepted from the request body. It is read from the validated JWT and
injected as filter_by by the router.
Cross-tenant search — Manage
POST /v1/admin/search
Authorization: Bearer <jwt> (requires super_admin role)
Body: same shape as above, plus optional:
{
"tenantId": "clyyy" // optional — scope to a specific tenant
}
Response: same shape, plus each result includes:
{
"tenantId": "clyyy",
"tenantName": "Acme Corp"
}Error responses
| Status | When |
|---|---|
| 400 | query is missing or shorter than 2 characters |
| 401 | Missing or invalid JWT |
| 403 | Non-super-admin hitting /v1/admin/search |
| 503 | Typesense unreachable — returns { "error": "search_unavailable" } |
The 503 case is handled gracefully in the UI (search bar shows “Search unavailable” and does not crash the page).
Multi-Tenant Isolation
All documents in every collection carry a tenantId field declared as facet: true.
The search router injects this as a hard filter on every Typesense query:
// Tenant-scoped route
const searchParams = {
q: query,
query_by: "title,metaDescription",
filter_by: `tenantId:=${tenantId}`, // injected from JWT — never from body
per_page: limit,
offset,
}For Manage cross-tenant searches the filter is omitted entirely (or narrowed to a specific
tenantId if the admin passes one explicitly).
A tenant user cannot access another tenant’s documents — even if they craft a raw Typesense request — because the Typesense admin key is never exposed to the browser. All searches go through the Fastify proxy which enforces the filter.
Typesense Multi-Search
A single POST /multi_search call to Typesense fans out across all active collections
and returns results per collection. The Fastify router assembles them into the unified
response shape.
const searches = activeCollections.map((collection) => ({
collection,
q: query,
query_by: QUERY_FIELDS[collection], // per-collection text fields
filter_by: `tenantId:=${tenantId}`,
per_page: Math.ceil(limit / activeCollections.length),
sort_by: "_text_match:desc,updatedAt:desc",
}))
const { results } = await typesenseClient.multiSearch.perform({ searches })Results from all collections are merged, de-duplicated, and sorted by Typesense text match score before being returned to the client.
Consistency Model
Typesense is eventually consistent with Postgres. The sync lag is typically 100–500ms (BullMQ job pickup time). This means:
- A newly created record will not appear in search results until the sync job completes.
- A deleted record may briefly still appear; clicking the link will return a 404, which the UI should handle gracefully.
- Agent-written content (blog-writer, social-post-writer, etc.) is indexed when the agent
worker calls
enqueueSearchSyncafter its Prisma write.
This is acceptable. Search is a discovery tool, not a real-time feed.