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