README - DashboardApi Edge Function

REST API endpoints for the Customer Dashboard, providing transaction history, content performance metrics, activity timelines, and RoC (Return on Contribution) breakdowns Modified: 2026-Jan-31 03:15:32 UTC

Dashboard API Edge Function

Purpose: REST API endpoints for the Customer Dashboard, providing transaction history, content performance metrics, activity timelines, and RoC (Return on Contribution) breakdowns.

Status: Active Last Updated: 2026-01-20 Version: 1.7.0


Overview

The Dashboard API is a Supabase Edge Function that provides authenticated REST endpoints for customer-facing dashboards. It enables users to:

  • View current account balance and balance history
  • View paginated transaction history with filtering and sorting
  • Drill down into individual transaction details
  • Monitor content performance and RoC earnings
  • Visualize activity trends over time
  • Analyze RoC earnings by source, day, or querier
  • Visualize "Hot Topics" - trending query topics across the organization

Base URL

https://<project-ref>.supabase.co/functions/v1/dashboardApi

Authentication

All endpoints require JWT authentication via the Authorization header:

Authorization: Bearer <user-jwt>

The JWT must contain app claims with:

  • userId - User's UUID
  • orgId - Organization ID
  • accessLevel - User's access level (optional)

Endpoints

1. GET /transactions

Description: Paginated list of transactions with filtering and sorting.

Query Parameters:

Parameter Type Default Description
page integer 1 Page number (1-indexed)
limit integer 20 Items per page (max 100)
type string - Comma-separated transaction types filter
role string "all" Filter by user's role: user, contributor, ipr_owner, all
date_from ISO date - Start date (inclusive)
date_to ISO date - End date (inclusive)
sort_by string "created_at" Sort column: created_at, platform_fee, credits_earned
sort_order string "desc" Sort order: asc, desc

Transaction Types:

  • document_add - Document upload
  • document_delete - Document deletion
  • document_update - Document update
  • query_usage - RAG query cost
  • credit_earned - RoC credit earned as contributor
  • credit_spent - Balance deduction
  • ipr_revenue - IPR royalty (future)
  • license_fee - License fee (future)

Response:

{
  data: [
    {
      id: string,
      transaction_type: string,
      created_at: string,
      platform_fee: number | null,
      credits_earned: number | null,
      usage_duration_seconds: number | null,
      source_url: string | null,
      source_title: string | null,
      contributor_id: string | null,   // Document owner's user ID (for messaging/profile link)
      contributor_name: string | null, // Document owner's full_name
      role: string,  // user's role in this transaction
      balance_before: number | null,
      balance_after: number | null,
      summary: {
        contributors_count: number,
        sources_count: number
      },
      dublin_core: {              // Dublin Core metadata for popover display
        dc_title?: string | null,
        dc_creator?: string | null,
        dc_publisher?: string | null,
        dc_date?: string | null,
        dc_rights?: string | null,
        dc_description?: string | null,
        dc_source?: string | null,
        dc_identifier?: string | null
      } | null
    }
  ],
  pagination: {
    page: number,
    limit: number,
    total: number,
    total_pages: number,
    has_next: boolean,
    has_prev: boolean
  },
  filters_applied: {
    types: string[],
    dateFrom: string | null,
    dateTo: string | null,
    sortBy: string,
    sortOrder: "asc" | "desc",
    role: string
  }
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/transactions?page=1&limit=10&type=query_usage,credit_earned&role=all' \
  -H 'Authorization: Bearer <jwt>'

2. GET /transactions/:id

Description: Full transaction details including source attribution and RoC distribution.

Response:

{
  id: string,
  transaction_type: string,
  created_at: string,
  user_id: string | null,
  org_id: string,
  platform_fee: number | null,
  usage_duration_seconds: number | null,
  hourly_rate: number | null,
  roc_split_percent: number | null,

  // For query_usage/credit_earned transactions
  query_details?: {
    question: string | null,
    model: string | null,
    retrieval_method: string | null,
    relevance_score: number | null
  },

  // For document operations (document_add, document_delete, document_update)
  document_details?: {
    source_url: string | null,
    source_title: string | null,
    document_count: number | null,
    vector_count: number | null,
    source_type: string | null,
    dublin_core?: {              // Dublin Core metadata (from upload)
      dc_title?: string | null,
      dc_creator?: string | null,
      dc_publisher?: string | null,
      dc_date?: string | null,
      dc_rights?: string | null,
      dc_description?: string | null,
      dc_source?: string | null,
      dc_identifier?: string | null
    } | null
  },

  balance_impact: {
    balance_before: number | null,
    balance_after: number | null,
    deduction: number | null
  },

  // Source attribution for queries
  sources?: [
    {
      source_url: string,
      source_title: string | null,  // Document title from vector metadata
      contributor_id: string | null,
      contributor_name: string | null,
      content_type: "contribution" | "ipr",
      portion: number,
      roc_earned: number | null,
      ipr_cost: number | null,
      chunks_used: number | null,
      dublin_core?: {              // Dublin Core metadata for popover display
        dc_title?: string | null,
        dc_creator?: string | null,
        dc_publisher?: string | null,
        dc_date?: string | null,
        dc_rights?: string | null,
        dc_description?: string | null,
        dc_source?: string | null,
        dc_identifier?: string | null
      } | null
    }
  ],

  roc_distribution?: {
    total_distributed: number,
    contributors_paid: number
  }
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/transactions/123e4567-e89b-12d3-a456-426614174000' \
  -H 'Authorization: Bearer <jwt>'

3. GET /content-performance

Description: Per-document RoC earnings and usage statistics for content you've contributed.

User Filtering: This endpoint automatically filters to show only the logged-in user's own documents. The contributor_id is extracted from the JWT claims and passed to the SQL function - there is no parameter to view other users' content. This is intentional for privacy: users can only see performance metrics for their own contributions.

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (1-365)
limit integer 20 Max documents to return (max 100)
sort_by string "roc_earned" Sort by: roc_earned, times_queried, avg_relevance
org_id string - Filter by organization (optional)

Response:

{
  data: [
    {
      source_url: string,
      source_title: string | null,
      uploaded_at: string | null,
      // Note: contributor_id/contributor_name are NOT included because this endpoint
      // already filters by the logged-in user - all documents shown are the user's own content
      performance: {
        times_queried: number,
        total_roc_earned: number,
        avg_relevance_score: number | null,
        avg_portion: number | null,
        unique_queriers: number,
        last_queried: string | null
      },
      dublin_core: {              // Dublin Core metadata for popover display
        dc_title?: string | null,
        dc_creator?: string | null,
        dc_publisher?: string | null,
        dc_date?: string | null,
        dc_rights?: string | null,
        dc_description?: string | null,
        dc_source?: string | null,
        dc_identifier?: string | null
      } | null
    }
  ],
  summary: {
    total_documents: number,
    total_roc_earned: number,
    total_times_queried: number,
    avg_relevance_overall: number | null,
    period_days: number
  }
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/content-performance?days=30&limit=10&sort_by=roc_earned' \
  -H 'Authorization: Bearer <jwt>'

4. GET /activity-timeline

Description: Daily activity aggregates for timeline/chart visualization.

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (1-365)
granularity string "day" Aggregation: day, week, month
org_id string - Filter by organization (optional)

Response:

{
  data: [
    {
      date: string,           // YYYY-MM-DD
      queries_made: number,   // Queries you initiated
      queries_received: number, // Queries that used your content
      documents_uploaded: number,
      roc_earned: number,
      platform_fees_paid: number,
      balance_delta: number   // roc_earned - platform_fees_paid
    }
  ],
  summary: {
    total_queries_made: number,
    total_queries_received: number,
    total_documents_uploaded: number,
    total_roc_earned: number,
    total_platform_fees: number,
    net_balance_change: number,
    period_days: number,
    transaction_counts: {     // Transaction counts by type for KPI cards
      query_usage: number,
      credit_earned: number,
      document_add: number,           // Count of upload transactions
      unique_documents_added: number, // Count of unique source_urls uploaded
      document_delete: number,
      document_update: number,
      credit_spent: number,
      ipr_revenue: number,
      license_fee: number,
      stripe_payment: number,
      stripe_refund: number
    }
  }
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/activity-timeline?days=30&granularity=day' \
  -H 'Authorization: Bearer <jwt>'

5. GET /balance

Description: Returns the user's current RoC credit balance and optionally recent balance history.

Query Parameters:

Parameter Type Default Description
include_history boolean false Include recent balance-affecting transactions
history_limit integer 10 Number of history items (max 50)

Response:

{
  balance: number,              // Current balance in minutes (RoC credits)
  balance_updated_at: string | null,  // ISO timestamp of last update
  currency: string,             // "minutes" (RoC credit unit)
  history?: [                   // Only if include_history=true
    {
      transaction_id: string,
      created_at: string,
      transaction_type: string,
      change: number,           // Absolute value of change
      direction: "credit" | "debit",
      balance_before: number | null,
      balance_after: number | null
    }
  ]
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/balance?include_history=true&history_limit=5' \
  -H 'Authorization: Bearer <jwt>'

6. GET /roc-breakdown

Description: RoC earnings breakdown grouped by source, day, or querier.

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (1-365)
limit integer 20 Max items to return (max 100)
group_by string "source" Group by: source, day, querier
org_id string - Filter by organization (optional)

Response:

{
  data: [
    {
      source_url: string,       // For group_by=source: document URL
                                // For group_by=day: date string
                                // For group_by=querier: user ID
      source_title: string | null,
      total_roc_earned: number,
      times_used: number,
      unique_queriers: number,
      avg_portion_per_query: number | null,
      avg_relevance: number | null
    }
  ],
  totals: {
    total_roc_earned: number,
    total_times_used: number,
    unique_sources: number,
    period_days: number
  }
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/roc-breakdown?days=30&group_by=source&limit=10' \
  -H 'Authorization: Bearer <jwt>'

7. GET /leaderboard (KPI Summary)

Description: Combined KPI summary for dashboard cards. Returns top 3 contributors AND top 3 content in a single call, plus the user's rank in each. Designed as the dashboard entry point with drill-down to full leaderboards.

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (1-365)
org_id string - Filter by organization (defaults to user's org)

Response:

{
  contributors: {
    top_3: [
      {
        rank: number,
        contributor_id: string,
        contributor_name: string | null,
        total_roc_earned: number,
        documents_count: number
      }
    ],
    my_rank: number | null,      // User's rank (null if not on board)
    total: number                // Total contributors
  },
  content: {
    top_3: [
      {
        rank: number,
        source_url: string,
        source_title: string | null,
        contributor_name: string | null,
        dublin_core: DublinCoreMetadata | null,  // For popover display
        total_roc_generated: number,
        is_mine: boolean         // Highlight user's content
      }
    ],
    my_top_rank: number | null,  // User's top content rank
    my_content_count: number,    // How many of user's docs are on the board
    total: number                // Total documents
  },
  period_days: number
}

Example:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/leaderboard?days=30' \
  -H 'Authorization: Bearer <jwt>'

Frontend Integration - KPI Cards:

const response = await fetch("/leaderboard?days=30");
const data = await response.json();

// Contributors KPI Card
const topContributor = data.contributors.top_3[0];
const myContributorRank = data.contributors.my_rank;
// Click → navigate to /leaderboard/contributors

// Content KPI Card
const topContent = data.content.top_3[0];
const myTopContentRank = data.content.my_top_rank;
// Click → navigate to /leaderboard/content

8. GET /leaderboard/contributors

Description: Top contributors ranked by RoC credits earned. Enables discovery of subject matter experts.

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (1-365)
limit integer 20 Number of contributors to return (max 100)
offset integer 0 Pagination offset for scrolling
around_me boolean false Center results around current user's rank
org_id string - Filter by organization (defaults to user's org)

Response:

{
  data: [
    {
      rank: number,                    // Position on leaderboard
      contributor_id: string,          // UUID for messaging/profile link
      contributor_name: string | null, // User's full_name (not email)
      documents_contributed: number,   // Unique documents contributed
      times_content_used: number,      // Number of times content was used
      total_roc_earned: number,        // Total RoC credits earned
      avg_relevance_score: number | null,
      last_activity_at: string | null,
      is_current_user: boolean         // Highlights user's own entry
    }
  ],
  summary: {
    total_contributors: number,        // Total contributors in org/period
    total_roc_distributed: number,     // Total RoC distributed
    period_days: number,               // Lookback period
    user_rank: number | null,          // Current user's rank (null if not on board)
    user_total_roc: number | null      // Current user's RoC earned
  },
  pagination: {
    offset: number,
    limit: number,
    total: number,
    has_more: boolean
  }
}

Example - Top 10:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/leaderboard/contributors?limit=10&days=30' \
  -H 'Authorization: Bearer <jwt>'

Example - "Where Am I?" (centered on user):

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/leaderboard/contributors?around_me=true&limit=20&days=30' \
  -H 'Authorization: Bearer <jwt>'

Frontend Integration:

  • Use is_current_user to highlight the user's row
  • Use summary.user_rank for "Where Am I?" button
  • Use around_me=true to scroll leaderboard to user's position
  • Use offset for pagination arrows (up/down navigation)

9. GET /leaderboard/content

Description: Top performing documents ranked by RoC generated. Enables discovery of valuable content in the knowledge base.

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (1-365)
limit integer 20 Number of documents to return (max 100)
offset integer 0 Pagination offset for scrolling
scope string "all" Filter scope: all (org-wide) or mine (user's content only)
around_mine boolean false Center results around user's top-ranked content
highlight_mine boolean false (Documentation only - is_mine always included)
org_id string - Filter by organization (defaults to user's org)

Response:

{
  data: [
    {
      rank: number,                    // Position on leaderboard
      source_url: string,              // Document URL
      source_title: string | null,     // Document title
      contributor_id: string | null,   // UUID of document owner
      contributor_name: string | null, // Owner's full_name
      dublin_core: DublinCoreMetadata | null, // Dublin Core for popovers
      times_queried: number,           // Number of times used in queries
      total_roc_generated: number,     // Total RoC credits generated
      unique_queriers: number,         // Number of unique users who queried
      avg_relevance_score: number | null,
      last_queried_at: string | null,
      is_mine: boolean                 // true if this is the current user's content
    }
  ],
  summary: {
    total_documents: number,           // Total documents in leaderboard
    total_roc_generated: number,       // Total RoC generated by all content
    period_days: number,               // Lookback period
    scope: "all" | "mine",             // Applied scope filter
    my_content_positions: number[]     // Ranks of user's content [3, 15, 42]
  },
  pagination: {
    offset: number,
    limit: number,
    total: number,
    has_more: boolean
  }
}

Example - Top 10 Content:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/leaderboard/content?limit=10&days=30' \
  -H 'Authorization: Bearer <jwt>'

Example - My Content Only:

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/leaderboard/content?scope=mine&days=30' \
  -H 'Authorization: Bearer <jwt>'

Example - "Find Mine" (centered on user's top content):

curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/leaderboard/content?around_mine=true&limit=20&days=30' \
  -H 'Authorization: Bearer <jwt>'

Frontend Integration - "Find Mine" Button:

// First click: Jump to user's top-ranked content
const response = await fetch("/leaderboard/content?around_mine=true&limit=20");
const data = await response.json();

// Access user's content positions for subsequent "Find Mine" clicks
const positions = data.summary.my_content_positions; // [3, 15, 42]

// Cycle through user's content on subsequent clicks
let currentIndex = 0;
function findNext() {
  if (positions.length === 0) return; // User has no content
  currentIndex = (currentIndex + 1) % positions.length;
  const nextRank = positions[currentIndex];
  const offset = Math.max(0, nextRank - Math.floor(limit / 2));
  fetch(`/leaderboard/content?offset=${offset}&limit=20`);
}

Frontend Integration - "Highlight Mine" Styling:

// Apply "Rosie Highlighter" style to user's content
response.data.forEach((item) => {
  if (item.is_mine) {
    element.classList.add("rosie-highlight", "table-row-mine");
  }
});

10. GET /hot-topics

Description: Privacy-preserving query topic analytics. Shows trending topics within the organization based on extracted query tags. Never exposes raw query text.

Privacy Measures:

  • Raw queries never exposed - only derived topic tags
  • Minimum threshold (default 3) to prevent identifying individual queries
  • Unique user counts hidden if below threshold
  • Topics older than 90 days excluded

Access Level Filtering (RLS Parity):

  • By default, users only see topics from queries at ≤ their access_level (matching document RLS)
  • Optional access_filter parameter to exclude topics from lower access levels (reduce noise)

Query Parameters:

Parameter Type Default Description
days integer 30 Lookback period (max 90)
limit integer 20 Number of topics to return (max 50)
min_count integer 3 Minimum query count threshold (min 3)
format string "ranking" Response format: ranking, timeseries, wordcloud
org_id string - Filter by organization (defaults to user's org)
access_filter integer - Min access level to include (reduces noise from lower tiers)

Response Formats:

Ranking Format (default)

Returns topics ranked by query count with trend indicators.

{
  format: "ranking",
  data: [
    {
      rank: number,              // 1-based rank
      topic: string,             // Hyphenated tag (e.g., "budget-planning")
      display_name: string,      // Human-readable (e.g., "Budget Planning")
      query_count: number,       // Total queries with this topic
      unique_users: number | null, // Null if below threshold
      trend: {
        direction: "up" | "down" | "stable",
        change_percent: number | null  // vs previous period
      },
      first_seen: string,        // ISO timestamp
      last_seen: string          // ISO timestamp
    }
  ],
  summary: {
    total_topics: number,
    total_queries_analyzed: number,
    coverage_percent: number,
    trending_up: number,         // Topics with upward trend
    new_this_period: number      // Topics first seen this period
  },
  period: {
    days: number,
    start_date: string,
    end_date: string
  }
}

Time Series Format

Returns daily topic counts for trend chart visualization.

// GET /hot-topics?format=timeseries
{
  format: "timeseries",
  data: [
    {
      date: string,              // YYYY-MM-DD
      topics: {                  // Topic counts for this day
        "budget-planning": number,
        "compliance-audit": number,
        // ...
      }
    }
  ],
  topics_included: string[],     // List of topics in the data
  period: {
    days: number,
    start_date: string,
    end_date: string
  }
}

Word Cloud Format

Returns topic weights for word cloud visualization.

// GET /hot-topics?format=wordcloud
{
  format: "wordcloud",
  data: [
    {
      text: string,              // Display name (e.g., "Budget Planning")
      value: number              // Weight (query count)
    }
  ],
  period: {
    days: number,
    start_date: string,
    end_date: string
  }
}

Examples:

# Default ranking format
curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/hot-topics?days=30&limit=20' \
  -H 'Authorization: Bearer <jwt>'

# Time series for chart visualization
curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/hot-topics?format=timeseries&days=14' \
  -H 'Authorization: Bearer <jwt>'

# Word cloud format
curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/hot-topics?format=wordcloud&limit=30' \
  -H 'Authorization: Bearer <jwt>'

# Filter to only show topics from access_level >= 2 (reduce noise from lower tiers)
curl -X GET \
  'https://<project>.supabase.co/functions/v1/dashboardApi/hot-topics?access_filter=2' \
  -H 'Authorization: Bearer <jwt>'

Frontend Integration:

// Fetch hot topics for ranking display
const response = await fetch("/dashboardApi/hot-topics?days=30&limit=20", {
  headers: { Authorization: `Bearer ${jwt}` },
});
const { data, summary, period } = await response.json();

// Display trending topics
data.forEach((topic) => {
  const trendIcon =
    topic.trend.direction === "up"
      ? "↑"
      : topic.trend.direction === "down"
      ? "↓"
      : "→";
  console.log(
    `${topic.rank}. ${topic.display_name} (${topic.query_count} queries) ${trendIcon}`,
  );
});

// For word cloud visualization (e.g., with wordcloud2.js)
const wordcloudResponse = await fetch(
  "/dashboardApi/hot-topics?format=wordcloud&limit=50",
);
const wordcloudData = await wordcloudResponse.json();
WordCloud(document.getElementById("wordcloud"), {
  list: wordcloudData.data.map((d) => [d.text, d.value]),
});

Error Responses

All endpoints return consistent error responses:

{
  error: string,
  timestamp: string
}

HTTP Status Codes:

  • 400 - Bad Request (invalid parameters)
  • 401 - Unauthorized (missing/invalid JWT)
  • 404 - Not Found (resource doesn't exist or access denied)
  • 405 - Method Not Allowed (only GET supported)
  • 500 - Internal Server Error

Database Requirements

This API requires the following database objects (created by migration 20251213000001_dashboard_api_functions.sql):

SQL Functions

Function Purpose
get_content_performance() Per-document RoC metrics aggregation
get_activity_timeline() Daily activity aggregates
get_roc_breakdown() RoC breakdown by source
get_hot_topics() Topic aggregation with privacy threshold
get_hot_topics_timeseries() Daily topic counts for trend charts

Indexes

Index Purpose
idx_transactions_dashboard_list Transaction list queries
idx_transactions_contributor_content Contributor content queries
idx_transactions_by_roles Role-based transaction lookup
idx_transactions_topics_gin GIN index for topic array queries (Hot Topics)
idx_transactions_query_with_topics Partial index for query_usage with topics

File Structure

dashboardApi/
├── README.md                  # This file
├── index.ts                   # Main entry point and routing
├── _deps.ts                   # Dependency re-exports
├── deno.json                  # Deno configuration
├── types.ts                   # TypeScript interfaces
├── dashboardApi.test.ts       # Unit tests
├── handlers/
│   ├── transactions.ts        # /transactions endpoints
│   ├── content.ts             # /content-performance endpoint
│   ├── activity.ts            # /activity-timeline endpoint
│   ├── roc.ts                 # /roc-breakdown endpoint
│   ├── balance.ts             # /balance endpoint
│   ├── leaderboard.ts         # /leaderboard/contributors endpoint
│   ├── leaderboard-content.ts # /leaderboard/content endpoint
│   ├── leaderboard-summary.ts # /leaderboard (KPI summary) endpoint
│   └── hot-topics.ts          # /hot-topics endpoint (NEW)
└── utils/
    ├── pagination.ts          # Pagination helpers
    ├── filters.ts             # Query filter parsing
    └── dublin-core.ts         # Dublin Core metadata utilities

Development

Run Locally

deno task run:dashboardApi

Run Tests

TEST_NAME=dashboardApi deno task test:fns

Deploy

supabase functions deploy dashboardApi

Front-End Integration Notes

For 11ty/Nunjucks Integration

  1. Authentication: The front-end must obtain a Supabase JWT via supabase.auth.signIn() and include it in all API requests.

  2. CORS: The API includes CORS headers for browser requests.

  3. Pagination UI: Use pagination.has_next and pagination.has_prev for navigation controls.

  4. Chart Data: The /activity-timeline endpoint returns data ready for Chart.js or similar:

    const labels = data.map((d) => d.date);
    const rocValues = data.map((d) => d.roc_earned);
  5. Transaction Drill-Down: Click a transaction row to call /transactions/:id for full details modal.

  6. Role Indicators: Use the role field to show badges (User/Contributor/IPR Owner).

  7. Currency Formatting: All monetary values are in the platform's base currency (RoC credits).

  8. Query Question (query_details.question): The user's original query text is available in transaction drill-down.

    Availability:

    Endpoint Query Available?
    GET /transactions (list) No - summary data only
    GET /transactions/:id (detail) Yes - in query_details.question

    Transaction types with query details:

    • query_usage - The query the user made
    • credit_earned - The query that used the contributor's content

    Frontend usage:

    // Fetch transaction detail
    const response = await fetch(`/dashboardApi/transactions/${transactionId}`, {
      headers: { Authorization: `Bearer ${jwt}` },
    });
    const tx = await response.json();
    
    // Access the question (null-safe)
    const question = tx.query_details?.question || "Question not available";

    Notes:

    • question may be null for older transactions or if not stored in metadata
    • Only users involved in the transaction (user, contributor, ipr_owner, licensee) can access details
  9. Source URLs (source_url): Document URLs are now properly returned for all transaction types.

    Data Location Field Availability
    Transaction list source_url ✅ Available (falls back to metadata.sources[0].source_url)

    For credit_earned transactions:

    • The source_url field identifies which document earned the RoC credits
    • Frontend can display clickable links to the contributing document
    • The "Your Contributing Document" section in transaction detail modals will render correctly

    Frontend usage:

    // In transaction list rendering
    if (tx.transaction_type === "credit_earned" && tx.source_url) {
      // Render clickable link to the source document
      renderDocumentLink(tx.source_url, tx.source_title);
    }
  10. Source Titles (source_title): Document titles are populated from vector metadata. Important notes:

Data Location Field Availability
Transaction list source_title ✅ Available (falls back to metadata.sources[0].source_title)
Transaction detail sources[] source_title ✅ Available (from metadata.sources[].source_title)
Content Performance source_title ✅ Available (SQL COALESCE fallback)
RoC Breakdown source_title ✅ Available (SQL COALESCE fallback)

When titles may be NULL:

  • Transactions created before the rag function was updated to extract titles from vector metadata
  • Documents uploaded without a sourceTitle in the request (title comes from Netlify processing)
  • The SQL migration 20251215000001_source_title_metadata_fallback.sql backfills titles from vector stores

Frontend fallback pattern:

// Check both field names for compatibility
const title =
  source.source_title || source.title || extractTitleFromUrl(source.source_url);

Building a Reusable Transaction Detail Component

The /transactions/:id endpoint returns different data depending on the transaction type. Here's guidance for building a single, reusable component that handles all types.

Transaction Type Categories

Category Transaction Types Key Fields
Query query_usage, credit_earned query_details, sources, roc_distribution
Document document_add, document_delete, document_update document_details
Financial credit_spent, ipr_revenue, license_fee, stripe_payment, stripe_refund balance_impact

Response Field Availability by Type

┌─────────────────────┬───────────┬───────────┬───────────┬─────────────┐
│ Field               │ query_    │ credit_   │ document_ │ credit_     │
│                     │ usage     │ earned    │ add/del   │ spent       │
├─────────────────────┼───────────┼───────────┼───────────┼─────────────┤
│ platform_fee        │ ✅        │ ❌        │ ✅        │ ✅          │
│ credits_earned      │ ❌        │ ✅        │ ❌        │ ❌          │
│ query_details       │ ✅        │ ✅        │ ❌        │ ❌          │
│ document_details    │ ❌        │ ❌        │ ✅        │ ❌          │
│ sources[]           │ ✅        │ ✅        │ ❌        │ ❌          │
│ roc_distribution    │ ✅        │ ❌        │ ❌        │ ❌          │
│ balance_impact      │ ✅        │ ❌        │ ✅        │ ✅          │
└─────────────────────┴───────────┴───────────┴───────────┴─────────────┘

TypeScript Types (Copy to Frontend)

// Transaction types enum for switch statements
type TransactionType =
  | "document_add"
  | "document_delete"
  | "document_update"
  | "query_usage"
  | "credit_earned"
  | "credit_spent"
  | "ipr_revenue"
  | "license_fee"
  | "stripe_payment"
  | "stripe_refund";

interface QueryDetails {
  question: string | null;
  model: string | null;
  retrieval_method: string | null;
  relevance_score: number | null;
}

interface DocumentDetails {
  source_url: string | null;
  source_title: string | null;
  document_count: number | null;
  vector_count: number | null;
  source_type: string | null;
}

interface BalanceImpact {
  balance_before: number | null;
  balance_after: number | null;
  deduction: number | null;
}

interface DublinCoreMetadata {
  dc_title?: string | null;
  dc_creator?: string | null;
  dc_publisher?: string | null;
  dc_date?: string | null;
  dc_rights?: string | null;
  dc_description?: string | null;
  dc_source?: string | null;
  dc_identifier?: string | null;
}

interface SourceDetail {
  source_url: string;
  source_title: string | null;
  contributor_id: string | null;
  contributor_name: string | null;
  content_type: "contribution" | "ipr";
  portion: number; // 0-1 (percentage of query this source contributed)
  roc_earned: number | null; // RoC credits earned by contributor
  ipr_cost: number | null; // IPR cost (if content_type === 'ipr')
  chunks_used: number | null;
  dublin_core?: DublinCoreMetadata | null; // Dublin Core metadata for popover
}

interface RocDistribution {
  total_distributed: number;
  contributors_paid: number;
}

interface TransactionDetail {
  id: string;
  transaction_type: TransactionType;
  created_at: string;
  user_id: string | null;
  org_id: string;

  // Common fields
  platform_fee: number | null;
  usage_duration_seconds: number | null;
  hourly_rate: number | null;
  roc_split_percent: number | null;

  // Top-level fields for credit_earned transactions (v1.3.0+)
  source_url?: string | null; // Document URL that earned credits
  source_title?: string | null; // Document title
  dublin_core?: DublinCoreMetadata | null; // Dublin Core metadata
  credits_earned?: number | null; // RoC credits earned

  // Conditional fields (check existence before use)
  query_details?: QueryDetails;
  document_details?: DocumentDetails;
  balance_impact: BalanceImpact;
  sources?: SourceDetail[];
  roc_distribution?: RocDistribution;
}

Component Architecture Pattern

// Recommended: Composition pattern with type-specific sections

function TransactionDetailModal({ transactionId }: { transactionId: string }) {
  const [tx, setTx] = useState<TransactionDetail | null>(null);

  // Fetch transaction detail
  useEffect(() => {
    fetchTransaction(transactionId).then(setTx);
  }, [transactionId]);

  if (!tx) return <LoadingSpinner />;

  return (
    <div className="transaction-detail">
      {/* Always show: Header with type badge and timestamp */}
      <TransactionHeader tx={tx} />

      {/* Always show: Balance impact (if any values present) */}
      {hasBalanceImpact(tx.balance_impact) && (
        <BalanceImpactSection impact={tx.balance_impact} />
      )}

      {/* Conditional: Query details for query_usage/credit_earned */}
      {tx.query_details && <QueryDetailsSection details={tx.query_details} />}

      {/* Conditional: Document details for document operations */}
      {tx.document_details && (
        <DocumentDetailsSection details={tx.document_details} />
      )}

      {/* Conditional: Sources list for queries */}
      {tx.sources && tx.sources.length > 0 && (
        <SourcesSection sources={tx.sources} />
      )}

      {/* Conditional: RoC distribution summary */}
      {tx.roc_distribution && (
        <RocDistributionSection distribution={tx.roc_distribution} />
      )}
    </div>
  );
}

// Helper to check if balance impact has meaningful data
function hasBalanceImpact(impact: BalanceImpact): boolean {
  return (
    impact.balance_before !== null ||
    impact.balance_after !== null ||
    impact.deduction !== null
  );
}

Display Labels by Transaction Type

const TRANSACTION_LABELS: Record<
  TransactionType,
  {
    label: string;
    icon: string;
    color: string;
    description: string;
  }
> = {
  document_add: {
    label: "Document Upload",
    icon: "📄",
    color: "green",
    description: "You uploaded a document to the knowledge base",
  },
  document_delete: {
    label: "Document Removed",
    icon: "🗑️",
    color: "red",
    description: "A document was removed from the knowledge base",
  },
  document_update: {
    label: "Document Updated",
    icon: "📝",
    color: "blue",
    description: "A document was updated in the knowledge base",
  },
  query_usage: {
    label: "Query Made",
    icon: "🔍",
    color: "purple",
    description: "You asked a question using the RAG system",
  },
  credit_earned: {
    label: "RoC Earned",
    icon: "💰",
    color: "gold",
    description: "Your content was used to answer a query",
  },
  credit_spent: {
    label: "Balance Deduction",
    icon: "💳",
    color: "orange",
    description: "Platform fee deducted from your balance",
  },
  ipr_revenue: {
    label: "IPR Revenue",
    icon: "©️",
    color: "teal",
    description: "Royalty earned from intellectual property",
  },
  license_fee: {
    label: "License Fee",
    icon: "📜",
    color: "indigo",
    description: "Fee paid for licensed content usage",
  },
  stripe_payment: {
    label: "Payment Received",
    icon: "✅",
    color: "green",
    description: "Payment added to your account",
  },
  stripe_refund: {
    label: "Refund Issued",
    icon: "↩️",
    color: "gray",
    description: "A refund was processed",
  },
};

Formatting Helpers

// Format RoC credits (minutes)
function formatRocCredits(minutes: number | null): string {
  if (minutes === null) return "—";
  if (minutes < 1) return `${(minutes * 60).toFixed(1)}s`;
  if (minutes < 60) return `${minutes.toFixed(2)} min`;
  return `${(minutes / 60).toFixed(2)} hrs`;
}

// Format platform fee (currency)
function formatPlatformFee(fee: number | null): string {
  if (fee === null) return "—";
  return `$${fee.toFixed(4)}`;
}

// Format duration
function formatDuration(seconds: number | null): string {
  if (seconds === null) return "—";
  if (seconds < 60) return `${seconds}s`;
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}

// Format relevance score as percentage
function formatRelevance(score: number | null): string {
  if (score === null) return "—";
  return `${(score * 100).toFixed(0)}%`;
}

// Format portion as percentage
function formatPortion(portion: number): string {
  return `${(portion * 100).toFixed(1)}%`;
}

// Truncate question for display
function truncateQuestion(question: string | null, maxLength = 100): string {
  if (!question) return "Question not available";
  if (question.length <= maxLength) return question;
  return question.slice(0, maxLength) + "...";
}

Section Components Reference

QueryDetailsSection - Show when tx.query_details exists:

  • question - The user's original query (may be long, consider expandable)
  • model - AI model used (e.g., "gpt-4")
  • retrieval_method - How documents were retrieved (e.g., "similarity")
  • relevance_score - Overall relevance (0-1, display as percentage)

DocumentDetailsSection - Show when tx.document_details exists:

  • source_url - Link to the document
  • source_title - Document title (fallback to URL if null)
  • document_count - Number of document chunks
  • vector_count - Number of vectors stored
  • source_type - Type of source (e.g., "web", "pdf")
  • Dublin Core popover - Show metadata on hover (see below)

SourcesSection - Show when tx.sources has items:

  • Render as a table or list of cards
  • Show contributor_name (or "Anonymous" if null)
  • Show source_title with link to source_url
  • Show portion as percentage (how much this source contributed)
  • Show roc_earned for credit_earned transactions
  • Badge for content_type ("contribution" vs "ipr")
  • Dublin Core popover - Show metadata on hover for each source (see below)

BalanceImpactSection - Show when any balance field is non-null:

  • Show before → after with arrow
  • Highlight deduction amount
  • Color code: green for credits, red for debits

Access Control Notes

  • Users can only see transactions where they are involved as:
    • user_id (they initiated the action)
    • contributor_id (their content was used)
    • ipr_owner_id (they own IP that was licensed)
    • licensee_id (they licensed content)
  • The API enforces this server-side; 404 returned if no access

Dublin Core Metadata Popovers

For document details and source links, show Dublin Core metadata in Bootstrap popovers on hover. This pattern is already implemented in the RAG client (ui/rag-client/index.html).

API Support: The GET /transactions/:id endpoint now includes a dublin_core object on each source in the sources[] array. Use this data directly to populate popovers - no additional API calls needed.

Reference Implementation: See ui/rag-client/index.html lines 1036-1116

Dublin Core Fields Available

Each source in the transaction detail response includes a dublin_core field (null if no metadata available):

Field Display Label Description
dc_title Title Document title
dc_creator Author Creator/author of the content
dc_publisher Publisher Publishing entity
dc_date Date Publication date
dc_rights Rights Copyright/license information
dc_description Summary Brief description (truncate to ~200 chars)
dc_source Source Original source URL
dc_identifier Identifier DOI, ISBN, or other identifier

HTML Structure (Bootstrap 5)

<!-- Source link with Dublin Core popover -->
<a
  href="${sourceUrl}"
  target="_blank"
  class="source-link"
  data-bs-toggle="popover"
  data-bs-trigger="hover focus"
  data-bs-html="true"
  data-bs-title="Dublin Core Metadata"
  data-bs-content="${buildDCPopoverContent(metadata)}"
>
  <i class="bi bi-link-45deg"></i> ${sourceTitle || sourceUrl}
</a>

JavaScript Implementation

/**
 * Build popover content from Dublin Core metadata
 * @param {Object} meta - Metadata object with dc_* fields
 * @returns {string} HTML content for popover
 */
function buildDCPopoverContent(meta) {
  if (!meta) return "No metadata available";

  let content = "";

  // Core fields (always show if present)
  if (meta.dc_creator) {
    content += `<strong>Author:</strong> ${escapeHtml(meta.dc_creator)}<br>`;
  }
  if (meta.dc_publisher) {
    content += `<strong>Publisher:</strong> ${escapeHtml(
      meta.dc_publisher,
    )}<br>`;
  }
  if (meta.dc_date) {
    content += `<strong>Date:</strong> ${escapeHtml(meta.dc_date)}<br>`;
  }
  if (meta.dc_rights) {
    content += `<strong>Rights:</strong> ${escapeHtml(meta.dc_rights)}<br>`;
  }
  if (meta.dc_identifier) {
    content += `<strong>ID:</strong> ${escapeHtml(meta.dc_identifier)}<br>`;
  }

  // Description (truncate if long)
  if (meta.dc_description) {
    const desc = meta.dc_description;
    const truncated = desc.length > 200 ? desc.substring(0, 200) + "..." : desc;
    content += `<strong>Summary:</strong> ${escapeHtml(truncated)}`;
  }

  return content || "No metadata available";
}

/**
 * Check if metadata has any Dublin Core fields worth showing
 */
function hasDCMetadata(meta) {
  if (!meta) return false;
  return !!(
    meta.dc_creator ||
    meta.dc_publisher ||
    meta.dc_date ||
    meta.dc_rights ||
    meta.dc_description ||
    meta.dc_identifier
  );
}

/**
 * Escape HTML to prevent XSS in popover content
 */
function escapeHtml(str) {
  if (!str) return "";
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

// Initialize all popovers after rendering
function initializePopovers(container) {
  const popoverTriggers = container.querySelectorAll(
    '[data-bs-toggle="popover"]',
  );
  popoverTriggers.forEach((el) => {
    new bootstrap.Popover(el, {
      container: "body",
      sanitize: false, // Allow HTML content
    });
  });
}

Dark Mode Styling

Include this CSS for dark mode support (already in ui/shared/theme.css):

[data-bs-theme="dark"] .popover {
  --bs-popover-bg: #2d3748;
  --bs-popover-border-color: #4a5568;
}

[data-bs-theme="dark"] .popover-header {
  background-color: #1a202c;
  border-bottom-color: #4a5568;
  color: #e2e8f0;
}

[data-bs-theme="dark"] .popover-body {
  color: #cbd5e0;
}

Integration Example

// In your SourcesSection component
// source.dublin_core comes directly from the API response
function renderSource(source) {
  const hasMetadata = source.dublin_core !== null;

  return `
    <div class="source-item">
      <a href="${source.source_url}"
         target="_blank"
         ${
           hasMetadata
             ? `
           data-bs-toggle="popover"
           data-bs-trigger="hover focus"
           data-bs-html="true"
           data-bs-title="Document Metadata"
           data-bs-content="${buildDCPopoverContent(source.dublin_core)}"
         `
             : ""
         }>
        ${escapeHtml(source.source_title || source.source_url)}
      </a>
      <span class="badge">${formatPortion(source.portion)}</span>
    </div>
  `;
}

// After rendering sources
initializePopovers(document.getElementById("sources-container"));

Notes

  • Conditional rendering: Only add popover attributes if source.dublin_core !== null
  • Performance: Initialize popovers once after all content is rendered, not per-element
  • Cleanup: Dispose popovers when modal closes to prevent memory leaks:
    // On modal close
    document.querySelectorAll('[data-bs-toggle="popover"]').forEach((el) => {
      const popover = bootstrap.Popover.getInstance(el);
      if (popover) popover.dispose();
    });
  • Shared code: Consider extracting buildDCPopoverContent, escapeHtml, and initializePopovers to a shared utility file (ui/shared/popovers.js)

Dublin Core Data Flow and Troubleshooting

How Dublin Core Fields Get Populated

Dublin Core metadata flows through two separate paths during document upload, both originating from maintainSource() in documents-manager.ts:

Upload Request (with DC fields)
         │
         ▼
   maintainSource()
         │
         ├──────────────────────────────────┐
         ▼                                  ▼
   Vector Store                      Transaction Log
   (documents_fpp)                   (transactions)
         │                                  │
         ▼                                  ▼
   retriever.addDocuments()     logDocumentUploadTransaction()
   + updateDocuments()                      │
         │                                  ▼
         ▼                           Stores DC fields in
   Stores DC fields in               metadata JSON column
   chunk metadata                    (dc_creator, dc_date, etc.)
         │
         ▼
   Read by RAG endpoint          Read by Dashboard API
   (query-time sources)          (transaction history, content-performance)

Why Dublin Core May Be Missing

1. Upload form didn't provide DC fields

The most common reason. If the upload form/API only sends sourceDate but not dcCreator, dcPublisher, etc., then only dc_date will be stored. Both vector metadata AND transaction metadata will be incomplete because they receive the same input.

Verification: Check what fields the upload form sends:

// In maintainSource options:
{
  sourceDate: "2025-01-15",     // ✅ Always provided
  dcCreator: "Jane Doe",        // ❓ Only if form captures this
  dcPublisher: "Acme Corp",     // ❓ Only if form captures this
  // etc.
}

2. Document uploaded before DC fields were implemented

Documents uploaded before Dublin Core support was added will have no DC metadata in either location.

3. Google Docs URL format mismatch (FIXED in 2025-12-22)

Previously, document_add transactions stored URLs in one format (e.g., /edit) while credit_earned transactions used another (e.g., /export). The lookup failed due to exact URL matching. This is now fixed - the API normalizes by Google Doc ID.

Diagnostic SQL Queries

Check what DC fields exist for a specific document:

-- Check transaction metadata for a source URL
SELECT
  transaction_type,
  source_url,
  metadata->>'dc_title' as dc_title,
  metadata->>'dc_creator' as dc_creator,
  metadata->>'dc_publisher' as dc_publisher,
  metadata->>'dc_date' as dc_date,
  metadata->'dublin_core' as dublin_core_nested
FROM transactions
WHERE source_url LIKE '%your-document-url%'
ORDER BY created_at DESC;

-- Check vector metadata (documents_fpp) for a source URL
SELECT
  id,
  metadata->>'source' as source,
  metadata->>'title' as title,
  metadata->>'dcCreator' as dc_creator,
  metadata->>'dcPublisher' as dc_publisher,
  metadata->>'date' as date
FROM documents_fpp
WHERE metadata->>'source' LIKE '%your-document-url%'
LIMIT 5;

Ensuring DC Fields for New Uploads

To ensure Dublin Core fields appear in popovers for newly uploaded documents:

  1. Update the upload form to capture DC fields (creator, publisher, rights, etc.)
  2. Pass DC fields to maintainSource():
    await maintainSource({
      sourceUrl: "https://example.com/doc",
      sourceDate: "2025-01-15",
      sourceTitle: "Document Title",
      dcCreator: "Author Name", // ← Add these
      dcPublisher: "Publisher Name", // ← Add these
      dcRights: "CC-BY-4.0", // ← Add these
      // ... other fields
    });
  3. Both storage locations will be populated automatically from these parameters

Backfilling Missing DC Metadata

For existing documents missing DC metadata:

  1. Re-upload the document with DC fields populated (recommended for important documents)
  2. Run the backfill migration 20251220000001_backfill_dublin_core_transactions.sql to restructure existing flat fields into nested dublin_core objects (but this cannot create data that wasn't captured originally)
  3. Manual database update for critical documents (not recommended for bulk operations)

Ownership Settlement Transactions

Problem: On-Behalf-Of Uploads

When uploading documents on behalf of an unregistered user, transaction attribution becomes inconsistent:

  • Upload costs (ToC): Charged to original uploader (User A)
  • RoC earnings: Initially attributed to User A
  • After transfer: User B should own the document and receive future RoC

Solution: Settlement Transaction Pattern

Instead of rewriting transaction history (which the old admin_transfer_transactions does), use the new settlement pattern that creates explicit settlement transactions:

New Transaction Types:

  • ownership_transfer - Audit record of ownership change
  • settlement_fee_reimbursement - Platform fee reimbursement (new owner pays original uploader)
  • settlement_roc_transfer - RoC credits transfer (original uploader returns to new owner)

Settlement Flow:

User A uploads document (pays platform fee)
    ↓
Document earns RoC (credited to A)
    ↓
Ownership transfers to User B
    ↓
Settlement executes:
  1. Create ownership_transfer transaction (audit)
  2. Calculate total platform fees paid by A
  3. Create settlement_fee_reimbursement: credit A, debit B
  4. Calculate total RoC earned by A for this doc
  5. Create settlement_roc_transfer: debit A, credit B
  6. Update documents_fpp.user_id to B
    ↓
Net effect:
  - A: +fees, -roc (breaks even if fees ≈ roc)
  - B: -fees, +roc (pays for upload, gets earnings)

Admin API Endpoints

Preview Settlement (dry run):

GET /adminReconcile/preview/settlement?sourceUrl=...&originalOwnerId=...&newOwnerId=...

Execute Settlement:

POST /adminReconcile/reconcile/settlement
{
  "sourceUrl": "https://docs.google.com/document/d/ABC123/view",
  "originalOwnerId": "uuid-of-user-a",
  "newOwnerId": "uuid-of-user-b",
  "orgId": "fpp",
  "updateDocuments": true  // Also update documents_fpp.user_id
}

Batch Settlement:

POST /adminReconcile/reconcile/settlement
{
  "sourceUrls": ["url1", "url2", "url3"],
  "originalOwnerId": "uuid-of-user-a",
  "newOwnerId": "uuid-of-user-b"
}

SQL Functions

Direct database access (service_role only):

-- Preview settlement
SELECT * FROM admin_preview_settlement(
  'https://docs.google.com/document/d/ABC123/view',
  'original-owner-uuid'::uuid,
  'new-owner-uuid'::uuid
);

-- Execute settlement
SELECT * FROM admin_settle_ownership_transfer(
  'https://docs.google.com/document/d/ABC123/view',
  'original-owner-uuid'::uuid,
  'new-owner-uuid'::uuid,
  'fpp',
  'admin-uuid'::uuid  -- optional
);

When to Use Each Approach

Scenario Recommended Approach
New ownership transfer Use settlement (/reconcile/settlement)
Already transferred with old method Use balance recalculation (/reconcile/balance)
Preview before transfer Use preview endpoint first
Batch transfer multiple docs Use batch settlement

credit_earned Transaction Details

For credit_earned transactions (RoC credits earned when your content is used), the API now provides top-level fields for easy access to source information:

Top-Level Fields (NEW in v1.3.0)

These fields are available directly on the TransactionDetail object for credit_earned transactions:

Field Type Description
source_url string | null URL of the document that earned credits
source_title string | null Title of the document
dublin_core DublinCoreMetadata | null Dublin Core metadata for popover display
credits_earned number | null Amount of RoC credits earned

Example Response

// GET /transactions/123e4567-e89b-12d3-a456-426614174000
{
  id: "123e4567-e89b-12d3-a456-426614174000",
  transaction_type: "credit_earned",
  created_at: "2025-12-19T10:30:00Z",

  // NEW: Top-level source fields for credit_earned
  source_url: "https://docs.google.com/document/d/abc123/view",
  source_title: "Company Strategy Document",
  dublin_core: {
    dc_title: "Company Strategy Document",
    dc_creator: "Jane Doe",
    dc_publisher: "Acme Corp",
    dc_date: "2025-01-15",
    dc_rights: "Confidential"
  },
  credits_earned: 0.0542,

  // Existing fields
  query_details: {
    question: "What is our company strategy?",
    model: "gpt-4.1-mini",
    ...
  },
  sources: [...],  // Full source attribution array
  ...
}

Frontend Usage

// Rendering a credit_earned transaction detail
function renderCreditEarnedTransaction(tx) {
  // Use top-level fields directly (no need to dig into sources[])
  const documentLink = tx.source_url
    ? `<a href="${tx.source_url}" target="_blank">${
        tx.source_title || "View Document"
      }</a>`
    : "Document not available";

  const creditsDisplay = tx.credits_earned
    ? formatRocCredits(tx.credits_earned)
    : "—";

  // Dublin Core popover (if metadata available)
  const popoverAttrs = tx.dublin_core
    ? `data-bs-toggle="popover" data-bs-content="${buildDCPopoverContent(
        tx.dublin_core,
      )}"`
    : "";

  return `
    <div class="credit-earned-detail">
      <h4>Your Contributing Document</h4>
      <p ${popoverAttrs}>${documentLink}</p>
      <p>Credits Earned: <strong>${creditsDisplay}</strong></p>
    </div>
  `;
}

Fallback Logic

The API applies this fallback logic for each field:

  1. source_url: tx.source_urltx.metadata?.sources?.[0]?.source_url
  2. source_title: tx.source_titletx.metadata?.sources?.[0]?.source_title
  3. dublin_core: tx.metadata?.dublin_coretx.metadata?.sources?.[0] (extract dc_* fields)
  4. credits_earned: Parsed from tx.credits_earned column

This ensures backward compatibility with older transactions that may have data in different locations.


Change Log

2026-01-20

  • FEATURE: Hot Topics endpoint (GET /hot-topics)
    • Privacy-preserving query topic analytics for organization
    • Three response formats: ranking (default), timeseries, wordcloud
    • Ranking format includes trend indicators (up/down/stable) vs previous period
    • Time series format for daily topic trend charts (Chart.js compatible)
    • Word cloud format for visualization libraries (wordcloud2.js compatible)
    • Privacy measures: minimum threshold (≥3), unique users hidden below threshold
    • New handler: handlers/hot-topics.ts
    • New types: HotTopicItem, HotTopicsRankingResponse, HotTopicsTimeSeriesResponse, HotTopicsWordCloudResponse
    • Requires migration: 20260119000001_hot_topics_support.sql (GIN index + RPC functions)

2025-12-30

  • BREAKING: Simplified leaderboard endpoint structure
    • GET /leaderboard now returns KPI summary (was contributor leaderboard)
    • GET /leaderboard/contributors returns full contributor leaderboard with pagination
    • GET /leaderboard/content returns full content leaderboard with pagination
    • Removed ?summary=true parameter (summary is now the default for /leaderboard)
  • FEATURE: KPI Summary endpoint (GET /leaderboard)
    • Combined KPI summary for dashboard cards - single API call
    • Returns top 3 contributors AND top 3 content with user's rank in each
    • Designed as dashboard entry point with drill-down to full leaderboards
    • New types: LeaderboardSummaryResponse, ContributorPreviewItem, ContentPreviewItem
  • FEATURE: Content Leaderboard endpoint (GET /leaderboard/content)
    • New endpoint for ranking documents by RoC generated
    • Supports ?scope=mine|all parameter to filter to user's content or org-wide
    • "Find Mine" feature via ?around_mine=true centers view on user's top-ranked content
    • Response includes is_mine boolean on each item for frontend highlighting
    • Summary includes my_content_positions array for cycling through user's content ranks
    • Dublin Core metadata and contributor names included for popovers
    • Pagination with offset/limit for infinite scroll
    • New types: ContentLeaderboardItem, ContentLeaderboardSummary, ContentLeaderboardResponse
  • BUGFIX: Fixed /content-performance returning incomplete Dublin Core (only dc_date)
    • Changed data source from transactions table to documents_fpp table
    • Dublin Core metadata is stored in documents_fpp.metadata JSONB column (where vectors are stored)
    • New shared utility fetchDocumentMetadataForSources() queries documents table directly
    • Returns full Dublin Core: dc_title, dc_creator, dc_publisher, dc_date, dc_rights, dc_description
  • FEATURE: Added contributor info to /roc-breakdown endpoint
    • RocBreakdownItem now includes contributor_id and contributor_name fields
    • Fetched from documents_fpp.user_id joined with users.full_name
    • Enables consistent contributor display across all dashboard endpoints
  • FEATURE: Added Dublin Core metadata to RoC Earned view (/roc-breakdown)
    • RocBreakdownItem now includes dublin_core field for consistent popover display
    • Reuses shared fetchDocumentMetadataForSources() utility for Google Docs URL normalization
    • Created shared utils/dublin-core.ts for Dublin Core extraction functions
    • Fixes inconsistent popover content between Transactions page and RoC Earned view
  • REFACTOR: Consolidated Dublin Core utilities in utils/dublin-core.ts
    • fetchDocumentMetadataForSources() - Returns Dublin Core + contributor info from documents_fpp
    • fetchDublinCoreForSources() - Backwards-compatible wrapper returning only Dublin Core
    • Removed ~200 lines of duplicate code from handlers/content.ts
  • FEATURE: Added ownership settlement transaction pattern for proper accounting
    • New transaction types: ownership_transfer, settlement_fee_reimbursement, settlement_roc_transfer
    • Creates explicit settlement transactions instead of rewriting history
    • Proper financial reconciliation: new owner reimburses platform fees, receives RoC
    • New SQL functions: admin_preview_settlement(), admin_settle_ownership_transfer(), admin_settle_ownership_transfer_batch()
    • New TypeScript service: settlement-service.ts with previewSettlement(), executeSettlement(), validateSettlement()
    • New admin endpoints: GET /preview/settlement, POST /reconcile/settlement
    • Full audit trail maintained for all ownership transfers

2025-12-22

  • BUGFIX: Fixed dublin_core returning null for Google Docs URLs in /content-performance
    • Google Docs URLs stored in different formats (/edit?tab=t.0 vs /export?format=html)
    • document_add transactions stored /edit?tab=t.0 format, but credit_earned transactions used /export?format=html
    • The exact URL match lookup was failing due to this mismatch
    • Now normalizes Google Docs URLs by document ID to match across URL formats
    • Also extended lookup to include document_update transactions (metadata can be updated there too)
  • BUGFIX: Fixed dublin_core returning null for Google Docs in /transactions/:id sources
    • Applied same Google Docs URL normalization fix to the sources[] array in transaction details
    • The fetchDublinCoreForSources() function extracts document ID from Google Docs URLs
    • Uses ilike pattern matching to find dublin_core metadata regardless of URL format variant
    • Prefers dublin_core from transaction metadata, falls back to lookup from document_add/update transactions

2025-12-19

  • BUGFIX: Fixed credit_earned transaction detail missing top-level source_url, source_title, dublin_core
    • Added source_url, source_title, dublin_core, and credits_earned to TransactionDetail interface
    • For credit_earned transactions, these fields now populated at top level (not just in sources[])
    • Falls back to metadata.sources[0] if direct column values are null
    • Frontend can now render "Your Contributing Document" section correctly
  • FEATURE: Added SQL functions for ownership transfer with transaction backfill
    • admin_transfer_transactions(source_url, new_owner_id) - Transfer transactions for a single document
    • admin_transfer_transactions_batch(source_urls[], new_owner_id) - Batch transfer
    • admin_transfer_documents_with_transactions(doc_ids[], new_owner_id) - Combined documents + transactions transfer (recommended)
    • Updates user_id and contributor_id on historical transactions when document ownership is transferred
    • Records audit trail in metadata (previous_user_id, ownership_transferred_at)
    • Migration: 20251219000001_admin_transfer_transactions_function.sql

2025-12-22

  • FEATURE: Added contributor_id and contributor_name to GET /transactions list endpoint
    • Document owner's user ID and display name now included for each transaction
    • Enables frontend to show contributor info in transaction list popovers
    • Fetches names from users table via efficient batch lookup
  • CHANGE: Removed contributor_id and contributor_name from GET /content-performance
    • These fields were redundant since this endpoint already filters by the logged-in user's contributor_id
    • All documents shown are the user's own content - no need to display their own name
    • Reduces unnecessary database query for user name lookup
  • FEATURE: Added dublin_core to list endpoints for hover popovers
    • GET /transactions now includes dublin_core field on each transaction
    • GET /content-performance now includes dublin_core field on each document
    • Extraction logic handles multiple storage locations:
      1. metadata.dublin_core (dedicated field, from backfill)
      2. metadata.sources[0].dublin_core (for query_usage/credit_earned)
      3. Flat dc_* fields in metadata (legacy format)
      4. source_title as dc_title fallback
    • For content-performance, fetches dublin_core from document_add transactions by source_url
    • Field is null if no Dublin Core metadata available
    • Frontend popovers now functional on Transaction History and Top Performing Content tables
  • BUGFIX: Fixed dublin_core returning null for Google Docs in /content-performance
    • Google Docs URLs have different formats (edit, view, export) that all refer to the same document
    • document_add transactions stored /edit?tab=t.0 format, but credit_earned transactions used /export?format=html
    • The exact URL match lookup was failing due to this mismatch
    • Now normalizes Google Docs URLs by document ID to match across URL formats
    • Also extended lookup to include document_update transactions (metadata can be updated there too)
  • BUGFIX: Fixed dublin_core returning null for Google Docs in /transactions/:id sources
    • Applied same Google Docs URL normalization fix to the sources[] array in transaction details
    • The fetchDublinCoreForSources() function extracts document ID from Google Docs URLs
    • Uses ilike pattern matching to find dublin_core metadata regardless of URL format variant
    • Prefers dublin_core from transaction metadata, falls back to lookup from document_add/update transactions

2025-12-18

  • BUGFIX: Fixed dublin_core returning null when Dublin Core metadata exists
    • The extractDublinCoreMetadata function now handles two formats:
      1. Nested object: src.dublin_core = { dc_title: "...", dc_creator: "..." } (from RAG endpoint)
      2. Flat fields: src.dc_title = "..." (legacy format)
    • Previously only checked for flat fields, missing the nested format stored by the RAG endpoint
    • Affects GET /transactions/:id endpoint - dublin_core field now correctly populated
  • BUGFIX: Fixed source_url not being returned for credit_earned transactions
    • Added fallback extraction from metadata.sources[0].source_url when column is NULL
    • Mirrors existing source_title fallback pattern
    • Frontend can now display clickable links to contributing documents in transaction detail modals
    • Affects /transactions list endpoint - the source_url field is now populated for credit_earned transactions
  • Updated test file to auto-create test user (no longer requires manual env var configuration)
  • Added test case for source_url field on credit_earned transactions
  • FEATURE: Added unique_documents_added to transaction_counts in /activity-timeline
    • document_add counts upload transactions (may include re-uploads of the same document)
    • unique_documents_added counts distinct source_url values (unique documents uploaded)
    • Enables KPI display of actual content portfolio growth vs activity volume
  • BUGFIX: Fixed Google Docs source_url storing export URLs instead of view URLs
    • Documents from Google Docs are now stored with view URLs (.../view) instead of export URLs (.../export?format=html)
    • Clicking on a Google Doc source_url now opens the document for viewing instead of triggering a download
    • Note: Existing Google Docs documents need to be re-uploaded to get the corrected URLs
    • Affects maintainSource function in documents-manager.ts
  • FEATURE: Added Dublin Core metadata to transaction detail sources[] response
    • New dublin_core field on each source in GET /transactions/:id response
    • Includes: dc_title, dc_creator, dc_publisher, dc_date, dc_rights, dc_description, dc_source, dc_identifier
    • Field is null if no Dublin Core metadata is available for that source
    • Enables frontend to build reusable metadata popover components
  • FEATURE: Added Dublin Core metadata to RAG endpoint transaction-summary SSE event
    • New dublin_core field on each source in the sources[] array of the transaction-summary event
    • Same structure as dashboardApi: dc_title, dc_creator, dc_publisher, dc_date, dc_rights, dc_description, dc_source, dc_identifier
    • Extracted from vector metadata at query time - works for existing documents if Dublin Core fields were stored during upload
    • Enables frontend to display metadata popovers in real-time query results (same component as transaction details)

2025-12-17

  • NEW: Leaderboard Dashboard (/dashboards/leaderboard/)
    • Template: src/dashboards/leaderboard/index.njk
    • JavaScript: dist/js/public/leaderboard.js
    • 4 KPI cards: Total Contributors, RoC Distributed, Your Rank, Your RoC Earned
    • Top 10 Contributors horizontal bar chart (Chart.js)
    • Paginated leaderboard table with rank badges (gold/silver/bronze for top 3)
    • Current user row highlighting with "You" badge
    • "Find Me" button using around_me=true API parameter
    • Flash animation when scrolling to user's position
  • UPDATED: Transactions Dashboard improvements
    • Added days parameter to transaction filtering (was only filtering timeline)
    • Replaced "RoC Spent" KPI with "IPR Fees" placeholder (coming soon)
    • Changed secondary KPI row to show transaction counts by type (Queries, RoC Earned, Uploads, Deletions)
    • Added loading state placeholders for all period-dependent KPIs
    • Type filter dropdown now available in both Balance and Activity views
  • UPDATED: Default period changed from 30 days to 7 days across all dashboards
    • transactions.js, content-analytics.js, customer-dashboard.js, balance-history.js
    • dashboard_controls.njk default updated
  • UPDATED: Content Analytics improvements
    • Simplified document display to clickable links (removed expandable rows)
    • Fixed "Content not found" error by checking both source_id and id fields
  • ADDED: CSS for leaderboard styling
    • Rank badges: .rank-gold, .rank-silver, .rank-bronze
    • Current user highlighting: .table-row-current-user
    • Flash animation: .flash-highlight with dark mode support
  • FIXED: Transaction detail modal URL label wrapping issue
  • FIXED: Dark mode readability for rank badges and form-select dropdowns
  • FEATURE: Added transaction_counts to /activity-timeline response summary
    • Provides counts of each transaction type (query_usage, document_add, credit_earned, etc.) for the selected period
    • Enables accurate KPI card rendering without relying on paginated transaction counts
    • Frontend can use summary.transaction_counts.query_usage etc. for KPI display

2025-12-16

  • BUGFIX: Fixed query_details.question to read from usage_context column (was incorrectly looking in metadata.question)
  • Added comprehensive "Building a Reusable Transaction Detail Component" section
    • Field availability matrix by transaction type
    • TypeScript types ready to copy to frontend
    • Component architecture pattern (composition-based)
    • Display labels, icons, and colors for all transaction types
    • Formatting helpers for RoC credits, fees, duration, relevance
    • Section component reference guide
  • Added Dublin Core Metadata Popovers section
    • Bootstrap 5 popover implementation for document/source links
    • Reference to existing implementation in ui/rag-client/index.html
    • JavaScript helpers: buildDCPopoverContent, hasDCMetadata, escapeHtml, initializePopovers
    • Dark mode styling (already in ui/shared/theme.css)
    • Memory leak prevention with popover cleanup on modal close
  • Added front-end integration notes for query_details.question field
    • Documents how to access the user's original query in transaction drill-down
    • Clarifies availability by endpoint and transaction type

2025-12-15

  • Added GET /leaderboard endpoint for contributor rankings
    • Shows full_name (not email) to enable discovery of subject matter experts
    • Supports around_me=true for "Where Am I?" functionality
    • Scoped to user's organization by default
  • Added GET /balance endpoint for account balance KPI
  • Added source_title to sources[] in transaction details response
  • Anonymized querier IDs in /roc-breakdown?group_by=querier (privacy)
  • SQL migration for source_title fallback from metadata
  • Backfill source_title from vector store metadata
  • Fixed balance_before/balance_after not being stored in transaction metadata
    • Values are now persisted after balance deduction for transaction drill-down
    • Affects query-transaction-service.ts (RAG function backend)

2025-12-13

  • Initial implementation
  • 5 REST endpoints for dashboard data
  • SQL migration for aggregation functions
  • TypeScript types for all request/response shapes

Authoritative Document Ownership

Important: The Dashboard API uses documents_${orgId}.user_id as the single source of truth for document ownership, NOT transactions.contributor_id.

Why This Matters

When document ownership is transferred (e.g., document originally uploaded on behalf of a new user), the historical transactions retain the original contributor_id. This is intentional - transactions are immutable audit records.

Data Sources:

Data Authoritative Source Notes
Document ownership documents_${orgId}.user_id Current owner
Account balance users.balance Real-time balance
Historical transactions transactions table Immutable audit log
Contributor name users.full_name via documents_${orgId}.user_id lookup

Affected Endpoints

All endpoints that return contributor_id and contributor_name now use authoritative ownership lookup:

  • GET /transactions - List endpoint fetches current owner from documents table
  • GET /transactions/:id - Detail endpoint sources[] uses current ownership
  • GET /leaderboard/contributors - Aggregates by current document owners
  • GET /leaderboard/content - Shows current owner of each document
  • GET /leaderboard - KPI summary uses current ownership

Frontend Implications

No frontend changes required - the API response shape is unchanged:

// The API still returns:
{
  contributor_id: string,    // Now from documents_${orgId}.user_id
  contributor_name: string   // Now from users.full_name via current owner
}

Historical Transaction Display: For very old transactions, the displayed contributor may differ from the original uploader if ownership was transferred. This is correct behavior - it reflects the current owner who benefits from RoC earnings.

Implementation Details

The authoritative ownership lookup is handled by utility functions in utils/document-ownership.ts:

  • fetchAuthoritativeDocumentOwners(supabase, orgId, sourceUrls) - Returns map of sourceUrl → {user_id, full_name}
  • fetchAuthoritativeOwnerIds(supabase, orgId, sourceUrls) - Returns map of sourceUrl → user_id
  • fetchUserNames(supabase, userIds) - Returns map of userId → full_name