import { Inject, Body, Controller, Delete, Get, HttpCode, Post, Put, Query, Req } from "@nestjs/common"; import type { Request } from "express"; import { z } from "zod"; import { SearchService } from "./search.service.js"; import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js"; import { requireActor, requireUser } from "../../common/types.js"; /** * Search routes (TASK-0097 S3). Live contract (recorded in * docs/api/search.md — the Phase-0 draft's `query` field + 1–200 limit * never matched this endpoint): * * POST /search { q, types?, workspaceId?, teamId?, createdBy?, * updatedAfter?, updatedBefore?, sortBy?, cursor?, * limit? } → { data: SearchResult[], nextCursor, * tookMs }. `sortBy` (T-155): "relevance" (default) * | "createdAt" | "updatedAt" (newest-first). * GET /search/query ?q&workspaceId&limit&cursor → BARE SearchResult[] * GET /search/suggest ?q&limit → BARE {id,type,title}[] * GET /search/recent → { data: string[] } * PUT /search/recent { q } → { data: string[] } * DELETE /search/recent → { cleared: boolean } * * `q` (not the task spec's `query`) is the field the LIVE web client sends — * recorded contract choice (phase-08-search.md "Phase 8 Notes"). */ const SEARCH_TYPES = ["page", "block", "database", "record", "comment", "file", "user"] as const; const isoDate = z.string().refine((s) => Number.isFinite(Date.parse(s)), { message: "invalid ISO-8601 date" }); /** POST /search body — field names are pinned by the live web client (`q`). */ const SearchBody = z.object({ q: z.string().trim().min(1).max(200), types: z.array(z.enum(SEARCH_TYPES)).max(7).optional(), workspaceId: z.string().min(1).optional(), teamId: z.string().min(1).optional(), createdBy: z.string().min(1).optional(), updatedAfter: isoDate.optional(), updatedBefore: isoDate.optional(), // T-155: first-class date sorts (spec §10 "creation date / modification // date"). `relevance` is the default (the existing `_score` path). sortBy: z.enum(["relevance", "createdAt", "updatedAt"]).optional(), cursor: z.string().min(1).optional(), // zod enforces 1..50 (LIMITS.MAX_SEARCH_LIMIT) — `limit: 51` → 400 VALIDATION_ERROR. limit: z.number().int().min(1).max(50).optional(), }); type SearchBodyInput = z.infer; /** * Comma-joined type list (the ⌘K dialog sends `types=page,database` as a * single query param — TASK-0098 contract addendum, recorded in * docs/api/search.md). Splits on ","; unknown values → 400 VALIDATION_ERROR * (same enum as the POST body's `types`). */ const typesCsv = z .string() .transform((s) => s.split(",").map((t) => t.trim()).filter((t) => t.length > 0)) .pipe(z.array(z.enum(SEARCH_TYPES)).max(7)); /** GET /search/query (command palette) query params. */ const QueryParams = z.object({ q: z.string().trim().min(1).max(200), workspaceId: z.string().min(1).optional(), types: typesCsv.optional(), limit: z.coerce.number().int().min(1).max(50).optional(), cursor: z.string().min(1).optional(), }); const SuggestParams = z.object({ q: z.string().trim().min(1).max(200), limit: z.coerce.number().int().min(1).max(20).optional(), }); const RecentPutBody = z.object({ q: z.string().trim().min(1).max(200), }); /** * TASK-0127: search is a workspace-content surface — API keys may call it * (requireActor), scoped to their OWN workspace by `SearchService.resolveScope` * (the key's creator memberships are never consulted). Recent searches are a * PERSONAL convenience, so for a key the subject is the KEY (`api:{id}`) — * the creator's own recent list is never touched by key traffic. */ function recentSubject(req: Request): string { const auth = req.auth!; return auth.kind === "api" ? `api:${auth.apiKeyId}` : auth.userId!; } @Controller("search") export class SearchController { constructor(@Inject(SearchService) private readonly searches: SearchService) {} /** * Full-text search → envelope { data, nextCursor, tookMs }. * TASK-0100 S6: @HttpCode(200) — Nest's @Post() default of 201 is wrong * for a query: docs/api/search.md documents **200 OK**, the integration * test (search.integration.test.ts) asserts 200, and the k6 suite counts * non-200 as an error (the first 100k run reported errorRate 1 for this * reason). 201 "Created" implies a resource was created. */ @Post() @HttpCode(200) async search(@Body(ZodValidate(SearchBody)) body: SearchBodyInput, @Req() req: Request) { const userId = requireActor(req); const result = await this.searches.search(userId, body); // Auto-record into recent searches (dedupe is lrem+lpush, so it is safe to // double-record; best-effort — the search result must not fail on Redis). await this.searches.trackRecent(recentSubject(req), body.q).catch(() => undefined); return result; } /** Command-palette page search → BARE array of results. */ @Get("query") query(@Query(ZodValidate(QueryParams)) params: z.infer, @Req() req: Request) { const userId = requireActor(req); return this.searches.query(userId, params); } /** Prefix suggestions → BARE array of { id, type, title }. */ @Get("suggest") suggest(@Query(ZodValidate(SuggestParams)) params: z.infer, @Req() req: Request) { const userId = requireActor(req); return this.searches.suggest(userId, params.q, params.limit); } /** Recent searches (Redis list, newest first). */ @Get("recent") recent(@Req() req: Request) { requireActor(req); return this.searches.recent(recentSubject(req)); } /** Record a recent search (upsert; dedupe; cap 20; 30/min per user). */ @Put("recent") addRecent(@Body(ZodValidate(RecentPutBody)) body: z.infer, @Req() req: Request) { requireActor(req); return this.searches.addRecent(recentSubject(req), body.q); } /** Clear the caller's recent searches. */ @Delete("recent") clearRecent(@Req() req: Request) { requireActor(req); return this.searches.clearRecent(recentSubject(req)); } }