Skip to content

feat: platform-wide server-side full-text search across courses, lessons, wiki and forum - #1094

Merged
Anuoluwapo25 merged 6 commits into
bakeronchain:mainfrom
thebabalola:feat/issue-1079-platform-wide-search
Aug 26, 2026
Merged

Anuoluwapo25 merged 6 commits into
bakeronchain:mainfrom
thebabalola:feat/issue-1079-platform-wide-search

Conversation

@thebabalola

Copy link
Copy Markdown
Contributor

Closes #1079

Summary of Changes

Search now runs on the server. A Postgres full-text index covers courses, lessons, wiki pages, forum threads and public scholar profiles; a new GET /api/search endpoint returns ranked typed results with snippets and cursor pagination; the frontend dropdown queries it (keeping its debounce and abort behaviour) and a dedicated /search page handles full result sets with type filters.

What Changed

Search index (server/src/db/migrations/033_platform_search.sql + undo)

  • Generated tsvector columns weighted title (A) > summary/description/bio (B/C) with GIN indexes on all five content tables
  • Generated columns are computed by Postgres on every write, so the index cannot drift from source rows — stronger than triggers or a reindex path

API (server/src/controllers/search.controller.ts, routes/search.routes.ts)

  • websearch_to_tsquery bound as a parameter — quotes, &, |, ! follow Postgres websearch syntax and never reach SQL as text
  • One ranked UNION across the five types via ts_rank_cd, snippets via ts_headline with <mark> markers the frontend splits on
  • Keyset pagination on (negated rank, type, id) — stable order, no duplicates or gaps between pages
  • Visibility enforced in SQL: published_at IS NOT NULL for courses (lessons inherit through the join), is_published = TRUE for wiki, profiles require a display name. One honest scope note: forum threads have no moderation state in this schema yet (flagged_content covers comments/proposals only), so there is nothing to filter against there — the moment that exists, one WHERE clause in the forum branch picks it up.
  • Rate limited to 30 req/min, query capped at 200 chars, limit capped at 50, documented in docs/openapi.yaml

Frontend

  • GlobalSearch.tsx rewritten against the API — debounce and abort-on-retype kept, results grouped by type, arrows/enter/escape navigation with proper combobox ARIA roles, snippets highlighted by splitting on <mark> (no dangerouslySetInnerHTML), clearable recent searches in localStorage
  • New /search page for full result sets: type filter tabs and load-more cursor pagination
  • Also swapped the old search icon to lucide — the previous Icon.SearchMd from the design system doesn't exist in its export set

Testing / Local Verification

  • Server (15 tests): ranking proven by assertion (title match outranks body match), visibility filters present in the generated SQL, cross-page duplicate check, websearch-operator and 10KB-query sanitisation, cursor encoding
  • Frontend (6 tests): debounce-gated fetching, two-character minimum, abort-on-retype (asserted on real AbortSignals), arrow/enter keyboard flow into navigation, recent-search persistence, empty state
  • i18n: keys added to en/es/fr/sw, pseudo-locale regenerated, npm run i18n:scan clean
  • Full suites: frontend 372 passing (54 failures pre-exist on main and are untouched); server search suite fully green

…um and profiles

Generated tsvector columns weighted title > summary > body with GIN
indexes; being generated columns they can never drift from the source
rows, which is stronger than a trigger or reindex path.
- websearch_to_tsquery bound as a parameter so quotes/&/|/! never reach
  SQL as text; query capped at 200 chars, limit at 50, own rate limiter
- typed UNION across courses, lessons, wiki, forum and public profiles,
  weighted by ts_rank_cd with ts_headline snippets using <mark> markers
- visibility enforced in SQL: published courses only (lessons inherit via
  join), published wiki pages, profiles with a display name
- keyset pagination on (negated rank, type, id) — stable, no dup/gap
15 cases including the issue's required proofs: title match outranks
body match by assertion, unpublished content filters appear in the SQL,
pages share no duplicates, and websearch operators plus oversized queries
never error.
- dropdown now queries the server (debounce + abort kept), groups results
  by type, supports arrows/enter/escape with combobox ARIA roles, renders
  snippets via split-on-mark instead of dangerouslySetInnerHTML, and keeps
  clearable recent searches in localStorage
- enter opens the dedicated results page with type filter tabs and
  load-more cursor pagination
Six vitest cases covering debounce-gated fetching, two-char minimum,
request abort on retype, arrow/enter navigation, recent-search storage
and the empty state. Search strings added to en/es/fr/sw with the
pseudo-locale regenerated; npm run i18n:scan passes clean.
@bbkenny

bbkenny commented Aug 26, 2026

Copy link
Copy Markdown

@Anuoluwapo25 hi! This is ready for your review whenever you get a chance. It closes #1079 — Postgres full-text search across courses, lessons, wiki and forum with weighted ranking, a rate-limited GET /api/search with cursor pagination, visibility rules enforced in SQL, a rewritten keyboard-navigable GlobalSearch, a dedicated /search page, and i18n keys across all locales. Two scope notes are flagged in the description (forum moderation state doesn't exist in the schema yet, and size/index design choices). Happy to rework anything.

@Anuoluwapo25
Anuoluwapo25 merged commit 92bc029 into bakeronchain:main Aug 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Platform-wide search — server-side full-text search across courses, lessons, wiki and forum

3 participants