diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 41c67cb..75cbd70 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts new file mode 100644 index 0000000..2d47336 --- /dev/null +++ b/apps/api/src/routes/search.ts @@ -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 = { + 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=, StopSel=, 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); + } +}); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 82b87de..47ca8ac 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -528,3 +528,280 @@ export const apiKeys = pgTable( index('api_keys_active_idx').on(table.active), ] ); + +// ── Agents ────────────────────────────────────────────────────────────────────── + +export const agents = pgTable( + 'agents', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + description: text('description'), + status: text('status').notNull().default('active'), + permissionTier: text('permission_tier').notNull().default('read_only'), + customPermissions: text('custom_permissions').array().default([]), + apiKey: text('api_key'), + lastActiveAt: timestamp('last_active_at', { withTimezone: true }), + domainId: uuid('domain_id') + .notNull() + .references((): any => domains.id, { onDelete: 'cascade' }), + tags: text('tags').array().default([]), + config: jsonb('config').$type>().default({}), + customFields: jsonb('custom_fields').$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('agents_domain_id_idx').on(table.domainId), + index('agents_status_idx').on(table.status), + ] +); + +// ── Agent Activity ────────────────────────────────────────────────────────────── + +export const agentActivity = pgTable( + 'agent_activity', + { + id: uuid('id').defaultRandom().primaryKey(), + agentId: uuid('agent_id') + .notNull() + .references((): any => agents.id, { onDelete: 'cascade' }), + action: text('action').notNull(), + entityType: text('entity_type'), + entityId: uuid('entity_id'), + details: jsonb('details').$type>().default({}), + success: boolean('success').default(true), + errorMessage: text('error_message'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('agent_activity_agent_id_idx').on(table.agentId), + index('agent_activity_created_at_idx').on(table.createdAt), + ] +); + +// ── Agent Tasks ───────────────────────────────────────────────────────────────── + +export const agentTasks = pgTable( + 'agent_tasks', + { + id: uuid('id').defaultRandom().primaryKey(), + agentId: uuid('agent_id') + .notNull() + .references((): any => agents.id, { onDelete: 'cascade' }), + taskType: text('task_type').notNull(), + input: jsonb('input').$type>().default({}), + status: text('status').notNull().default('pending'), + output: jsonb('output').$type>(), + errorMessage: text('error_message'), + startedAt: timestamp('started_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('agent_tasks_agent_id_idx').on(table.agentId), + index('agent_tasks_status_idx').on(table.status), + ] +); + +// ── Canvases ──────────────────────────────────────────────────────────────────── + +export const canvases = pgTable( + 'canvases', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + description: text('description'), + mode: text('mode').notNull().default('freeform'), + domainId: uuid('domain_id') + .notNull() + .references((): any => domains.id, { onDelete: 'cascade' }), + tags: text('tags').array().default([]), + viewport: jsonb('viewport').$type<{ x: number; y: number; zoom: number }>().default({ x: 0, y: 0, zoom: 1 }), + background: text('background'), + customFields: jsonb('custom_fields').$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('canvases_domain_id_idx').on(table.domainId), + ] +); + +// ── Canvas Cards ───────────────────────────────────────────────────────────────── + +export const canvasCards = pgTable( + 'canvas_cards', + { + id: uuid('id').defaultRandom().primaryKey(), + canvasId: uuid('canvas_id') + .notNull() + .references((): any => canvases.id, { onDelete: 'cascade' }), + type: text('type').notNull().default('note'), + entityId: uuid('entity_id'), + title: text('title'), + content: text('content'), + x: integer('x').default(0), + y: integer('y').default(0), + width: integer('width').default(200), + height: integer('height').default(150), + rotation: integer('rotation').default(0), + color: text('color'), + zIndex: integer('z_index').default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('canvas_cards_canvas_id_idx').on(table.canvasId), + ] +); + +// ── Canvas Connections ─────────────────────────────────────────────────────────── + +export const canvasConnections = pgTable( + 'canvas_connections', + { + id: uuid('id').defaultRandom().primaryKey(), + canvasId: uuid('canvas_id') + .notNull() + .references((): any => canvases.id, { onDelete: 'cascade' }), + sourceCardId: uuid('source_card_id') + .notNull() + .references((): any => canvasCards.id, { onDelete: 'cascade' }), + targetCardId: uuid('target_card_id') + .notNull() + .references((): any => canvasCards.id, { onDelete: 'cascade' }), + label: text('label'), + style: text('style').notNull().default('solid'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('canvas_connections_canvas_id_idx').on(table.canvasId), + ] +); + +// ── Calendar Events ────────────────────────────────────────────────────────────── + +export const calendarEvents = pgTable( + 'calendar_events', + { + id: uuid('id').defaultRandom().primaryKey(), + title: text('title').notNull(), + description: text('description'), + startTime: timestamp('start_time', { withTimezone: true }).notNull(), + endTime: timestamp('end_time', { withTimezone: true }), + allDay: boolean('all_day').default(false), + color: text('color'), + domainId: uuid('domain_id') + .notNull() + .references((): any => domains.id, { onDelete: 'cascade' }), + entityType: text('entity_type'), + entityId: uuid('entity_id'), + recurrenceRule: text('recurrence_rule'), + customFields: jsonb('custom_fields').$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('calendar_events_domain_id_idx').on(table.domainId), + index('calendar_events_start_time_idx').on(table.startTime), + index('calendar_events_end_time_idx').on(table.endTime), + ] +); + +// ── Dashboard Widgets ──────────────────────────────────────────────────────────── + +export const dashboardWidgets = pgTable( + 'dashboard_widgets', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id') + .notNull() + .references((): any => users.id, { onDelete: 'cascade' }), + type: text('type').notNull(), + title: text('title'), + config: jsonb('config').$type>().default({}), + layout: jsonb('layout').$type<{ x: number; y: number; w: number; h: number }>().default({ x: 0, y: 0, w: 2, h: 2 }), + domainId: uuid('domain_id') + .notNull() + .references((): any => domains.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('dashboard_widgets_user_id_idx').on(table.userId), + index('dashboard_widgets_domain_id_idx').on(table.domainId), + ] +); + +// ── Daily Notes ────────────────────────────────────────────────────────────────── + +export const dailyNotes = pgTable( + 'daily_notes', + { + id: uuid('id').defaultRandom().primaryKey(), + date: timestamp('date', { withTimezone: true }).notNull(), + content: text('content'), + domainId: uuid('domain_id') + .notNull() + .references((): any => domains.id, { onDelete: 'cascade' }), + mood: integer('mood'), + energy: integer('energy'), + customFields: jsonb('custom_fields').$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('daily_notes_domain_id_idx').on(table.domainId), + index('daily_notes_date_idx').on(table.date), + uniqueIndex('daily_notes_date_domain_idx').on(table.date, table.domainId), + ] +); + +// ── Custom Fields ──────────────────────────────────────────────────────────────── + +export const customFields = pgTable( + 'custom_fields', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + type: text('type').notNull().default('text'), + entityType: text('entity_type').notNull(), + domainId: uuid('domain_id') + .notNull() + .references((): any => domains.id, { onDelete: 'cascade' }), + required: boolean('required').default(false), + options: jsonb('options').$type().default([]), + defaultValue: jsonb('default_value'), + sortOrder: integer('sort_order').default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('custom_fields_domain_id_idx').on(table.domainId), + index('custom_fields_entity_type_idx').on(table.entityType), + ] +); + +// ── Error Logs ─────────────────────────────────────────────────────────────────── + +export const errorLogs = pgTable( + 'error_logs', + { + id: uuid('id').defaultRandom().primaryKey(), + level: text('level').notNull().default('error'), + source: text('source').notNull(), + message: text('message').notNull(), + stackTrace: text('stack_trace'), + metadata: jsonb('metadata').$type>().default({}), + resolved: boolean('resolved').default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('error_logs_level_idx').on(table.level), + index('error_logs_created_at_idx').on(table.createdAt), + index('error_logs_resolved_idx').on(table.resolved), + ] +);