mission-control/app/api/search/route.ts
OpenClaw Bot 0092b318c2 feat(search): Enhance Mission Control search functionality
- Add search result highlighting with match positions
- Implement advanced filters (type, status, date range)
- Add relevance scoring algorithm with recency/status boosts
- Add search history with localStorage persistence
- Create useSearch and useSearchHistory hooks
- Add filter UI with popover component
- Improve visual feedback and status icons

Task: 56ae2be4-fcf1-403a-87fb-ea9de966f456
2026-02-25 15:28:47 -06:00

422 lines
13 KiB
TypeScript

import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import {
getGanttProjectUrl,
getGanttSprintUrl,
getGanttTaskUrl,
getMissionControlDocumentsUrl,
} from "@/lib/config/sites";
export const runtime = "nodejs";
// ============================================================================
// SEARCHABLE PROTOCOL - Minimal search result interface
// ============================================================================
export type SearchableType = "task" | "project" | "document" | "sprint";
export interface SearchResultHighlight {
field: string;
text: string;
matches: Array<{ start: number; end: number }>;
}
export interface SearchableResult {
id: string;
type: SearchableType;
title: string;
snippet?: string; // Brief preview text (max 150 chars)
url: string; // Deep link to full view
icon: string;
status?: string; // For visual badges
color?: string; // For project/task colors
highlights?: SearchResultHighlight[]; // Highlighted matches
score: number; // Relevance score for sorting
updatedAt?: string;
createdAt?: string;
}
export interface SearchResponse {
results: SearchableResult[];
total: number;
query: string;
filters?: SearchFilters;
executionTimeMs?: number;
}
export interface SearchFilters {
types?: SearchableType[];
status?: string[];
dateFrom?: string;
dateTo?: string;
}
// ============================================================================
// SEARCHABLE ENTITY CONFIGURATION
// Add new searchable types here following the pattern
// ============================================================================
interface SearchableEntityConfig {
table: string;
type: SearchableType;
titleField: string;
snippetField?: string;
statusField?: string;
colorField?: string;
dateFields?: string[];
icon: string;
enabled: boolean;
searchFields: string[];
// Generate URL for deep linking to full view
getUrl: (item: any) => string;
// Generate snippet from content
getSnippet?: (item: any) => string | undefined;
}
const searchableEntities: SearchableEntityConfig[] = [
{
table: "tasks",
type: "task",
titleField: "title",
snippetField: "description",
statusField: "status",
dateFields: ["created_at", "updated_at", "due_date"],
icon: "kanban",
enabled: true,
searchFields: ["title", "description"],
getUrl: (item) => getGanttTaskUrl(String(item.id)),
getSnippet: (item) => item.description
? `${item.description.substring(0, 150)}${item.description.length > 150 ? "..." : ""}`
: undefined,
},
{
table: "projects",
type: "project",
titleField: "name",
snippetField: "description",
colorField: "color",
statusField: "status",
dateFields: ["created_at", "updated_at"],
icon: "folder-kanban",
enabled: true,
searchFields: ["name", "description"],
getUrl: (item) => getGanttProjectUrl(String(item.id)),
getSnippet: (item) => item.description
? `${item.description.substring(0, 150)}${item.description.length > 150 ? "..." : ""}`
: undefined,
},
{
table: "sprints",
type: "sprint",
titleField: "name",
snippetField: "goal",
statusField: "status",
dateFields: ["start_date", "end_date", "created_at"],
icon: "timer",
enabled: true,
searchFields: ["name", "goal"],
getUrl: (item) => getGanttSprintUrl(String(item.id)),
getSnippet: (item) => item.goal
? `${item.goal.substring(0, 150)}${item.goal.length > 150 ? "..." : ""}`
: undefined,
},
{
table: "mission_control_documents",
type: "document",
titleField: "title",
snippetField: "content",
dateFields: ["created_at", "updated_at"],
icon: "file-text",
enabled: true,
searchFields: ["title", "content", "tags"],
getUrl: () => getMissionControlDocumentsUrl(),
getSnippet: (item) => item.content
? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}`
: undefined,
},
];
// ============================================================================
// HIGHLIGHTING UTILITIES
// ============================================================================
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function findMatches(text: string, query: string): Array<{ start: number; end: number }> {
const matches: Array<{ start: number; end: number }> = [];
if (!text || !query) return matches;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let index = 0;
while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) {
matches.push({ start: index, end: index + query.length });
index += query.length;
}
return matches;
}
function createHighlightedSnippet(
text: string,
query: string,
maxLength: number = 150
): { text: string; matches: Array<{ start: number; end: number }> } {
if (!text) return { text: "", matches: [] };
const matches = findMatches(text, query);
if (matches.length === 0) {
// No match in this field, return truncated text
return {
text: text.length > maxLength ? text.substring(0, maxLength) + "..." : text,
matches: []
};
}
// Find the first match and center the snippet around it
const firstMatch = matches[0];
const contextSize = Math.floor((maxLength - query.length) / 2);
let start = Math.max(0, firstMatch.start - contextSize);
let end = Math.min(text.length, firstMatch.end + contextSize);
// Adjust if we're at the beginning or end
if (start === 0) {
end = Math.min(text.length, maxLength);
} else if (end === text.length) {
start = Math.max(0, text.length - maxLength);
}
let snippet = text.substring(start, end);
const prefix = start > 0 ? "..." : "";
const suffix = end < text.length ? "..." : "";
// Adjust match positions for the snippet
const adjustedMatches = matches
.filter(m => m.start >= start && m.end <= end)
.map(m => ({
start: m.start - start + prefix.length,
end: m.end - start + prefix.length
}));
return {
text: prefix + snippet + suffix,
matches: adjustedMatches
};
}
// ============================================================================
// RELEVANCE SCORING
// ============================================================================
function calculateRelevanceScore(
item: any,
query: string,
entity: SearchableEntityConfig
): number {
const lowerQuery = query.toLowerCase();
let score = 0;
// Title match scoring
const title = item[entity.titleField]?.toLowerCase() || "";
if (title === lowerQuery) {
score += 100; // Exact match
} else if (title.startsWith(lowerQuery)) {
score += 80; // Starts with query
} else if (title.includes(lowerQuery)) {
score += 60; // Contains query
}
// Snippet/content match scoring
if (entity.snippetField) {
const snippet = item[entity.snippetField]?.toLowerCase() || "";
const snippetMatches = (snippet.match(new RegExp(escapeRegex(lowerQuery), 'g')) || []).length;
score += Math.min(snippetMatches * 10, 40); // Up to 40 points for multiple matches
}
// Recency boost (if updated_at exists)
if (item.updated_at) {
const daysSinceUpdate = (Date.now() - new Date(item.updated_at).getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceUpdate < 7) score += 10;
else if (daysSinceUpdate < 30) score += 5;
}
// Status boost for active items
if (entity.statusField) {
const status = item[entity.statusField]?.toLowerCase();
if (status === "in-progress" || status === "open" || status === "active") {
score += 5;
}
}
return score;
}
// ============================================================================
// API HANDLER
// ============================================================================
export async function GET(request: Request) {
const startTime = Date.now();
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q")?.trim();
// Parse filters
const filters: SearchFilters = {};
const typesParam = searchParams.get("types");
if (typesParam) {
filters.types = typesParam.split(",") as SearchableType[];
}
const statusParam = searchParams.get("status");
if (statusParam) {
filters.status = statusParam.split(",");
}
filters.dateFrom = searchParams.get("dateFrom") || undefined;
filters.dateTo = searchParams.get("dateTo") || undefined;
if (!query || query.length < 2) {
return NextResponse.json({
results: [],
total: 0,
query: query || "",
filters
});
}
// Create Supabase client
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json(
{ error: "Database not configured" },
{ status: 500 }
);
}
const supabase = createClient(supabaseUrl, supabaseKey);
const results: SearchableResult[] = [];
// Search each enabled entity
for (const entity of searchableEntities) {
if (!entity.enabled) continue;
// Skip if type filter is applied and this entity is not included
if (filters.types && !filters.types.includes(entity.type)) continue;
try {
// Build OR filter for search fields
const orConditions = entity.searchFields
.map(field => `${field}.ilike.%${query}%`)
.join(",");
// Only select fields we need for search results
const selectFields = ["id", entity.titleField];
if (entity.snippetField) selectFields.push(entity.snippetField);
if (entity.statusField) selectFields.push(entity.statusField);
if (entity.colorField) selectFields.push(entity.colorField);
if (entity.dateFields) {
entity.dateFields.forEach(df => {
if (!selectFields.includes(df)) selectFields.push(df);
});
}
let dbQuery = supabase
.from(entity.table)
.select(selectFields.join(", "))
.or(orConditions);
// Apply status filter if specified
if (filters.status && entity.statusField) {
dbQuery = dbQuery.in(entity.statusField, filters.status);
}
// Apply date filters if specified
if (entity.dateFields) {
if (filters.dateFrom && entity.dateFields[0]) {
dbQuery = dbQuery.gte(entity.dateFields[0], filters.dateFrom);
}
if (filters.dateTo && entity.dateFields[0]) {
dbQuery = dbQuery.lte(entity.dateFields[0], filters.dateTo);
}
}
const { data, error } = await dbQuery.limit(10);
if (error) {
console.error(`Search error in ${entity.table}:`, error);
continue;
}
if (data) {
const mappedResults: SearchableResult[] = data.map((item: any) => {
const score = calculateRelevanceScore(item, query, entity);
const titleSnippet = createHighlightedSnippet(
item[entity.titleField],
query,
100
);
const contentSnippet = entity.snippetField
? createHighlightedSnippet(item[entity.snippetField], query, 150)
: undefined;
return {
id: item.id,
type: entity.type,
title: item[entity.titleField] || "Untitled",
snippet: entity.getSnippet ? entity.getSnippet(item) : undefined,
url: entity.getUrl(item),
icon: entity.icon,
status: entity.statusField ? item[entity.statusField] : undefined,
color: entity.colorField ? item[entity.colorField] : undefined,
highlights: [
{
field: entity.titleField,
text: titleSnippet.text,
matches: titleSnippet.matches
},
...(contentSnippet ? [{
field: entity.snippetField!,
text: contentSnippet.text,
matches: contentSnippet.matches
}] : [])
],
score,
updatedAt: item.updated_at || item.updatedAt,
createdAt: item.created_at || item.createdAt,
};
});
results.push(...mappedResults);
}
} catch (err) {
console.error(`Error searching ${entity.table}:`, err);
}
}
// Sort by relevance score (highest first)
results.sort((a, b) => b.score - a.score);
// Limit total results
const limitedResults = results.slice(0, 50);
return NextResponse.json({
results: limitedResults,
total: limitedResults.length,
query,
filters: Object.keys(filters).length > 0 ? filters : undefined,
executionTimeMs: Date.now() - startTime,
});
} catch (error) {
console.error("Search API error:", error);
return NextResponse.json(
{ error: "Search failed" },
{ status: 500 }
);
}
}