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 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 } export interface SearchResponse { results: SearchableResult[]; total: number; query: 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; 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", 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", 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", 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", icon: "file-text", enabled: true, searchFields: ["title", "content"], getUrl: () => getMissionControlDocumentsUrl(), getSnippet: (item) => item.content ? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}` : undefined, }, // Add new searchable entities here: // { // table: "meetings", // type: "meeting", // titleField: "title", // icon: "calendar", // searchFields: ["title", "notes"], // getUrl: (item) => `/meetings/${item.id}`, // } ]; // ============================================================================ // API HANDLER // ============================================================================ export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const query = searchParams.get("q")?.trim().toLowerCase(); if (!query || query.length < 2) { return NextResponse.json({ results: [], total: 0, query: query || "" }); } // 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; 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); const { data, error } = await supabase .from(entity.table) .select(selectFields.join(", ")) .or(orConditions) .limit(10); if (error) { console.error(`Search error in ${entity.table}:`, error); continue; } if (data) { const mappedResults: SearchableResult[] = data.map((item: any) => ({ 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, })); results.push(...mappedResults); } } catch (err) { console.error(`Error searching ${entity.table}:`, err); } } // Sort by relevance: exact match > starts with > contains results.sort((a, b) => { const aTitle = a.title.toLowerCase(); const bTitle = b.title.toLowerCase(); const lowerQuery = query.toLowerCase(); if (aTitle === lowerQuery && bTitle !== lowerQuery) return -1; if (bTitle === lowerQuery && aTitle !== lowerQuery) return 1; if (aTitle.startsWith(lowerQuery) && !bTitle.startsWith(lowerQuery)) return -1; if (bTitle.startsWith(lowerQuery) && !aTitle.startsWith(lowerQuery)) return 1; return aTitle.localeCompare(bTitle); }); // Limit total results const limitedResults = results.slice(0, 50); return NextResponse.json({ results: limitedResults, total: limitedResults.length, query, }); } catch (error) { console.error("Search API error:", error); return NextResponse.json( { error: "Search failed" }, { status: 500 } ); } }