Skip to content

Semantic Search, Personalised Feed, Trending Algorithm & Embeddable Discovery Widget #690

Description

@grantfox-oss

Overview

CrowdPay currently has no discovery layer — contributors must know the direct URL of a campaign to reach it. There is no search, no trending page, no personalisation, and no way for campaigns to surface organically on third-party sites. A crowdfunding platform lives and dies by discovery. This issue implements the complete discovery stack: a full-text and semantic search engine over campaign metadata, a personalised campaign feed based on contribution history and asset preferences, a trending algorithm that balances recent velocity with absolute funding progress, and an embeddable discovery widget that any website can drop in to surface live CrowdPay campaigns matching a topic or asset filter.

What needs to be built

backend/src/services/search.js — Search and indexing engine

  • Full-text search index: on campaign creation and update, write { id, title, description, category, tags, targetAsset, goalAmountUsd, totalRaisedUsd, status, creatorPublicKey, createdAt } to a PostgreSQL campaigns_search table using tsvector with setweight ranking (title = A, tags = B, description = C)

  • GET /api/campaigns/search?q=<query>&asset=<asset>&category=<category>&status=active|completed|all&minGoal=<n>&maxGoal=<n>&page=<n>&limit=<n>:

    • Executes a to_tsquery query against the campaigns_search index
    • Applies asset, category, status, and goal range filters
    • Returns campaigns ranked by ts_rank_cd (text relevance) × funding_recency_score (higher score for campaigns with recent contributions)
    • Also returns facets: { byAsset: [{ asset, count }], byCategory: [{ category, count }], byStatus: [{ status, count }] } — computed in the same query for fast faceted navigation
  • GET /api/campaigns/trending:

    • Trending score formula per campaign: (contributions_last_24h / max_contributions_24h * 40) + (percent_funded * 30) + (unique_contributors_last_7d / max_contributors_7d * 20) + (days_until_deadline_score * 10)
    • days_until_deadline_score: campaigns with 1–7 days remaining score highest (urgency boost); campaigns with > 30 days remaining score lowest
    • Returns top 20 campaigns by trending score; recomputed every 15 minutes via a BullMQ job and cached in Redis
  • GET /api/campaigns/personalised — authenticated; returns a feed personalised to the contributor:

    • Categories of campaigns the contributor has previously funded (weighted by amount)
    • Assets the contributor has contributed in (weighted by frequency)
    • Geographic proximity inferred from federation addresses in the contributor's history
    • Excludes campaigns the contributor has already funded and campaigns that do not meet the contributor's identity requirements
    • Falls back to trending campaigns for unauthenticated or new users with no history
      backend/src/services/searchIndex.js — Index maintenance
  • BullMQ job SearchIndexJob triggered on: campaign creation, campaign update (title/description/tags change), contribution landed (updates totalRaisedUsd and percentFunded), campaign status change

  • Full re-index job runs nightly: truncates campaigns_search and rebuilds from campaigns table — ensures no stale data

  • EXPLAIN ANALYZE query plan logged for any search query that takes > 200ms — used to detect index degradation
    backend/src/routes/embed.js — Discovery widget API

  • GET /api/embed/discover?topic=<topic>&asset=<asset>&limit=<n>&embedToken=<token> — public endpoint validated by embed token:

    • Runs the search query with the provided topic and asset filter
    • Returns a stripped response: { campaigns: [{ id, title, description_truncated, goalAmountUsd, totalRaisedUsd, percentFunded, daysRemaining, asset, status, shareUrl }] } — no internal IDs, no creator email
    • Rate limited: 100 requests per hour per embed token
      frontend/src/pages/Discover.jsx — Discovery page
  • Hero search bar: full-text input with live suggestions (debounced 300ms; calls search API with limit=5 for autocomplete)

  • Filters sidebar:

    • Asset filter: multi-select (XLM, USDC, EURC, other)
    • Category filter: multi-select (generated from the facets response)
    • Goal range: dual-handle slider ($0 – $100,000+)
    • Status: Active / Completed / All
    • "Clear filters" button
  • Campaign grid: responsive 3-column layout; each campaign card shows title, description excerpt, progress bar, days remaining, asset badge, funding percentage

  • Trending carousel above the grid: horizontal scroll of top 10 trending campaigns with animated funding bars

  • Personalised feed tab (authenticated users): "For You" tab showing the personalised feed with a subtle "Based on your contribution history" label

  • Infinite scroll pagination — appends next page of results as the user scrolls to the bottom
    frontend/src/embed/discover-widget.js — Embeddable discovery widget

  • Standalone < 3KB JS bundle distributed via CDN; pasted by third parties as: <script src="https://cdn.crowdpay.com/discover.js" data-token="<embedToken>" data-topic="education" data-asset="USDC" data-limit="3"></script>

  • Renders an iframe pointing to /embed/discover-widget.html populated by the discovery API

  • Widget renders 1–5 campaign cards (configurable via data-limit); each card is fully styled, shows progress bar, and links to the campaign's CrowdPay page on click

  • postMessage events: CROWDPAY_WIDGET_READY, CROWDPAY_CAMPAIGN_CLICKED (with campaign ID)
    Database migrations

  • campaigns_search: campaign_id (PK), search_vector (tsvector), title, description, category, tags (text array), target_asset, goal_amount_usd (numeric), total_raised_usd (numeric), percent_funded (numeric), status, contributions_last_24h (int), unique_contributors_last_7d (int), trending_score (numeric), last_indexed_at

  • GIN index on search_vector

  • Index on (trending_score DESC, last_indexed_at DESC) for trending queries

  • campaign_categories: id, name, slug — seeded with 12 categories (Technology, Art, Community, Health, Education, Environment, Food, Music, Film, Gaming, Sports, Other)

  • Alter campaigns: add category_id (FK to campaign_categories), tags (text array)

Acceptance criteria

  • Full-text search for "solar energy Nigeria" returns campaigns containing those terms in title or description, ranked by relevance × recency — confirmed by creating test campaigns with controlled text and verifying rank order
  • Facets (byAsset, byCategory, byStatus) are computed in the same database query as the search results — confirmed by checking that the total query time does not increase proportionally with the addition of facets
  • Trending score recomputes every 15 minutes — confirmed by creating a campaign with high contribution velocity and observing it rise in the trending list within 15 minutes
  • Personalised feed excludes campaigns the contributor has already funded — confirmed by funding a campaign and verifying it does not appear in the personalised feed
  • Discovery widget loads in under 1.5 seconds on a simulated 3G connection (Chrome DevTools throttling) and renders 3 campaign cards correctly
  • Search query with no matching results returns an empty campaigns array and accurate facets showing zero counts — not a 404 or 500
  • EXPLAIN ANALYZE on a full-text search query confirms the GIN index is used — no sequential scan on the campaigns table

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions