- 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
422 lines
13 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|