@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.
- package/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- 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
|
+
}
|