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; } function buildSearchCondition( entity: SearchableEntityConfig, field: string, query: string ): string { // mission_control_documents.tags is text[]; ilike fails with Postgres 42883. // Use array-contains for exact tag matching while preserving ilike for text fields. if (entity.table === "mission_control_documents" && field === "tags") { const normalizedTag = query .trim() .toLowerCase() .replace(/\\/g, "\\\\") .replace(/"/g, '\\"'); return `${field}.cs.{"${normalizedTag}"}`; } return `${field}.ilike.%${query}%`; } 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) => buildSearchCondition(entity, field, 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 } ); } }