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