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

222 lines
6.3 KiB
TypeScript

import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
export const runtime = "nodejs";
// Search result types - extendable for future search types
export type SearchResultType =
| "task"
| "project"
| "document"
| "sprint"
| "activity";
export interface SearchResult {
id: string;
type: SearchResultType;
title: string;
subtitle?: string;
description?: string;
status?: string;
priority?: string;
color?: string;
url?: string;
icon?: string;
metadata?: Record<string, string>;
}
export interface SearchResponse {
results: SearchResult[];
total: number;
query: string;
}
// Search configuration - easily extensible
interface SearchConfig {
table: string;
type: SearchResultType;
fields: string[];
titleField: string;
subtitleField?: string;
descriptionField?: string;
statusField?: string;
priorityField?: string;
colorField?: string;
urlGenerator: (item: any) => string;
icon: string;
enabled: boolean;
searchFields: string[]; // Fields to search in
}
// Define searchable entities - add new ones here
const searchConfigs: SearchConfig[] = [
{
table: "tasks",
type: "task",
fields: ["id", "title", "description", "status", "priority", "project_id", "type"],
titleField: "title",
subtitleField: "type",
descriptionField: "description",
statusField: "status",
priorityField: "priority",
urlGenerator: (item) => `https://gantt-board.vercel.app/tasks/${item.id}`,
icon: "kanban",
enabled: true,
searchFields: ["title", "description"],
},
{
table: "projects",
type: "project",
fields: ["id", "name", "description", "color", "status"],
titleField: "name",
descriptionField: "description",
colorField: "color",
statusField: "status",
urlGenerator: (item) => `/projects`,
icon: "folder-kanban",
enabled: true,
searchFields: ["name", "description"],
},
{
table: "sprints",
type: "sprint",
fields: ["id", "name", "goal", "status", "start_date", "end_date", "project_id"],
titleField: "name",
subtitleField: "goal",
statusField: "status",
urlGenerator: (item) => `https://gantt-board.vercel.app/sprints/${item.id}`,
icon: "timer",
enabled: true,
searchFields: ["name", "goal"],
},
{
table: "mission_control_documents",
type: "document",
fields: ["id", "title", "content", "folder", "tags"],
titleField: "title",
subtitleField: "folder",
descriptionField: "content",
urlGenerator: (item) => `/documents`,
icon: "file-text",
enabled: true,
searchFields: ["title", "content"],
},
// Future: Add more search types here
// {
// table: "meetings",
// type: "meeting",
// ...
// }
];
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 with service role for full access
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: SearchResult[] = [];
// Search each configured entity
for (const config of searchConfigs) {
if (!config.enabled) continue;
try {
// Build OR filter dynamically based on searchFields
const orConditions = config.searchFields
.map(field => `${field}.ilike.%${query}%`)
.join(",");
const { data, error } = await supabase
.from(config.table)
.select(config.fields.join(", "))
.or(orConditions)
.limit(10);
if (error) {
console.error(`Search error in ${config.table}:`, error);
continue;
}
if (data) {
const mappedResults: SearchResult[] = data.map((item: any) => ({
id: item.id,
type: config.type,
title: item[config.titleField] || "Untitled",
subtitle: config.subtitleField ? item[config.subtitleField] : undefined,
description: config.descriptionField ? item[config.descriptionField] : undefined,
status: config.statusField ? item[config.statusField] : undefined,
priority: config.priorityField ? item[config.priorityField] : undefined,
color: config.colorField ? item[config.colorField] : undefined,
url: config.urlGenerator(item),
icon: config.icon,
metadata: {
table: config.table,
...Object.entries(item).reduce((acc, [key, value]) => {
if (typeof value === "string" || typeof value === "number") {
acc[key] = String(value);
}
return acc;
}, {} as Record<string, string>),
},
}));
results.push(...mappedResults);
}
} catch (err) {
console.error(`Error searching ${config.table}:`, err);
}
}
// Sort results by relevance (exact matches first, then partial)
results.sort((a, b) => {
const aTitle = a.title.toLowerCase();
const bTitle = b.title.toLowerCase();
// Exact match gets highest priority
if (aTitle === query && bTitle !== query) return -1;
if (bTitle === query && aTitle !== query) return 1;
// Starts with query gets second priority
if (aTitle.startsWith(query) && !bTitle.startsWith(query)) return -1;
if (bTitle.startsWith(query) && !aTitle.startsWith(query)) return 1;
// Otherwise alphabetical
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 }
);
}
}