1import { Inject, Body, Controller, Delete, Get, HttpCode, Post, Put, Query, Req } from "@nestjs/common";
2import type { Request } from "express";
3import { z } from "zod";
4import { SearchService } from "./search.service.js";
5import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js";
6import { requireActor, requireUser } from "../../common/types.js";
7
8
27
28const SEARCH_TYPES = ["page", "block", "database", "record", "comment", "file", "user"] as const;
29
30const isoDate = z.string().refine((s) => Number.isFinite(Date.parse(s)), { message: "invalid ISO-8601 date" });
31
32
33const SearchBody = z.object({
34 q: z.string().trim().min(1).max(200),
35 types: z.array(z.enum(SEARCH_TYPES)).max(7).optional(),
36 workspaceId: z.string().min(1).optional(),
37 teamId: z.string().min(1).optional(),
38 createdBy: z.string().min(1).optional(),
39 updatedAfter: isoDate.optional(),
40 updatedBefore: isoDate.optional(),
41
42
43 sortBy: z.enum(["relevance", "createdAt", "updatedAt"]).optional(),
44 cursor: z.string().min(1).optional(),
45
46 limit: z.number().int().min(1).max(50).optional(),
47});
48type SearchBodyInput = z.infer<typeof SearchBody>;
49
50
56const typesCsv = z
57 .string()
58 .transform((s) => s.split(",").map((t) => t.trim()).filter((t) => t.length > 0))
59 .pipe(z.array(z.enum(SEARCH_TYPES)).max(7));
60
61
62const QueryParams = z.object({
63 q: z.string().trim().min(1).max(200),
64 workspaceId: z.string().min(1).optional(),
65 types: typesCsv.optional(),
66 limit: z.coerce.number().int().min(1).max(50).optional(),
67 cursor: z.string().min(1).optional(),
68});
69
70const SuggestParams = z.object({
71 q: z.string().trim().min(1).max(200),
72 limit: z.coerce.number().int().min(1).max(20).optional(),
73});
74
75const RecentPutBody = z.object({
76 q: z.string().trim().min(1).max(200),
77});
78
79
86function recentSubject(req: Request): string {
87 const auth = req.auth!;
88 return auth.kind === "api" ? `api:${auth.apiKeyId}` : auth.userId!;
89}
90
91@Controller("search")
92export class SearchController {
93 constructor(@Inject(SearchService) private readonly searches: SearchService) {}
94
95
103 @Post()
104 @HttpCode(200)
105 async search(@Body(ZodValidate(SearchBody)) body: SearchBodyInput, @Req() req: Request) {
106 const userId = requireActor(req);
107 const result = await this.searches.search(userId, body);
108
109
110 await this.searches.trackRecent(recentSubject(req), body.q).catch(() => undefined);
111 return result;
112 }
113
114
115 @Get("query")
116 query(@Query(ZodValidate(QueryParams)) params: z.infer<typeof QueryParams>, @Req() req: Request) {
117 const userId = requireActor(req);
118 return this.searches.query(userId, params);
119 }
120
121
122 @Get("suggest")
123 suggest(@Query(ZodValidate(SuggestParams)) params: z.infer<typeof SuggestParams>, @Req() req: Request) {
124 const userId = requireActor(req);
125 return this.searches.suggest(userId, params.q, params.limit);
126 }
127
128
129 @Get("recent")
130 recent(@Req() req: Request) {
131 requireActor(req);
132 return this.searches.recent(recentSubject(req));
133 }
134
135
136 @Put("recent")
137 addRecent(@Body(ZodValidate(RecentPutBody)) body: z.infer<typeof RecentPutBody>, @Req() req: Request) {
138 requireActor(req);
139 return this.searches.addRecent(recentSubject(req), body.q);
140 }
141
142
143 @Delete("recent")
144 clearRecent(@Req() req: Request) {
145 requireActor(req);
146 return this.searches.clearRecent(recentSubject(req));
147 }
148}
149