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
227 lines
6.3 KiB
TypeScript
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 };
|
|
}),
|
|
});
|