ubiquitous-invention/apps/web/server/routers/search.ts
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

227 lines
6.3 KiB
TypeScript

import { z } from "zod";
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { objects } from "@tasks/database/schema";
import { objectTypes } from "@tasks/shared";
import type { Context } from "@/server/trpc";
import { router, protectedProcedure } from "@/server/trpc";
const objectTypeSchema = z.enum(objectTypes);
function escapeIlike(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
function makeSnippet(
description: string | null,
query: string,
maxLen = 160,
): string | null {
if (!description) return null;
const t = description.trim();
if (!t) return null;
const q = query.trim().toLowerCase();
if (!q) {
return t.length > maxLen ? `${t.slice(0, maxLen)}` : t;
}
const lower = t.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) {
return t.length > maxLen ? `${t.slice(0, maxLen)}` : t;
}
const pad = 48;
const start = Math.max(0, idx - pad);
const end = Math.min(t.length, idx + query.length + pad);
let s = t.slice(start, end);
if (start > 0) s = `${s}`;
if (end < t.length) s = `${s}`;
return s;
}
type ParentRow = { parentId: string | null; title: string };
export type SearchResult = {
id: string;
type: string;
title: string;
status: string | null;
parentId: string | null;
workspaceId: string | null;
descriptionSnippet: string | null;
parentBreadcrumb: string | null;
};
async function fetchAncestorMap(
db: Context["db"],
parentIds: string[],
): Promise<Map<string, ParentRow>> {
const map = new Map<string, ParentRow>();
const seen = new Set<string>();
let frontier = [...new Set(parentIds)];
while (frontier.length > 0) {
const batch = await db
.select({
id: objects.id,
parentId: objects.parentId,
title: objects.title,
})
.from(objects)
.where(inArray(objects.id, frontier));
const next: string[] = [];
for (const row of batch) {
map.set(row.id, { parentId: row.parentId, title: row.title });
seen.add(row.id);
if (row.parentId && !seen.has(row.parentId)) {
next.push(row.parentId);
}
}
frontier = [...new Set(next.filter((id) => !seen.has(id)))];
}
return map;
}
function parentBreadcrumb(
parentId: string | null,
ancestorMap: Map<string, ParentRow>,
): string | null {
if (!parentId) return null;
const parts: string[] = [];
let cur: string | null = parentId;
for (let i = 0; i < 32 && cur; i++) {
const row = ancestorMap.get(cur);
if (!row) break;
parts.unshift(row.title);
cur = row.parentId;
}
return parts.length > 0 ? parts.join(" > ") : null;
}
export const searchRouter = router({
search: protectedProcedure
.input(
z.object({
query: z.string(),
workspaceId: z.string().uuid().optional(),
type: objectTypeSchema.optional(),
limit: z.number().int().positive().max(100).optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 20;
const raw = input.query.trim();
if (raw.length === 0) {
return { results: [] as SearchResult[] };
}
const pattern = `%${escapeIlike(raw)}%`;
const matchCondition = sql`(${objects.title} ILIKE ${pattern} ESCAPE '\\' OR ${objects.description} ILIKE ${pattern} ESCAPE '\\')`;
const conditions = [isNull(objects.archivedAt), matchCondition];
if (input.workspaceId !== undefined) {
conditions.push(eq(objects.workspaceId, input.workspaceId));
}
if (input.type !== undefined) {
conditions.push(eq(objects.type, input.type));
}
const titleFirst = sql<number>`
CASE
WHEN ${objects.title} ILIKE ${pattern} ESCAPE '\\' THEN 0
ELSE 1
END
`.mapWith(Number);
const rows = await ctx.db
.select({
id: objects.id,
type: objects.type,
title: objects.title,
status: objects.status,
parentId: objects.parentId,
workspaceId: objects.workspaceId,
description: objects.description,
})
.from(objects)
.where(and(...conditions))
.orderBy(titleFirst, desc(objects.updatedAt))
.limit(limit);
const parentIds = rows
.map((r) => r.parentId)
.filter((x): x is string => x != null);
const ancestorMap =
parentIds.length > 0
? await fetchAncestorMap(ctx.db, parentIds)
: new Map<string, ParentRow>();
const results: SearchResult[] = rows.map((r) => ({
id: r.id,
type: r.type,
title: r.title,
status: r.status,
parentId: r.parentId,
workspaceId: r.workspaceId,
descriptionSnippet: makeSnippet(r.description, raw),
parentBreadcrumb: parentBreadcrumb(r.parentId, ancestorMap),
}));
return { results };
}),
recent: protectedProcedure
.input(
z.object({
workspaceId: z.string().uuid().optional(),
limit: z.number().int().positive().max(50).optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 10;
const conditions = [isNull(objects.archivedAt)];
if (input.workspaceId !== undefined) {
conditions.push(eq(objects.workspaceId, input.workspaceId));
}
const rows = await ctx.db
.select({
id: objects.id,
type: objects.type,
title: objects.title,
status: objects.status,
parentId: objects.parentId,
workspaceId: objects.workspaceId,
description: objects.description,
})
.from(objects)
.where(and(...conditions))
.orderBy(desc(objects.updatedAt))
.limit(limit);
const parentIds = rows
.map((r) => r.parentId)
.filter((x): x is string => x != null);
const ancestorMap =
parentIds.length > 0
? await fetchAncestorMap(ctx.db, parentIds)
: new Map<string, ParentRow>();
const results: SearchResult[] = rows.map((r) => ({
id: r.id,
type: r.type,
title: r.title,
status: r.status,
parentId: r.parentId,
workspaceId: r.workspaceId,
descriptionSnippet: makeSnippet(r.description, ""),
parentBreadcrumb: parentBreadcrumb(r.parentId, ancestorMap),
}));
return { results };
}),
});