@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,664 @@
1
+ import {
2
+ documents,
3
+ documentTags,
4
+ folders,
5
+ tags,
6
+ versions,
7
+ } from "@hiai-docs/db/schema";
8
+ import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
9
+ import { Elysia } from "elysia";
10
+ import { z } from "zod";
11
+ import { getSessionUserId } from "../../lib/auth-helpers";
12
+ import { db } from "../../lib/db";
13
+ import { enqueueEmbedding } from "../../lib/embedding-queue";
14
+ import { logger } from "../../lib/logger";
15
+ import { markdownToDocJson } from "../../lib/markdown-to-doc";
16
+ import {
17
+ documentRateLimiter,
18
+ rateLimitHeaders,
19
+ writeRateLimiter,
20
+ } from "../middleware/rate-limit";
21
+
22
+ const createDocumentSchema = z.object({
23
+ title: z.string().min(1).max(500).default("Untitled"),
24
+ content: z.string().optional(),
25
+ folderId: z.string().uuid().optional(),
26
+ });
27
+
28
+ const updateDocumentSchema = z.object({
29
+ title: z.string().min(1).max(500).optional(),
30
+ content: z.string().optional(),
31
+ contentJson: z.unknown().optional(),
32
+ metadata: z.unknown().optional(),
33
+ folderId: z.string().uuid().nullable().optional(),
34
+ });
35
+
36
+ const listQuerySchema = z.object({
37
+ folderId: z.string().uuid().optional(),
38
+ tag: z.string().uuid().optional(),
39
+ page: z.coerce.number().int().min(1).default(1),
40
+ limit: z.coerce.number().int().min(1).max(100).default(20),
41
+ });
42
+
43
+ const ALLOWED_IMPORT_EXTENSIONS = [".md", ".txt", ".markdown", ".json"];
44
+ const MAX_IMPORT_SIZE = 10 * 1024 * 1024;
45
+
46
+ const importJsonSchema = z.object({
47
+ title: z.string().min(1).max(500).optional(),
48
+ content: z.string().min(1).max(5_000_000),
49
+ folderId: z.string().uuid().optional(),
50
+ });
51
+
52
+ /**
53
+ * Attach a `tags` array (`{ id, name, color }`) to each document row in a
54
+ * list response. Runs a single grouped query for all rows so the list
55
+ * endpoint can show tags without an N+1 round trip.
56
+ */
57
+ async function withTags<T extends { id: string }>(
58
+ rows: T[],
59
+ ): Promise<
60
+ Array<T & { tags: Array<{ id: string; name: string; color: string | null }> }>
61
+ > {
62
+ if (rows.length === 0) return [];
63
+ const ids = rows.map((r) => r.id);
64
+ const tagRows = await db
65
+ .select({
66
+ documentId: documentTags.documentId,
67
+ id: tags.id,
68
+ name: tags.name,
69
+ color: tags.color,
70
+ })
71
+ .from(documentTags)
72
+ .innerJoin(tags, eq(tags.id, documentTags.tagId))
73
+ .where(inArray(documentTags.documentId, ids));
74
+
75
+ const byDoc = new Map<
76
+ string,
77
+ Array<{ id: string; name: string; color: string | null }>
78
+ >();
79
+ for (const t of tagRows) {
80
+ const list = byDoc.get(t.documentId) ?? [];
81
+ list.push({ id: t.id, name: t.name, color: t.color });
82
+ byDoc.set(t.documentId, list);
83
+ }
84
+ return rows.map((r) => ({ ...r, tags: byDoc.get(r.id) ?? [] }));
85
+ }
86
+
87
+ export const documentRoutes = new Elysia({ prefix: "/api" })
88
+ // GET /api/documents — List documents with pagination
89
+ .get("/documents", async ({ query, set, request }) => {
90
+ const ip =
91
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
92
+ request.headers.get("x-real-ip") ??
93
+ "unknown";
94
+ const rl = await documentRateLimiter(ip);
95
+ if (!rl.allowed) {
96
+ set.status = 429;
97
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
98
+ return { error: "Too many requests" };
99
+ }
100
+ set.headers = rateLimitHeaders(rl.remaining);
101
+
102
+ const userId = await getSessionUserId(request.headers);
103
+ if (!userId) {
104
+ set.status = 401;
105
+ return { error: "Unauthorized" };
106
+ }
107
+ const parsed = listQuerySchema.safeParse(query);
108
+ if (!parsed.success) {
109
+ set.status = 400;
110
+ return { error: "Invalid query", details: parsed.error.flatten() };
111
+ }
112
+ const { folderId, tag, page, limit } = parsed.data;
113
+ const offset = (page - 1) * limit;
114
+ try {
115
+ const conditions = [eq(documents.ownerId, userId)];
116
+ if (folderId) conditions.push(eq(documents.folderId, folderId));
117
+
118
+ if (tag) {
119
+ const [countResult, rows] = await Promise.all([
120
+ db
121
+ .select({ total: count() })
122
+ .from(documents)
123
+ .innerJoin(documentTags, eq(documents.id, documentTags.documentId))
124
+ .where(and(eq(documentTags.tagId, tag), ...conditions)),
125
+ db
126
+ .select({
127
+ id: documents.id,
128
+ title: documents.title,
129
+ content: sql<string>`LEFT(${documents.content}, 200)`.as(
130
+ "content",
131
+ ),
132
+ folderId: documents.folderId,
133
+ createdAt: documents.createdAt,
134
+ updatedAt: documents.updatedAt,
135
+ })
136
+ .from(documents)
137
+ .innerJoin(documentTags, eq(documents.id, documentTags.documentId))
138
+ .where(and(eq(documentTags.tagId, tag), ...conditions))
139
+ .orderBy(desc(documents.updatedAt))
140
+ .limit(limit)
141
+ .offset(offset),
142
+ ]);
143
+ return {
144
+ items: await withTags(rows),
145
+ total: countResult[0]?.total ?? 0,
146
+ page,
147
+ limit,
148
+ };
149
+ }
150
+
151
+ const [countResult, rows] = await Promise.all([
152
+ db
153
+ .select({ total: count() })
154
+ .from(documents)
155
+ .where(and(...conditions)),
156
+ db
157
+ .select({
158
+ id: documents.id,
159
+ title: documents.title,
160
+ content: sql<string>`LEFT(${documents.content}, 200)`.as("content"),
161
+ folderId: documents.folderId,
162
+ createdAt: documents.createdAt,
163
+ updatedAt: documents.updatedAt,
164
+ })
165
+ .from(documents)
166
+ .where(and(...conditions))
167
+ .orderBy(desc(documents.updatedAt))
168
+ .limit(limit)
169
+ .offset(offset),
170
+ ]);
171
+ return {
172
+ items: await withTags(rows),
173
+ total: countResult[0]?.total ?? 0,
174
+ page,
175
+ limit,
176
+ };
177
+ } catch (err) {
178
+ logger.error({ err }, "Failed to list documents");
179
+ set.status = 500;
180
+ return { error: "Failed to list documents" };
181
+ }
182
+ })
183
+
184
+ // POST /api/documents — Create document + initial version
185
+ .post("/documents", async ({ request, set }) => {
186
+ const ip =
187
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
188
+ request.headers.get("x-real-ip") ??
189
+ "unknown";
190
+ const rl = await writeRateLimiter(ip);
191
+ if (!rl.allowed) {
192
+ set.status = 429;
193
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
194
+ return { error: "Too many requests" };
195
+ }
196
+ set.headers = rateLimitHeaders(rl.remaining);
197
+
198
+ const userId = await getSessionUserId(request.headers);
199
+ if (!userId) {
200
+ set.status = 401;
201
+ return { error: "Unauthorized" };
202
+ }
203
+ const body = createDocumentSchema.safeParse(await request.json());
204
+ if (!body.success) {
205
+ set.status = 400;
206
+ return { error: "Invalid input", details: body.error.flatten() };
207
+ }
208
+ try {
209
+ // If the client posts raw markdown without the TipTap JSON view
210
+ // (e.g. an import, a script, or any path that bypasses the
211
+ // editor), generate the JSON server-side so the editor opens
212
+ // with formatted content rather than the raw markdown source
213
+ // the next time the document is opened. `markdownToDocJson`
214
+ // returns `null` for empty input or on parse failure, in which
215
+ // case we simply persist the markdown without a JSON view — the
216
+ // frontend's `markdownToJson` helper will recover on load.
217
+ const initialContent = body.data.content ?? "";
218
+ const initialDocJson = initialContent
219
+ ? await markdownToDocJson(initialContent)
220
+ : null;
221
+ const [created] = await db
222
+ .insert(documents)
223
+ .values({
224
+ ownerId: userId,
225
+ title: body.data.title,
226
+ content: initialContent,
227
+ contentJson: initialDocJson,
228
+ folderId: body.data.folderId ?? null,
229
+ })
230
+ .returning();
231
+ if (!created) {
232
+ set.status = 500;
233
+ return { error: "Failed to create document" };
234
+ }
235
+
236
+ await db.insert(versions).values({
237
+ documentId: created.id,
238
+ content: initialContent,
239
+ contentJson: initialDocJson,
240
+ createdBy: userId,
241
+ });
242
+
243
+ enqueueEmbedding(created.id);
244
+ set.status = 201;
245
+ return created;
246
+ } catch (err) {
247
+ logger.error({ err }, "Failed to create document");
248
+ set.status = 500;
249
+ return { error: "Failed to create document" };
250
+ }
251
+ })
252
+
253
+ // GET /api/documents/:id — Get document with tags
254
+ .get("/documents/:id", async ({ params, set, request }) => {
255
+ const ip =
256
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
257
+ request.headers.get("x-real-ip") ??
258
+ "unknown";
259
+ const rl = await documentRateLimiter(ip);
260
+ if (!rl.allowed) {
261
+ set.status = 429;
262
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
263
+ return { error: "Too many requests" };
264
+ }
265
+ set.headers = rateLimitHeaders(rl.remaining);
266
+
267
+ const userId = await getSessionUserId(request.headers);
268
+ if (!userId) {
269
+ set.status = 401;
270
+ return { error: "Unauthorized" };
271
+ }
272
+ try {
273
+ const rows = await db
274
+ .select({
275
+ id: documents.id,
276
+ ownerId: documents.ownerId,
277
+ folderId: documents.folderId,
278
+ folderName: folders.name,
279
+ title: documents.title,
280
+ content: documents.content,
281
+ contentJson: documents.contentJson,
282
+ metadata: documents.metadata,
283
+ createdAt: documents.createdAt,
284
+ updatedAt: documents.updatedAt,
285
+ })
286
+ .from(documents)
287
+ .leftJoin(folders, eq(folders.id, documents.folderId))
288
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
289
+ .limit(1);
290
+
291
+ const doc = rows[0];
292
+ if (!doc) {
293
+ set.status = 404;
294
+ return { error: "Document not found" };
295
+ }
296
+
297
+ const docTags = await db
298
+ .select({ id: tags.id, name: tags.name, color: tags.color })
299
+ .from(tags)
300
+ .innerJoin(documentTags, eq(tags.id, documentTags.tagId))
301
+ .where(eq(documentTags.documentId, doc.id));
302
+
303
+ return { ...doc, tags: docTags };
304
+ } catch (err) {
305
+ logger.error({ err }, "Failed to get document");
306
+ set.status = 500;
307
+ return { error: "Failed to get document" };
308
+ }
309
+ })
310
+
311
+ // PATCH /api/documents/:id — Update document, save version before
312
+ .patch("/documents/:id", async ({ params, request, set }) => {
313
+ const ip =
314
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
315
+ request.headers.get("x-real-ip") ??
316
+ "unknown";
317
+ const rl = await writeRateLimiter(ip);
318
+ if (!rl.allowed) {
319
+ set.status = 429;
320
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
321
+ return { error: "Too many requests" };
322
+ }
323
+ set.headers = rateLimitHeaders(rl.remaining);
324
+
325
+ const userId = await getSessionUserId(request.headers);
326
+ if (!userId) {
327
+ set.status = 401;
328
+ return { error: "Unauthorized" };
329
+ }
330
+ const body = updateDocumentSchema.safeParse(await request.json());
331
+ if (!body.success) {
332
+ set.status = 400;
333
+ return { error: "Invalid input", details: body.error.flatten() };
334
+ }
335
+ if (
336
+ !body.data.title &&
337
+ body.data.content === undefined &&
338
+ body.data.contentJson === undefined &&
339
+ body.data.metadata === undefined &&
340
+ body.data.folderId === undefined
341
+ ) {
342
+ set.status = 400;
343
+ return { error: "At least one field is required" };
344
+ }
345
+ try {
346
+ const existing = await db
347
+ .select({
348
+ id: documents.id,
349
+ content: documents.content,
350
+ contentJson: documents.contentJson,
351
+ })
352
+ .from(documents)
353
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
354
+ .limit(1);
355
+ if (existing.length === 0) {
356
+ set.status = 404;
357
+ return { error: "Document not found" };
358
+ }
359
+
360
+ await db.insert(versions).values({
361
+ documentId: params.id,
362
+ content: existing[0]?.content ?? "",
363
+ contentJson: existing[0]?.contentJson,
364
+ createdBy: userId,
365
+ });
366
+
367
+ // When the client sends new `content` but no `contentJson`,
368
+ // generate the JSON view server-side so the editor can render
369
+ // formatted content on the next open. When the client sends
370
+ // both fields (the editor's normal save path), prefer the
371
+ // client-supplied JSON — it reflects the user's live edits.
372
+ let resolvedDocJson: unknown | undefined = body.data.contentJson;
373
+ if (resolvedDocJson === undefined && body.data.content !== undefined) {
374
+ resolvedDocJson = body.data.content
375
+ ? await markdownToDocJson(body.data.content)
376
+ : null;
377
+ }
378
+
379
+ const [updated] = await db
380
+ .update(documents)
381
+ .set({
382
+ ...(body.data.title !== undefined && { title: body.data.title }),
383
+ ...(body.data.content !== undefined && {
384
+ content: body.data.content,
385
+ }),
386
+ ...(resolvedDocJson !== undefined && {
387
+ contentJson: resolvedDocJson,
388
+ }),
389
+ ...(body.data.metadata !== undefined && {
390
+ metadata: body.data.metadata,
391
+ }),
392
+ ...(body.data.folderId !== undefined && {
393
+ folderId: body.data.folderId,
394
+ }),
395
+ updatedAt: new Date(),
396
+ })
397
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
398
+ .returning();
399
+
400
+ if (body.data.content !== undefined || body.data.title !== undefined) {
401
+ enqueueEmbedding(params.id);
402
+ }
403
+ return updated;
404
+ } catch (err) {
405
+ logger.error({ err }, "Failed to update document");
406
+ set.status = 500;
407
+ return { error: "Failed to update document" };
408
+ }
409
+ })
410
+
411
+ // POST /api/documents/:id/duplicate — Duplicate document with "(Copy)" suffix
412
+ .post("/documents/:id/duplicate", async ({ params, request, set }) => {
413
+ const ip =
414
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
415
+ request.headers.get("x-real-ip") ??
416
+ "unknown";
417
+ const rl = await writeRateLimiter(ip);
418
+ if (!rl.allowed) {
419
+ set.status = 429;
420
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
421
+ return { error: "Too many requests" };
422
+ }
423
+ set.headers = rateLimitHeaders(rl.remaining);
424
+
425
+ const userId = await getSessionUserId(request.headers);
426
+ if (!userId) {
427
+ set.status = 401;
428
+ return { error: "Unauthorized" };
429
+ }
430
+ try {
431
+ const [source] = await db
432
+ .select()
433
+ .from(documents)
434
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
435
+ .limit(1);
436
+ if (!source) {
437
+ set.status = 404;
438
+ return { error: "Document not found" };
439
+ }
440
+
441
+ const [copy] = await db
442
+ .insert(documents)
443
+ .values({
444
+ ownerId: userId,
445
+ folderId: source.folderId,
446
+ title: `${source.title} (Copy)`,
447
+ content: source.content ?? "",
448
+ contentJson: source.contentJson,
449
+ metadata: source.metadata,
450
+ })
451
+ .returning();
452
+ if (!copy) {
453
+ set.status = 500;
454
+ return { error: "Failed to duplicate document" };
455
+ }
456
+
457
+ await db.insert(versions).values({
458
+ documentId: copy.id,
459
+ content: copy.content ?? "",
460
+ contentJson: copy.contentJson,
461
+ createdBy: userId,
462
+ });
463
+
464
+ enqueueEmbedding(copy.id);
465
+ set.status = 201;
466
+ return copy;
467
+ } catch (err) {
468
+ logger.error({ err }, "Failed to duplicate document");
469
+ set.status = 500;
470
+ return { error: "Failed to duplicate document" };
471
+ }
472
+ })
473
+
474
+ // DELETE /api/documents/:id — Delete document (cascade via FK)
475
+ .delete("/documents/:id", async ({ params, set, request }) => {
476
+ const ip =
477
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
478
+ request.headers.get("x-real-ip") ??
479
+ "unknown";
480
+ const rl = await writeRateLimiter(ip);
481
+ if (!rl.allowed) {
482
+ set.status = 429;
483
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
484
+ return { error: "Too many requests" };
485
+ }
486
+ set.headers = rateLimitHeaders(rl.remaining);
487
+
488
+ const userId = await getSessionUserId(request.headers);
489
+ if (!userId) {
490
+ set.status = 401;
491
+ return { error: "Unauthorized" };
492
+ }
493
+ try {
494
+ const existing = await db
495
+ .select({ id: documents.id })
496
+ .from(documents)
497
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
498
+ .limit(1);
499
+ if (existing.length === 0) {
500
+ set.status = 404;
501
+ return { error: "Document not found" };
502
+ }
503
+ await db
504
+ .delete(documents)
505
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)));
506
+ return { success: true };
507
+ } catch (err) {
508
+ logger.error({ err }, "Failed to delete document");
509
+ set.status = 500;
510
+ return { error: "Failed to delete document" };
511
+ }
512
+ })
513
+
514
+ .get("/documents/:id/export", async ({ params, set, request }) => {
515
+ const userId = await getSessionUserId(request.headers);
516
+ if (!userId) {
517
+ set.status = 401;
518
+ return { error: "Unauthorized" };
519
+ }
520
+ try {
521
+ const rows = await db
522
+ .select({
523
+ id: documents.id,
524
+ title: documents.title,
525
+ content: documents.content,
526
+ })
527
+ .from(documents)
528
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
529
+ .limit(1);
530
+ const doc = rows[0];
531
+ if (!doc) {
532
+ set.status = 404;
533
+ return { error: "Document not found" };
534
+ }
535
+ const filename = `${doc.title.replace(/[^a-zA-Z0-9-_]/g, "_")}.md`;
536
+ set.headers = {
537
+ "Content-Type": "text/markdown; charset=utf-8",
538
+ "Content-Disposition": `attachment; filename="${filename}"`,
539
+ };
540
+ return doc.content ?? "";
541
+ } catch (err) {
542
+ logger.error({ err }, "Failed to export document");
543
+ set.status = 500;
544
+ return { error: "Failed to export document" };
545
+ }
546
+ })
547
+
548
+ .post("/documents/import", async ({ request, set }) => {
549
+ const ip =
550
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
551
+ request.headers.get("x-real-ip") ??
552
+ "unknown";
553
+ const rl = await writeRateLimiter(ip);
554
+ if (!rl.allowed) {
555
+ set.status = 429;
556
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
557
+ return { error: "Too many requests" };
558
+ }
559
+ set.headers = rateLimitHeaders(rl.remaining);
560
+
561
+ const userId = await getSessionUserId(request.headers);
562
+ if (!userId) {
563
+ set.status = 401;
564
+ return { error: "Unauthorized" };
565
+ }
566
+ try {
567
+ const contentType = request.headers.get("content-type") ?? "";
568
+ let title: string;
569
+ let content: string;
570
+ let folderId: string | null = null;
571
+
572
+ if (contentType.includes("application/json")) {
573
+ const body = await request.json();
574
+ const parsed = importJsonSchema.safeParse(body);
575
+ if (!parsed.success) {
576
+ set.status = 400;
577
+ return {
578
+ error: "Invalid import data",
579
+ details: parsed.error.flatten(),
580
+ };
581
+ }
582
+ title = parsed.data.title ?? "Imported Document";
583
+ content = parsed.data.content;
584
+ folderId = parsed.data.folderId ?? null;
585
+ } else if (contentType.includes("multipart/form-data")) {
586
+ const formData = await request.formData();
587
+ const file = formData.get("file") as File | null;
588
+ folderId = (formData.get("folderId") as string) ?? null;
589
+ if (!file) {
590
+ set.status = 400;
591
+ return { error: "File is required" };
592
+ }
593
+ if (file.size > MAX_IMPORT_SIZE) {
594
+ set.status = 413;
595
+ return {
596
+ error: `File too large. Maximum size: ${MAX_IMPORT_SIZE / 1024 / 1024}MB`,
597
+ };
598
+ }
599
+ const ext = `.${file.name.split(".").pop()?.toLowerCase()}`;
600
+ if (!ALLOWED_IMPORT_EXTENSIONS.includes(ext)) {
601
+ set.status = 415;
602
+ return {
603
+ error: `Invalid file type. Allowed: ${ALLOWED_IMPORT_EXTENSIONS.join(", ")}`,
604
+ };
605
+ }
606
+ if (folderId && !z.string().uuid().safeParse(folderId).success) {
607
+ set.status = 400;
608
+ return { error: "Invalid folderId" };
609
+ }
610
+ const text = await file.text();
611
+ const name = file.name;
612
+ if (name.endsWith(".json")) {
613
+ const jsonBody = JSON.parse(text);
614
+ const jsonParsed = importJsonSchema.safeParse(jsonBody);
615
+ if (!jsonParsed.success) {
616
+ set.status = 400;
617
+ return {
618
+ error: "Invalid JSON format",
619
+ details: jsonParsed.error.flatten(),
620
+ };
621
+ }
622
+ title = jsonParsed.data.title ?? name.replace(/\.json$/, "");
623
+ content = jsonParsed.data.content;
624
+ } else {
625
+ title = name.replace(/\.(md|txt|markdown)$/, "");
626
+ content = text;
627
+ }
628
+ } else {
629
+ set.status = 415;
630
+ return {
631
+ error:
632
+ "Unsupported content type. Use application/json or multipart/form-data",
633
+ };
634
+ }
635
+
636
+ const [created] = await db
637
+ .insert(documents)
638
+ .values({
639
+ ownerId: userId,
640
+ title,
641
+ content,
642
+ folderId: folderId ?? null,
643
+ })
644
+ .returning();
645
+ if (!created) {
646
+ set.status = 500;
647
+ return { error: "Failed to import document" };
648
+ }
649
+
650
+ await db.insert(versions).values({
651
+ documentId: created.id,
652
+ content,
653
+ createdBy: userId,
654
+ });
655
+
656
+ enqueueEmbedding(created.id);
657
+ set.status = 201;
658
+ return created;
659
+ } catch (err) {
660
+ logger.error({ err }, "Failed to import document");
661
+ set.status = 500;
662
+ return { error: "Failed to import document" };
663
+ }
664
+ });