mission-control/app/api/search/route.ts

231 lines
7.0 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 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 }
);
}
}