@hiai-gg/hiai-docs 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,226 @@
1
+ import { folders } from "@hiai-docs/db/schema";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ import { Elysia } from "elysia";
4
+ import { z } from "zod";
5
+ import { getSessionUserId } from "../../lib/auth-helpers";
6
+ import { db } from "../../lib/db";
7
+ import { logger } from "../../lib/logger";
8
+ import { writeRateLimiter } from "../middleware/rate-limit";
9
+
10
+ const createFolderSchema = z.object({
11
+ name: z.string().min(1).max(255),
12
+ // Accept string, null, or undefined so the frontend can explicitly send
13
+ // `parentId: null` for root-level folders (the previous `.optional()`
14
+ // rejected `null` with a 400).
15
+ parentId: z.string().uuid().nullish(),
16
+ });
17
+
18
+ const updateFolderSchema = z.object({
19
+ name: z.string().min(1).max(255).optional(),
20
+ parentId: z.string().uuid().nullable().optional(),
21
+ });
22
+
23
+ export const folderRoutes = new Elysia({ prefix: "/api/folders" })
24
+ .get("/:id", async ({ params, set, request }) => {
25
+ const userId = await getSessionUserId(request.headers);
26
+ if (!userId) {
27
+ set.status = 401;
28
+ return { error: "Unauthorized" };
29
+ }
30
+ try {
31
+ const [row] = await db
32
+ .select()
33
+ .from(folders)
34
+ .where(and(eq(folders.id, params.id), eq(folders.ownerId, userId)))
35
+ .limit(1);
36
+ if (!row) {
37
+ set.status = 404;
38
+ return { error: "Folder not found" };
39
+ }
40
+ return row;
41
+ } catch (err) {
42
+ logger.error({ err }, "Failed to get folder");
43
+ set.status = 500;
44
+ return { error: "Failed to get folder" };
45
+ }
46
+ })
47
+ .get("/", async ({ query, set, request }) => {
48
+ const userId = await getSessionUserId(request.headers);
49
+ if (!userId) {
50
+ set.status = 401;
51
+ return { error: "Unauthorized" };
52
+ }
53
+ try {
54
+ const conditions = [eq(folders.ownerId, userId)];
55
+ if (query.parentId) {
56
+ conditions.push(eq(folders.parentId, query.parentId));
57
+ } else {
58
+ conditions.push(isNull(folders.parentId));
59
+ }
60
+ const rows = await db
61
+ .select()
62
+ .from(folders)
63
+ .where(and(...conditions))
64
+ .orderBy(folders.name);
65
+ return rows;
66
+ } catch (err) {
67
+ logger.error({ err }, "Failed to list folders");
68
+ set.status = 500;
69
+ return { error: "Failed to list folders" };
70
+ }
71
+ })
72
+ .post("/", async ({ request, set }) => {
73
+ const ip =
74
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
75
+ request.headers.get("x-real-ip") ??
76
+ "unknown";
77
+ const rl = await writeRateLimiter(ip);
78
+ if (!rl.allowed) {
79
+ set.status = 429;
80
+ return { error: "Rate limited" };
81
+ }
82
+ const userId = await getSessionUserId(request.headers);
83
+ if (!userId) {
84
+ set.status = 401;
85
+ return { error: "Unauthorized" };
86
+ }
87
+ const parsed = createFolderSchema.safeParse(await request.json());
88
+ if (!parsed.success) {
89
+ set.status = 400;
90
+ return { error: "Invalid input", details: parsed.error.flatten() };
91
+ }
92
+ try {
93
+ if (parsed.data.parentId) {
94
+ const parent = await db
95
+ .select({ id: folders.id })
96
+ .from(folders)
97
+ .where(
98
+ and(
99
+ eq(folders.id, parsed.data.parentId),
100
+ eq(folders.ownerId, userId),
101
+ ),
102
+ )
103
+ .limit(1);
104
+ if (parent.length === 0) {
105
+ set.status = 404;
106
+ return { error: "Parent folder not found" };
107
+ }
108
+ }
109
+ const [created] = await db
110
+ .insert(folders)
111
+ .values({
112
+ ownerId: userId,
113
+ name: parsed.data.name,
114
+ parentId: parsed.data.parentId ?? null,
115
+ })
116
+ .returning();
117
+ set.status = 201;
118
+ return created;
119
+ } catch (err) {
120
+ logger.error({ err }, "Failed to create folder");
121
+ set.status = 500;
122
+ return { error: "Failed to create folder" };
123
+ }
124
+ })
125
+ .patch("/:id", async ({ params, request, set }) => {
126
+ const ip =
127
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
128
+ request.headers.get("x-real-ip") ??
129
+ "unknown";
130
+ const rl = await writeRateLimiter(ip);
131
+ if (!rl.allowed) {
132
+ set.status = 429;
133
+ return { error: "Rate limited" };
134
+ }
135
+ const userId = await getSessionUserId(request.headers);
136
+ if (!userId) {
137
+ set.status = 401;
138
+ return { error: "Unauthorized" };
139
+ }
140
+ const parsed = updateFolderSchema.safeParse(await request.json());
141
+ if (!parsed.success) {
142
+ set.status = 400;
143
+ return { error: "Invalid input", details: parsed.error.flatten() };
144
+ }
145
+ if (parsed.data.name === undefined && parsed.data.parentId === undefined) {
146
+ set.status = 400;
147
+ return { error: "At least one field (name or parentId) is required" };
148
+ }
149
+ try {
150
+ if (parsed.data.parentId) {
151
+ if (parsed.data.parentId === params.id) {
152
+ set.status = 400;
153
+ return { error: "Folder cannot be its own parent" };
154
+ }
155
+ const parent = await db
156
+ .select({ id: folders.id })
157
+ .from(folders)
158
+ .where(
159
+ and(
160
+ eq(folders.id, parsed.data.parentId),
161
+ eq(folders.ownerId, userId),
162
+ ),
163
+ )
164
+ .limit(1);
165
+ if (parent.length === 0) {
166
+ set.status = 404;
167
+ return { error: "Parent folder not found" };
168
+ }
169
+ }
170
+ const [updated] = await db
171
+ .update(folders)
172
+ .set({
173
+ ...(parsed.data.name !== undefined && { name: parsed.data.name }),
174
+ ...(parsed.data.parentId !== undefined && {
175
+ parentId: parsed.data.parentId,
176
+ }),
177
+ updatedAt: new Date(),
178
+ })
179
+ .where(and(eq(folders.id, params.id), eq(folders.ownerId, userId)))
180
+ .returning();
181
+ if (!updated) {
182
+ set.status = 404;
183
+ return { error: "Folder not found" };
184
+ }
185
+ return updated;
186
+ } catch (err) {
187
+ logger.error({ err }, "Failed to update folder");
188
+ set.status = 500;
189
+ return { error: "Failed to update folder" };
190
+ }
191
+ })
192
+ .delete("/:id", async ({ params, set, request }) => {
193
+ const ip =
194
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
195
+ request.headers.get("x-real-ip") ??
196
+ "unknown";
197
+ const rl = await writeRateLimiter(ip);
198
+ if (!rl.allowed) {
199
+ set.status = 429;
200
+ return { error: "Rate limited" };
201
+ }
202
+ const userId = await getSessionUserId(request.headers);
203
+ if (!userId) {
204
+ set.status = 401;
205
+ return { error: "Unauthorized" };
206
+ }
207
+ try {
208
+ const existing = await db
209
+ .select({ id: folders.id })
210
+ .from(folders)
211
+ .where(and(eq(folders.id, params.id), eq(folders.ownerId, userId)))
212
+ .limit(1);
213
+ if (existing.length === 0) {
214
+ set.status = 404;
215
+ return { error: "Folder not found" };
216
+ }
217
+ await db
218
+ .delete(folders)
219
+ .where(and(eq(folders.id, params.id), eq(folders.ownerId, userId)));
220
+ return { success: true };
221
+ } catch (err) {
222
+ logger.error({ err }, "Failed to delete folder");
223
+ set.status = 500;
224
+ return { error: "Failed to delete folder" };
225
+ }
226
+ });
@@ -0,0 +1,354 @@
1
+ import { documentTags, tags as tagsTable } from "@hiai-docs/db/schema";
2
+ import { and, eq, inArray, sql } from "drizzle-orm";
3
+ import { Elysia } from "elysia";
4
+ import { z } from "zod";
5
+ import { getEmbedding } from "../../embedding";
6
+ import { getSessionUserId } from "../../lib/auth-helpers";
7
+ import { db } from "../../lib/db";
8
+ import { logger } from "../../lib/logger";
9
+ import { rateLimitHeaders, searchRateLimiter } from "../middleware/rate-limit";
10
+
11
+ const searchQuerySchema = z.object({
12
+ q: z.string().optional(),
13
+ page: z.coerce.number().int().min(1).default(1),
14
+ limit: z.coerce.number().int().min(1).max(100).default(20),
15
+ sort: z
16
+ .enum(["relevance", "date_desc", "date_asc", "name_asc", "name_desc"])
17
+ .default("relevance"),
18
+ folder: z.string().optional(),
19
+ tags: z.string().optional(),
20
+ dateFrom: z.string().optional(),
21
+ dateTo: z.string().optional(),
22
+ });
23
+
24
+ const suggestQuerySchema = z.object({
25
+ q: z.string().optional(),
26
+ });
27
+
28
+ /**
29
+ * Hybrid search: combines full-text (tsvector) + semantic (pgvector cosine).
30
+ * Results are merged and deduplicated with weighted scoring.
31
+ */
32
+ export const searchRoutes = new Elysia({ prefix: "/api/search" })
33
+ .get("/", async ({ query, set, request }) => {
34
+ const ip =
35
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
36
+ request.headers.get("x-real-ip") ??
37
+ "unknown";
38
+ const rl = await searchRateLimiter(ip);
39
+ if (!rl.allowed) {
40
+ set.status = 429;
41
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
42
+ return { error: "Too many requests" };
43
+ }
44
+ set.headers = rateLimitHeaders(rl.remaining);
45
+
46
+ const userId = await getSessionUserId(request.headers);
47
+ if (!userId) {
48
+ set.status = 401;
49
+ return { error: "Unauthorized" };
50
+ }
51
+ const parsed = searchQuerySchema.safeParse(query);
52
+ if (!parsed.success) {
53
+ set.status = 400;
54
+ return { error: "Invalid query", details: parsed.error.flatten() };
55
+ }
56
+ try {
57
+ const {
58
+ q: rawQ,
59
+ page,
60
+ limit,
61
+ sort,
62
+ folder,
63
+ tags,
64
+ dateFrom,
65
+ dateTo,
66
+ } = parsed.data;
67
+ const q = rawQ ?? "";
68
+ const offset = (page - 1) * limit;
69
+
70
+ if (!q.trim()) return { items: [], total: 0, page, limit };
71
+
72
+ // Run full-text and semantic search in parallel
73
+ const [textResults, semanticResults] = await Promise.all([
74
+ fullTextSearch(userId, q, limit * 2),
75
+ semanticSearch(userId, q, limit * 2),
76
+ ]);
77
+
78
+ // Merge results with weighted scoring (0.4 text + 0.6 semantic)
79
+ type RawSearchResult = {
80
+ id: string;
81
+ title: string;
82
+ snippet: string;
83
+ score: number;
84
+ folder_id: string | null;
85
+ folder_name: string | null;
86
+ created_at: string;
87
+ updated_at: string;
88
+ };
89
+ type SearchResult = {
90
+ id: string;
91
+ title: string;
92
+ snippet: string;
93
+ score: number;
94
+ folder_id: string | null;
95
+ folder_name: string | null;
96
+ created_at: string;
97
+ updated_at: string;
98
+ tags?: Array<{ id: string; name: string; color: string | null }>;
99
+ };
100
+ const merged = new Map<string, SearchResult>();
101
+
102
+ function mapResult(row: RawSearchResult): SearchResult {
103
+ return {
104
+ id: row.id,
105
+ title: row.title,
106
+ snippet: row.snippet,
107
+ score: row.score,
108
+ folder_id: row.folder_id,
109
+ folder_name: row.folder_name,
110
+ created_at: row.created_at,
111
+ updated_at: row.updated_at,
112
+ };
113
+ }
114
+
115
+ for (const row of textResults as unknown as RawSearchResult[]) {
116
+ const mapped = mapResult(row);
117
+ mapped.score = row.score * 0.4;
118
+ merged.set(row.id, mapped);
119
+ }
120
+
121
+ for (const row of semanticResults as unknown as RawSearchResult[]) {
122
+ const existing = merged.get(row.id);
123
+ if (existing) {
124
+ existing.score += row.score * 0.6;
125
+ } else {
126
+ const mapped = mapResult(row);
127
+ mapped.score = row.score * 0.6;
128
+ merged.set(row.id, mapped);
129
+ }
130
+ }
131
+
132
+ // Apply filters (folder, date range, tags) before sort + pagination
133
+ let filtered = Array.from(merged.values());
134
+
135
+ if (folder) {
136
+ filtered = filtered.filter((r) => r.folder_id === folder);
137
+ }
138
+
139
+ if (dateFrom) {
140
+ const from = new Date(dateFrom);
141
+ if (!Number.isNaN(from.getTime())) {
142
+ filtered = filtered.filter((r) => new Date(r.created_at) >= from);
143
+ }
144
+ }
145
+
146
+ if (dateTo) {
147
+ const to = new Date(dateTo);
148
+ if (!Number.isNaN(to.getTime())) {
149
+ // Include the entire "to" day
150
+ to.setHours(23, 59, 59, 999);
151
+ filtered = filtered.filter((r) => new Date(r.created_at) <= to);
152
+ }
153
+ }
154
+
155
+ if (tags) {
156
+ const tagList = tags
157
+ .split(",")
158
+ .map((t) => t.trim())
159
+ .filter(Boolean);
160
+ if (tagList.length > 0) {
161
+ const allowedIds = await tagFilter(userId, tagList);
162
+ filtered = filtered.filter((r) => allowedIds.has(r.id));
163
+ }
164
+ }
165
+
166
+ // Sort by selected order, then paginate
167
+ switch (sort) {
168
+ case "date_desc":
169
+ filtered.sort(
170
+ (a, b) =>
171
+ new Date(b.created_at).getTime() -
172
+ new Date(a.created_at).getTime(),
173
+ );
174
+ break;
175
+ case "date_asc":
176
+ filtered.sort(
177
+ (a, b) =>
178
+ new Date(a.created_at).getTime() -
179
+ new Date(b.created_at).getTime(),
180
+ );
181
+ break;
182
+ case "name_asc":
183
+ filtered.sort((a, b) => a.title.localeCompare(b.title));
184
+ break;
185
+ case "name_desc":
186
+ filtered.sort((a, b) => b.title.localeCompare(a.title));
187
+ break;
188
+ default:
189
+ filtered.sort((a, b) => b.score - a.score);
190
+ break;
191
+ }
192
+ const total = filtered.length;
193
+ const items = filtered.slice(offset, offset + limit);
194
+
195
+ const itemsWithTags = await withTags(items);
196
+ return { items: itemsWithTags, total, page, limit };
197
+ } catch (err) {
198
+ logger.error({ err }, "Search failed");
199
+ set.status = 500;
200
+ return { error: "Search failed" };
201
+ }
202
+ })
203
+ .get("/suggest", async ({ query, set, request }) => {
204
+ const ip =
205
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
206
+ request.headers.get("x-real-ip") ??
207
+ "unknown";
208
+ const rl = await searchRateLimiter(ip);
209
+ if (!rl.allowed) {
210
+ set.status = 429;
211
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
212
+ return { error: "Too many requests" };
213
+ }
214
+ set.headers = rateLimitHeaders(rl.remaining);
215
+
216
+ const userId = await getSessionUserId(request.headers);
217
+ if (!userId) {
218
+ set.status = 401;
219
+ return { error: "Unauthorized" };
220
+ }
221
+ const parsed = suggestQuerySchema.safeParse(query);
222
+ if (!parsed.success) {
223
+ set.status = 400;
224
+ return { error: "Invalid query", details: parsed.error.flatten() };
225
+ }
226
+ try {
227
+ const q = parsed.data.q ?? "";
228
+ if (!q.trim()) return [];
229
+ const results = await db.execute(sql`
230
+ SELECT id, title, similarity(title, ${q}) as score
231
+ FROM documents
232
+ WHERE owner_id = ${userId} AND title % ${q}
233
+ ORDER BY score DESC LIMIT 5
234
+ `);
235
+ return results;
236
+ } catch (err) {
237
+ logger.error({ err }, "Suggest failed");
238
+ set.status = 500;
239
+ return { error: "Suggest failed" };
240
+ }
241
+ });
242
+
243
+ /**
244
+ * Full-text search using PostgreSQL tsvector + ts_rank.
245
+ */
246
+ async function fullTextSearch(userId: string, q: string, limit: number) {
247
+ const tsQuery = sql`plainto_tsquery('english', ${q})`;
248
+
249
+ return db.execute(sql`
250
+ SELECT d.id, d.title, LEFT(d.content, 200) as snippet,
251
+ ts_rank(d.search_vector, ${tsQuery}) as score,
252
+ d.folder_id, f.name as folder_name, d.created_at, d.updated_at
253
+ FROM documents d
254
+ LEFT JOIN folders f ON f.id = d.folder_id
255
+ WHERE d.owner_id = ${userId}
256
+ AND d.search_vector @@ ${tsQuery}
257
+ ORDER BY score DESC
258
+ LIMIT ${limit}
259
+ `);
260
+ }
261
+
262
+ /**
263
+ * Semantic search using pgvector cosine similarity.
264
+ * Queries the document_embeddings table against the query embedding.
265
+ */
266
+ async function semanticSearch(userId: string, q: string, limit: number) {
267
+ try {
268
+ const queryEmbedding = await getEmbedding(q);
269
+
270
+ // Skip if embedding is all zeros (provider failure)
271
+ if (queryEmbedding.every((v) => v === 0)) {
272
+ return [];
273
+ }
274
+
275
+ const embeddingStr = `[${queryEmbedding.join(",")}]`;
276
+
277
+ return db.execute(sql`
278
+ SELECT DISTINCT ON (d.id)
279
+ d.id, d.title, LEFT(d.content, 200) as snippet,
280
+ 1 - (de.embedding <=> ${embeddingStr}::vector) as score,
281
+ d.folder_id, f.name as folder_name, d.created_at, d.updated_at
282
+ FROM document_embeddings de
283
+ JOIN documents d ON d.id = de.document_id
284
+ LEFT JOIN folders f ON f.id = d.folder_id
285
+ WHERE d.owner_id = ${userId}
286
+ AND de.embedding IS NOT NULL
287
+ ORDER BY d.id, de.embedding <=> ${embeddingStr}::vector
288
+ LIMIT ${limit}
289
+ `);
290
+ } catch (err) {
291
+ logger.warn({ err }, "Semantic search failed, falling back to text-only");
292
+ return [];
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Return the set of document ids owned by `userId` that have at least one of
298
+ * the supplied tag names (ANY semantics — a doc qualifies if it carries any
299
+ * of the requested tags).
300
+ */
301
+ async function tagFilter(
302
+ userId: string,
303
+ tagNames: string[],
304
+ ): Promise<Set<string>> {
305
+ if (tagNames.length === 0) return new Set();
306
+
307
+ // Look up tag ids by name (parameterised — safe against injection).
308
+ const tagRows = await db
309
+ .select({ id: tagsTable.id })
310
+ .from(tagsTable)
311
+ .where(
312
+ and(eq(tagsTable.ownerId, userId), inArray(tagsTable.name, tagNames)),
313
+ );
314
+ if (tagRows.length === 0) return new Set();
315
+
316
+ const tagIds = tagRows.map((r) => r.id);
317
+
318
+ const docRows = await db
319
+ .selectDistinct({ documentId: documentTags.documentId })
320
+ .from(documentTags)
321
+ .where(inArray(documentTags.tagId, tagIds));
322
+
323
+ return new Set(docRows.map((r) => r.documentId));
324
+ }
325
+
326
+ async function withTags<T extends { id: string }>(
327
+ rows: T[],
328
+ ): Promise<
329
+ Array<T & { tags: Array<{ id: string; name: string; color: string | null }> }>
330
+ > {
331
+ if (rows.length === 0) return [];
332
+ const ids = rows.map((r) => r.id);
333
+ const tagRows = await db
334
+ .select({
335
+ documentId: documentTags.documentId,
336
+ id: tagsTable.id,
337
+ name: tagsTable.name,
338
+ color: tagsTable.color,
339
+ })
340
+ .from(documentTags)
341
+ .innerJoin(tagsTable, eq(tagsTable.id, documentTags.tagId))
342
+ .where(inArray(documentTags.documentId, ids));
343
+
344
+ const byDoc = new Map<
345
+ string,
346
+ Array<{ id: string; name: string; color: string | null }>
347
+ >();
348
+ for (const t of tagRows) {
349
+ const list = byDoc.get(t.documentId) ?? [];
350
+ list.push({ id: t.id, name: t.name, color: t.color });
351
+ byDoc.set(t.documentId, list);
352
+ }
353
+ return rows.map((r) => ({ ...r, tags: byDoc.get(r.id) ?? [] }));
354
+ }