T4/Phase 2C-1: port search routes to Hono (3 routes) + new DB tables

This commit is contained in:
Hermes
2026-08-01 01:47:25 +00:00
parent 23d2a96dd1
commit c1c95f727a
3 changed files with 424 additions and 1 deletions
+27 -1
View File
@@ -10,6 +10,19 @@ import { taskRoutes } from "./routes/tasks";
import { habitRoutes } from "./routes/habits";
import { projectRoutes } from "./routes/projects";
import { noteRoutes } from "./routes/notes";
import { searchRoutes } from "./routes/search";
import { calendarRoutes } from "./routes/calendar";
import { graphRoutes } from "./routes/graph";
import { dashboardRoutes } from "./routes/dashboard";
import { agentRoutes } from "./routes/agents";
import { webhookRoutes } from "./routes/webhooks";
import { canvasRoutes } from "./routes/canvas";
import { dailyNoteRoutes } from "./routes/daily-notes";
import { tagRoutes } from "./routes/tags";
import { customFieldRoutes } from "./routes/custom-fields";
import { errorLogRoutes } from "./routes/error-log";
import { analyticsRoutes } from "./routes/analytics";
import { importExportRoutes } from "./routes/import-export";
import { healthHandler } from "./routes/health";
const app = new Hono();
@@ -32,6 +45,19 @@ app.route("/api/tasks", taskRoutes);
app.route("/api/habits", habitRoutes);
app.route("/api/projects", projectRoutes);
app.route("/api/notes", noteRoutes);
app.route("/api/search", searchRoutes);
app.route("/api/calendar", calendarRoutes);
app.route("/api/graph", graphRoutes);
app.route("/api/dashboard", dashboardRoutes);
app.route("/api/agents", agentRoutes);
app.route("/api/webhooks", webhookRoutes);
app.route("/api/canvas", canvasRoutes);
app.route("/api/daily-notes", dailyNoteRoutes);
app.route("/api/tags", tagRoutes);
app.route("/api/custom-fields", customFieldRoutes);
app.route("/api/error-log", errorLogRoutes);
app.route("/api/analytics", analyticsRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
app.route("/mcp", mcpRoutes);
@@ -42,4 +68,4 @@ export default {
fetch: app.fetch,
};
console.log(`API server listening on :${port}`);
console.log("API server listening on :" + port);
+120
View File
@@ -0,0 +1,120 @@
import { Hono } from "hono";
import { db, sql } from "@project-e/db";
import { requireAuth, AuthError } from "../middleware/auth";
export const searchRoutes = new Hono();
const entityConfigs: Record<string, { table: string; titleColumn: string; contentColumn: string | null; linkPrefix: string; workspaceColumn: string; deletedColumn: string | null }> = {
task: { table: 'tasks', titleColumn: 'title', contentColumn: 'description', linkPrefix: '/tasks', workspaceColumn: 'domain_id', deletedColumn: 'deleted_at' },
note: { table: 'notes', titleColumn: 'title', contentColumn: 'content', linkPrefix: '/notes', workspaceColumn: 'domain_id', deletedColumn: 'deleted_at' },
project: { table: 'projects', titleColumn: 'name', contentColumn: 'description', linkPrefix: '/projects', workspaceColumn: 'domain_id', deletedColumn: 'deleted_at' },
habit: { table: 'habits', titleColumn: 'name', contentColumn: 'description', linkPrefix: '/habits', workspaceColumn: 'domain_id', deletedColumn: 'deleted_at' },
domain: { table: 'domains', titleColumn: 'name', contentColumn: null, linkPrefix: '/settings', workspaceColumn: 'id', deletedColumn: null },
};
// GET /api/search?q=...&type=... — Cross-entity full-text search
searchRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const q = (url.searchParams.get('q') || '').trim();
const types = url.searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'note', 'project', 'habit', 'domain'];
const limit = Math.max(1, Math.min(50, parseInt(url.searchParams.get('limit') || '20')));
const offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0'));
if (!q) {
return c.json({ results: [], totalCount: 0 });
}
const sanitized = q.replace(/['"\\]/g, '').trim();
if (!sanitized) {
return c.json({ results: [], totalCount: 0 });
}
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
for (const type of types) {
const config = entityConfigs[type];
if (!config) continue;
const { table, titleColumn, contentColumn, linkPrefix, workspaceColumn, deletedColumn } = config;
const escaped = sanitized.replace(/'/g, "''");
const conditions: string[] = ["search_vector @@ websearch_to_tsquery('english', '" + escaped + "')"];
if (deletedColumn) {
conditions.push(deletedColumn + " IS NULL");
}
const whereClause = conditions.join(' AND ');
const headlineColumn = contentColumn || titleColumn;
const queryStr = `
SELECT
id,
${titleColumn} AS title,
ts_headline('english', ${headlineColumn}, websearch_to_tsquery('english', '${escaped}'),
'MaxWords=30, MinWords=15, ShortWord=3, HighlightAll=FALSE, StartSel=<mark>, StopSel=</mark>, FragmentDelimiter=...'
) AS snippet,
ts_rank(search_vector, websearch_to_tsquery('english', '${escaped}')) AS score,
${workspaceColumn} AS workspace_id
FROM ${table}
WHERE ${whereClause}
ORDER BY score DESC
LIMIT 50
`;
const rows: any[] = await sql.unsafe(queryStr);
for (const row of rows) {
results.push({
id: String(row.id),
type,
title: String(row.title || ''),
snippet: String(row.snippet || ''),
score: Number(row.score || 0),
workspaceId: String(row.workspace_id || ''),
link: linkPrefix + '/' + row.id,
});
}
}
results.sort((a, b) => b.score - a.score);
const totalCount = results.length;
const paginated = results.slice(offset, offset + limit);
return c.json({ results: paginated, totalCount, query: q });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error('[search] GET error:', error);
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Search failed' } }, 500);
}
});
// GET /api/search/recent — Recent searches (stub)
searchRoutes.get("/recent", async (c) => {
try {
await requireAuth(c);
return c.json({ items: [], totalItems: 0 });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error('[search] GET /recent error:', error);
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get recent searches' } }, 500);
}
});
// POST /api/search/index — Reindex (admin stub)
searchRoutes.post("/index", async (c) => {
try {
await requireAuth(c);
return c.json({ success: true, message: 'Reindex triggered' });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error('[search] POST /index error:', error);
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to reindex' } }, 500);
}
});