Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.
import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js";
import webhookRoutes from "./src/routes/webhookRoutes.js";
import categoryRoutes from "./src/routes/categoryRoutes.js";

// Issue #245 — Search analytics
import searchAnalyticsRoutes from "./src/routes/analytics/search.js";
import { healthCheck, ping } from "./src/controllers/healthController.js";

const app = express();
Expand Down Expand Up @@ -225,6 +228,9 @@ app.use("/api/notifications", generousLimiter, notificationRoutes);
// Outbound webhook management API (admin-gated)
app.use("/api/webhooks", standardLimiter, webhookRoutes);

// Issue #245 — Search analytics (admin-gated; see routes/analytics/search.js)
app.use("/api/analytics/search", generousLimiter, searchAnalyticsRoutes);

// Internal service-to-service (dnb-ai) — signed-request auth, no user JWTs
app.use("/api/internal/ai", internalAiRoutes);

Expand Down
120 changes: 120 additions & 0 deletions src/controllers/analytics/searchAnalyticsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
getTopSearchQueries,
getZeroResultSearches,
getSearchSummary,
getSearchTrends,
} from "../../services/analytics/search-analytics-service.js";
import logger from "../../config/logger.js";

/**
* GET /api/analytics/search/top
* Get top search queries by frequency.
*/
export const getTopSearchQueriesHandler = async (req, res) => {
try {
const { startDate, endDate, type, limit, page } = req.query;

const result = await getTopSearchQueries({
startDate,
endDate,
type,
limit,
page,
});

res.status(200).json({
success: true,
data: result.queries,
pagination: result.pagination,
});
} catch (error) {
logger.error("Error fetching top search queries:", error);
res.status(500).json({
success: false,
message: "Failed to fetch top search queries",
});
}
};

/**
* GET /api/analytics/search/zero-results
* Get zero-result searches.
*/
export const getZeroResultSearchesHandler = async (req, res) => {
try {
const { startDate, endDate, type, limit, page } = req.query;

const result = await getZeroResultSearches({
startDate,
endDate,
type,
limit,
page,
});

res.status(200).json({
success: true,
data: result.queries,
pagination: result.pagination,
});
} catch (error) {
logger.error("Error fetching zero-result searches:", error);
res.status(500).json({
success: false,
message: "Failed to fetch zero-result searches",
});
}
};

/**
* GET /api/analytics/search/summary
* Get search analytics summary.
*/
export const getSearchSummaryHandler = async (req, res) => {
try {
const { startDate, endDate } = req.query;

const summary = await getSearchSummary({ startDate, endDate });

res.status(200).json({
success: true,
data: summary,
});
} catch (error) {
logger.error("Error fetching search summary:", error);
res.status(500).json({
success: false,
message: "Failed to fetch search summary",
});
}
};

/**
* GET /api/analytics/search/trends
* Get search trends over time.
*/
export const getSearchTrendsHandler = async (req, res) => {
try {
const { startDate, endDate, type } = req.query;

const trends = await getSearchTrends({ startDate, endDate, type });

res.status(200).json({
success: true,
data: trends,
});
} catch (error) {
logger.error("Error fetching search trends:", error);
res.status(500).json({
success: false,
message: "Failed to fetch search trends",
});
}
};

export default {
getTopSearchQueriesHandler,
getZeroResultSearchesHandler,
getSearchSummaryHandler,
getSearchTrendsHandler,
};
78 changes: 78 additions & 0 deletions src/middlewares/analytics/search-logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { logSearchEvent } from "../../services/analytics/search-analytics-service.js";
import logger from "../../config/logger.js";

/**
* Sum the number of returned items across the possible search response
* shapes. The search endpoints currently expose the payload either as
* `body.data` (standardized envelope) or the legacy `body.results`, and the
* payload itself may be a bare array (educators) or an object keyed by
* collection (courses/books/spaces/reels).
*/
const countResults = (body) => {
let payload = body?.data;
if (payload === undefined && body != null) {
payload = body.results;
}
if (payload === undefined) return 0;

if (Array.isArray(payload)) return payload.length;

if (payload && typeof payload === "object") {
let total = 0;
for (const items of Object.values(payload)) {
if (Array.isArray(items)) total += items.length;
}
return total;
}

return 0;
};

/**
* Middleware to log search queries for analytics.
*
* Wires into the real search routes and records one event per query with a
* timestamp (via the model's `createdAt`), the result count, and whether the
* search returned zero results — the signal used to spot dead ends in
* content discovery. Logging is fire-and-forget: a failed write is logged
* and never fails or slows the search response.
*/
export const searchLogger = (req, res, next) => {
const originalJson = res.json.bind(res);

res.json = function (body) {
// Restore the original so we don't double-wrap on subsequent calls.
res.json = originalJson;

const query = (req.query.q || req.query.query || "").trim();
if (query) {
const resultCount = countResults(body);
const event = {
userId: req.user?._id || null,
sessionId: req.headers["x-session-id"] || null,
query,
type: req.query.type || "all",
resultCount,
hasResults: resultCount > 0,
filters: {
category: req.query.category || null,
minPrice: req.query.minPrice || null,
maxPrice: req.query.maxPrice || null,
free: req.query.free || null,
minRating: req.query.minRating || null,
},
userAgent: req.headers["user-agent"] || null,
};

logSearchEvent(event).catch((err) =>
logger.error("Failed to log search event:", err)
);
}

return originalJson(body);
};

next();
};

export default searchLogger;
64 changes: 64 additions & 0 deletions src/models/search-analytics-event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import mongoose from "mongoose";

const searchAnalyticsEventSchema = new mongoose.Schema(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
default: null,
index: true,
},
sessionId: {
type: String,
default: null,
index: true,
},
query: {
type: String,
required: true,
trim: true,
index: true,
},
type: {
type: String,
default: "all",
enum: ["all", "courses", "books", "spaces", "reels", "educators"],
},
resultCount: {
type: Number,
default: 0,
min: 0,
},
hasResults: {
type: Boolean,
default: true,
index: true,
},
filters: {
type: mongoose.Schema.Types.Mixed,
default: {},
},
userAgent: {
type: String,
default: null,
},
},
{
timestamps: true,
}
);

// Lookup by query popularity within a window.
searchAnalyticsEventSchema.index({ query: 1, createdAt: -1 });
// Efficient zero-result reporting.
searchAnalyticsEventSchema.index({ hasResults: 1, createdAt: -1 });
// Rolling 90-day retention; older events are auto-pruned.
searchAnalyticsEventSchema.index(
{ createdAt: 1 },
{ expireAfterSeconds: 90 * 24 * 60 * 60 }
);

export default mongoose.model(
"SearchAnalyticsEvent",
searchAnalyticsEventSchema
);
27 changes: 27 additions & 0 deletions src/routes/analytics/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import express from "express";
import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js";
import {
getTopSearchQueriesHandler,
getZeroResultSearchesHandler,
getSearchSummaryHandler,
getSearchTrendsHandler,
} from "../../controllers/analytics/searchAnalyticsController.js";

const router = express.Router();

// All search analytics endpoints require admin authentication
router.use(protect, authorizeRoles("admin"));

// Top search queries by frequency
router.get("/top", getTopSearchQueriesHandler);

// Zero-result searches
router.get("/zero-results", getZeroResultSearchesHandler);

// Search summary metrics
router.get("/summary", getSearchSummaryHandler);

// Search trends over time
router.get("/trends", getSearchTrendsHandler);

export default router;
15 changes: 5 additions & 10 deletions src/routes/searchRoutes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import express from "express";
import { searchAll } from "../controllers/searchController.js";
import { searchAll, searchEducatorsHandler } from "../controllers/searchController.js";
import { cacheMiddleware } from "../middlewares/cache.js";
import { searchLogger } from "../middlewares/analytics/search-logger.js";
import { CACHE_TTL, CACHE_KEYS } from "../utils/cache.js";

const router = express.Router();
Expand All @@ -10,22 +10,17 @@ const router = express.Router();
const searchCacheKey = (req) => {
const query = req.query.q || req.query.query || "";
const type = req.query.type || "all";
return `${CACHE_KEYS.SEARCH}${type}:${query.toLowerCase().trim()}`;
};

// Main search endpoint - cached for 5 minutes
router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll);
const page = req.query.page || 1;
const limit = req.query.limit || 10;
const filterKeys = ['minPrice', 'maxPrice', 'free', 'category', 'minRating', 'interest', 'sort'];
const filtersStr = filterKeys.map(k => `${k}=${req.query[k] || ''}`).join('&');
return `${CACHE_KEYS.SEARCH}${req.path}:${type}:${query.toLowerCase().trim()}:page=${page}:limit=${limit}:${filtersStr}`;
};

// Main search endpoint
router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll);
// Main search endpoint — logs every query for analytics (issue #245).
router.get("/", searchLogger, cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll);

// Dedicated educators endpoint
router.get("/educators", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchEducatorsHandler);
// Dedicated educators endpoint — logs every query for analytics (issue #245).
router.get("/educators", searchLogger, cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchEducatorsHandler);

export default router;
Loading
Loading