@goscribe/server 1.3.4 → 1.6.0

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 (383) hide show
  1. package/.env.example +12 -0
  2. package/.vscode/settings.json +3 -0
  3. package/REFACTOR_NOTES.md +60 -0
  4. package/TESTING_PROMPT.md +225 -0
  5. package/dist/context.d.ts +14 -1
  6. package/dist/context.js +23 -2
  7. package/dist/controllers/admin.controller.d.ts +715 -0
  8. package/dist/controllers/admin.controller.js +9 -0
  9. package/dist/controllers/annotations.controller.d.ts +439 -0
  10. package/dist/controllers/annotations.controller.js +9 -0
  11. package/dist/controllers/app-router.controller.d.ts +3011 -0
  12. package/dist/controllers/app-router.controller.js +38 -0
  13. package/dist/controllers/app-router.controller.test.d.ts +1 -0
  14. package/dist/controllers/app-router.controller.test.js +36 -0
  15. package/dist/controllers/auth.controller.d.ts +323 -0
  16. package/dist/controllers/auth.controller.js +9 -0
  17. package/dist/controllers/base.controller.d.ts +4 -0
  18. package/dist/controllers/base.controller.js +5 -0
  19. package/dist/controllers/chat.controller.d.ts +341 -0
  20. package/dist/controllers/chat.controller.js +9 -0
  21. package/dist/controllers/copilot.controller.d.ts +397 -0
  22. package/dist/controllers/copilot.controller.js +9 -0
  23. package/dist/controllers/flashcards.controller.d.ts +651 -0
  24. package/dist/controllers/flashcards.controller.js +9 -0
  25. package/dist/controllers/members.controller.d.ts +339 -0
  26. package/dist/controllers/members.controller.js +9 -0
  27. package/dist/controllers/notifications.controller.d.ts +199 -0
  28. package/dist/controllers/notifications.controller.js +9 -0
  29. package/dist/controllers/payment.controller.d.ts +181 -0
  30. package/dist/controllers/payment.controller.js +9 -0
  31. package/dist/controllers/podcast.controller.d.ts +575 -0
  32. package/dist/controllers/podcast.controller.js +9 -0
  33. package/dist/controllers/router-module.controller.d.ts +5 -0
  34. package/dist/controllers/router-module.controller.js +6 -0
  35. package/dist/controllers/studyguide.controller.d.ts +73 -0
  36. package/dist/controllers/studyguide.controller.js +9 -0
  37. package/dist/controllers/worksheets.controller.d.ts +829 -0
  38. package/dist/controllers/worksheets.controller.js +9 -0
  39. package/dist/controllers/workspace.controller.d.ts +1207 -0
  40. package/dist/controllers/workspace.controller.js +9 -0
  41. package/dist/lib/activity_human_description.test.js +16 -15
  42. package/dist/lib/activity_log_service.test.js +28 -23
  43. package/dist/lib/ai/config.d.ts +20 -0
  44. package/dist/lib/ai/config.js +31 -0
  45. package/dist/lib/ai/embedding-client.d.ts +8 -0
  46. package/dist/lib/ai/embedding-client.js +30 -0
  47. package/dist/lib/ai/index.d.ts +48 -0
  48. package/dist/lib/ai/index.js +29 -0
  49. package/dist/lib/ai/inference-backend/client.d.ts +28 -0
  50. package/dist/lib/ai/inference-backend/client.js +301 -0
  51. package/dist/lib/ai/inference-backend/mocks.d.ts +12 -0
  52. package/dist/lib/ai/inference-backend/mocks.js +133 -0
  53. package/dist/lib/ai/inference-backend/types.d.ts +44 -0
  54. package/dist/lib/ai/inference-backend/types.js +1 -0
  55. package/dist/lib/ai/json-parse.d.ts +2 -0
  56. package/dist/lib/ai/json-parse.js +34 -0
  57. package/dist/lib/ai/llm-client.d.ts +7 -0
  58. package/dist/lib/ai/llm-client.js +36 -0
  59. package/dist/lib/ai/mock.d.ts +2 -0
  60. package/dist/lib/ai/mock.js +10 -0
  61. package/dist/lib/ai/types.d.ts +9 -0
  62. package/dist/lib/ai/types.js +1 -0
  63. package/dist/lib/chunking.d.ts +19 -0
  64. package/dist/lib/chunking.js +47 -0
  65. package/dist/lib/curated-kb-seed.d.ts +12 -0
  66. package/dist/lib/curated-kb-seed.js +155 -0
  67. package/dist/lib/email.js +67 -108
  68. package/dist/lib/embeddings.d.ts +2 -0
  69. package/dist/lib/embeddings.js +1 -0
  70. package/dist/lib/ensure-curated-kb-catalog.d.ts +6 -0
  71. package/dist/lib/ensure-curated-kb-catalog.js +53 -0
  72. package/dist/lib/env.d.ts +1 -5
  73. package/dist/lib/env.js +2 -7
  74. package/dist/lib/inference.d.ts +1 -8
  75. package/dist/lib/inference.js +1 -19
  76. package/dist/lib/kb-meta.d.ts +8 -0
  77. package/dist/lib/kb-meta.js +77 -0
  78. package/dist/lib/note-text.d.ts +1 -0
  79. package/dist/lib/note-text.js +47 -0
  80. package/dist/lib/notification-service.test.js +37 -36
  81. package/dist/lib/pdf.d.ts +11 -0
  82. package/dist/lib/pdf.js +11 -0
  83. package/dist/lib/usage_service.d.ts +2 -1
  84. package/dist/lib/usage_service.js +30 -12
  85. package/dist/lib/worksheet-generation.js +4 -4
  86. package/dist/lib/worksheet-generation.test.js +32 -17
  87. package/dist/lib/workspace-kb.d.ts +5 -0
  88. package/dist/lib/workspace-kb.js +7 -0
  89. package/dist/models/controller-context.model.d.ts +8 -0
  90. package/dist/models/controller-context.model.js +1 -0
  91. package/dist/repositories/artifact.repository.d.ts +60 -0
  92. package/dist/repositories/artifact.repository.js +40 -0
  93. package/dist/repositories/base.repository.d.ts +14 -0
  94. package/dist/repositories/base.repository.js +14 -0
  95. package/dist/repositories/invitation.repository.d.ts +94 -0
  96. package/dist/repositories/invitation.repository.js +44 -0
  97. package/dist/repositories/notification.repository.d.ts +72 -0
  98. package/dist/repositories/notification.repository.js +44 -0
  99. package/dist/repositories/router-module.repository.d.ts +10 -0
  100. package/dist/repositories/router-module.repository.js +14 -0
  101. package/dist/repositories/user.repository.d.ts +74 -0
  102. package/dist/repositories/user.repository.js +37 -0
  103. package/dist/repositories/workspace-member.repository.d.ts +31 -0
  104. package/dist/repositories/workspace-member.repository.js +31 -0
  105. package/dist/repositories/workspace.repository.d.ts +97 -0
  106. package/dist/repositories/workspace.repository.js +79 -0
  107. package/dist/routers/_app.d.ts +566 -111
  108. package/dist/routers/_app.js +4 -0
  109. package/dist/routers/admin.d.ts +0 -4
  110. package/dist/routers/admin.js +21 -549
  111. package/dist/routers/annotations.js +12 -170
  112. package/dist/routers/artifactVersions.d.ts +65 -0
  113. package/dist/routers/artifactVersions.js +14 -0
  114. package/dist/routers/auth.d.ts +0 -6
  115. package/dist/routers/auth.js +37 -422
  116. package/dist/routers/chat.js +15 -229
  117. package/dist/routers/copilot.d.ts +14 -13
  118. package/dist/routers/copilot.js +13 -532
  119. package/dist/routers/flashcards.d.ts +17 -6
  120. package/dist/routers/flashcards.js +23 -349
  121. package/dist/routers/knowledgeBase.d.ts +421 -0
  122. package/dist/routers/knowledgeBase.js +118 -0
  123. package/dist/routers/members.d.ts +0 -41
  124. package/dist/routers/members.js +22 -710
  125. package/dist/routers/notes.d.ts +94 -0
  126. package/dist/routers/notes.js +37 -0
  127. package/dist/routers/notifications.js +7 -109
  128. package/dist/routers/payment.d.ts +2 -12
  129. package/dist/routers/payment.js +11 -393
  130. package/dist/routers/podcast.d.ts +1 -1
  131. package/dist/routers/podcast.js +11 -784
  132. package/dist/routers/studyguide.js +3 -129
  133. package/dist/routers/worksheets.d.ts +29 -14
  134. package/dist/routers/worksheets.js +49 -628
  135. package/dist/routers/workspace.d.ts +27 -71
  136. package/dist/routers/workspace.js +29 -922
  137. package/dist/scripts/purge-deleted-users.js +2 -2
  138. package/dist/server.js +10 -3
  139. package/dist/services/activity/activity-human-description.service.d.ts +13 -0
  140. package/dist/services/activity/activity-human-description.service.js +221 -0
  141. package/dist/services/activity/activity-human-description.service.test.d.ts +1 -0
  142. package/dist/services/activity/activity-human-description.service.test.js +16 -0
  143. package/dist/services/activity/activity-log.service.d.ts +87 -0
  144. package/dist/services/activity/activity-log.service.js +276 -0
  145. package/dist/services/activity/activity-log.service.test.d.ts +1 -0
  146. package/dist/services/activity/activity-log.service.test.js +27 -0
  147. package/dist/services/activity-human-description.service.d.ts +13 -0
  148. package/dist/services/activity-human-description.service.js +221 -0
  149. package/dist/services/activity-human-description.service.test.d.ts +1 -0
  150. package/dist/services/activity-human-description.service.test.js +16 -0
  151. package/dist/services/activity-log.service.d.ts +87 -0
  152. package/dist/services/activity-log.service.js +276 -0
  153. package/dist/services/activity-log.service.test.d.ts +1 -0
  154. package/dist/services/activity-log.service.test.js +27 -0
  155. package/dist/services/admin/admin.service.d.ts +270 -0
  156. package/dist/services/admin/admin.service.js +476 -0
  157. package/dist/services/admin.service.d.ts +270 -0
  158. package/dist/services/admin.service.js +476 -0
  159. package/dist/services/ai/ai-session.service.d.ts +5 -0
  160. package/dist/services/ai/ai-session.service.js +4 -0
  161. package/dist/services/ai-session.service.d.ts +60 -0
  162. package/dist/services/ai-session.service.js +561 -0
  163. package/dist/services/annotation.service.d.ts +177 -0
  164. package/dist/services/annotation.service.js +154 -0
  165. package/dist/services/artifact-notification.service.d.ts +14 -0
  166. package/dist/services/artifact-notification.service.js +20 -0
  167. package/dist/services/artifact-version.service.d.ts +38 -0
  168. package/dist/services/artifact-version.service.js +129 -0
  169. package/dist/services/artifacts/annotation.service.d.ts +177 -0
  170. package/dist/services/artifacts/annotation.service.js +154 -0
  171. package/dist/services/artifacts/artifact-version.service.d.ts +38 -0
  172. package/dist/services/artifacts/artifact-version.service.js +129 -0
  173. package/dist/services/artifacts/chat.service.d.ts +127 -0
  174. package/dist/services/artifacts/chat.service.js +182 -0
  175. package/dist/services/artifacts/study-guide.service.d.ts +18 -0
  176. package/dist/services/artifacts/study-guide.service.js +65 -0
  177. package/dist/services/auth/auth.service.d.ts +94 -0
  178. package/dist/services/auth/auth.service.js +368 -0
  179. package/dist/services/auth.service.d.ts +94 -0
  180. package/dist/services/auth.service.js +368 -0
  181. package/dist/services/base.service.d.ts +14 -0
  182. package/dist/services/base.service.js +14 -0
  183. package/dist/services/billing/payment.service.d.ts +44 -0
  184. package/dist/services/billing/payment.service.js +365 -0
  185. package/dist/services/billing/subscription.service.d.ts +37 -0
  186. package/dist/services/billing/subscription.service.js +654 -0
  187. package/dist/services/billing/usage.service.d.ts +47 -0
  188. package/dist/services/billing/usage.service.js +149 -0
  189. package/dist/services/chat.service.d.ts +127 -0
  190. package/dist/services/chat.service.js +182 -0
  191. package/dist/services/content/copilot.service.d.ts +113 -0
  192. package/dist/services/content/copilot.service.js +439 -0
  193. package/dist/services/content/flashcard-progress.service.d.ts +159 -0
  194. package/dist/services/content/flashcard-progress.service.js +432 -0
  195. package/dist/services/content/flashcard.service.d.ts +184 -0
  196. package/dist/services/content/flashcard.service.js +339 -0
  197. package/dist/services/content/media-analysis.service.d.ts +23 -0
  198. package/dist/services/content/media-analysis.service.js +404 -0
  199. package/dist/services/content/podcast.service.d.ts +267 -0
  200. package/dist/services/content/podcast.service.js +653 -0
  201. package/dist/services/content/worksheet-content.service.d.ts +37 -0
  202. package/dist/services/content/worksheet-content.service.js +84 -0
  203. package/dist/services/content/worksheet-content.service.test.d.ts +1 -0
  204. package/dist/services/content/worksheet-content.service.test.js +69 -0
  205. package/dist/services/content/worksheet-generation.service.d.ts +91 -0
  206. package/dist/services/content/worksheet-generation.service.js +95 -0
  207. package/dist/services/content/worksheet-generation.service.test.d.ts +1 -0
  208. package/dist/services/content/worksheet-generation.service.test.js +20 -0
  209. package/dist/services/content/worksheet.service.d.ts +347 -0
  210. package/dist/services/content/worksheet.service.js +599 -0
  211. package/dist/services/copilot.service.d.ts +116 -0
  212. package/dist/services/copilot.service.js +447 -0
  213. package/dist/services/flashcard-progress.service.d.ts +2 -2
  214. package/dist/services/flashcard-progress.service.js +3 -2
  215. package/dist/services/flashcard.service.d.ts +140 -0
  216. package/dist/services/flashcard.service.js +325 -0
  217. package/dist/services/invitation.service.d.ts +66 -0
  218. package/dist/services/invitation.service.js +348 -0
  219. package/dist/services/knowledge/knowledge-base.service.d.ts +316 -0
  220. package/dist/services/knowledge/knowledge-base.service.js +544 -0
  221. package/dist/services/knowledge-base.service.d.ts +316 -0
  222. package/dist/services/knowledge-base.service.js +536 -0
  223. package/dist/services/media-analysis.service.d.ts +23 -0
  224. package/dist/services/media-analysis.service.js +384 -0
  225. package/dist/services/member.service.d.ts +36 -0
  226. package/dist/services/member.service.js +193 -0
  227. package/dist/services/members/invitation.service.d.ts +66 -0
  228. package/dist/services/members/invitation.service.js +348 -0
  229. package/dist/services/members/member.service.d.ts +36 -0
  230. package/dist/services/members/member.service.js +193 -0
  231. package/dist/services/note.service.d.ts +55 -0
  232. package/dist/services/note.service.js +111 -0
  233. package/dist/services/notification.service.d.ts +214 -0
  234. package/dist/services/notification.service.js +550 -0
  235. package/dist/services/notification.service.test.d.ts +1 -0
  236. package/dist/services/notification.service.test.js +87 -0
  237. package/dist/services/notifications/notification.service.d.ts +214 -0
  238. package/dist/services/notifications/notification.service.js +550 -0
  239. package/dist/services/notifications/notification.service.test.d.ts +1 -0
  240. package/dist/services/notifications/notification.service.test.js +87 -0
  241. package/dist/services/payment.service.d.ts +55 -0
  242. package/dist/services/payment.service.js +368 -0
  243. package/dist/services/podcast.service.d.ts +267 -0
  244. package/dist/services/podcast.service.js +654 -0
  245. package/dist/services/router-module.service.d.ts +7 -0
  246. package/dist/services/router-module.service.js +10 -0
  247. package/dist/services/study-guide.service.d.ts +18 -0
  248. package/dist/services/study-guide.service.js +65 -0
  249. package/dist/services/subscription.service.d.ts +37 -0
  250. package/dist/services/subscription.service.js +654 -0
  251. package/dist/services/usage-limit-policy.service.d.ts +12 -0
  252. package/dist/services/usage-limit-policy.service.js +22 -0
  253. package/dist/services/usage-limit-policy.service.test.d.ts +1 -0
  254. package/dist/services/usage-limit-policy.service.test.js +46 -0
  255. package/dist/services/usage.service.d.ts +27 -0
  256. package/dist/services/usage.service.js +77 -0
  257. package/dist/services/worksheet-content.service.d.ts +42 -0
  258. package/dist/services/worksheet-content.service.js +84 -0
  259. package/dist/services/worksheet-content.service.test.d.ts +1 -0
  260. package/dist/services/worksheet-content.service.test.js +69 -0
  261. package/dist/services/worksheet-generation.service.d.ts +91 -0
  262. package/dist/services/worksheet-generation.service.js +95 -0
  263. package/dist/services/worksheet-generation.service.test.d.ts +1 -0
  264. package/dist/services/worksheet-generation.service.test.js +20 -0
  265. package/dist/services/worksheet.service.d.ts +385 -0
  266. package/dist/services/worksheet.service.js +596 -0
  267. package/dist/services/workspace/workspace-analytics.service.d.ts +24 -0
  268. package/dist/services/workspace/workspace-analytics.service.js +95 -0
  269. package/dist/services/workspace/workspace-kb.service.d.ts +40 -0
  270. package/dist/services/workspace/workspace-kb.service.js +184 -0
  271. package/dist/services/workspace/workspace.service.d.ts +263 -0
  272. package/dist/services/workspace/workspace.service.js +401 -0
  273. package/dist/services/workspace-analytics.service.d.ts +24 -0
  274. package/dist/services/workspace-analytics.service.js +95 -0
  275. package/dist/services/workspace-kb.service.d.ts +40 -0
  276. package/dist/services/workspace-kb.service.js +184 -0
  277. package/dist/services/workspace-progress.service.d.ts +27 -0
  278. package/dist/services/workspace-progress.service.js +56 -0
  279. package/dist/services/workspace-progress.service.test.d.ts +1 -0
  280. package/dist/services/workspace-progress.service.test.js +49 -0
  281. package/dist/services/workspace.service.d.ts +307 -0
  282. package/dist/services/workspace.service.js +390 -0
  283. package/dist/trpc.d.ts +12 -4
  284. package/dist/trpc.js +7 -13
  285. package/package.json +5 -6
  286. package/prisma/migrations/20260509000001_add_knowledge_base/migration.sql +99 -0
  287. package/prisma/migrations/20260509000002_curate_knowledge_base/migration.sql +52 -0
  288. package/prisma/migrations/20260522000000_add_notes/migration.sql +27 -0
  289. package/prisma/migrations/20260524000000_remove_notes/migration.sql +3 -0
  290. package/prisma/schema.prisma +150 -48
  291. package/prisma/seed.mjs +67 -0
  292. package/scripts/debug/README.md +4 -0
  293. package/src/README.md +63 -0
  294. package/src/context.ts +33 -3
  295. package/src/lib/ai/config.ts +34 -0
  296. package/src/lib/ai/embedding-client.ts +47 -0
  297. package/src/lib/ai/index.ts +65 -0
  298. package/src/lib/ai/inference-backend/client.ts +479 -0
  299. package/src/lib/ai/inference-backend/mocks.ts +171 -0
  300. package/src/lib/ai/inference-backend/types.ts +50 -0
  301. package/src/lib/ai/json-parse.ts +35 -0
  302. package/src/lib/ai/llm-client.ts +54 -0
  303. package/src/lib/ai/mock.ts +12 -0
  304. package/src/lib/ai/types.ts +11 -0
  305. package/src/lib/chunking.ts +81 -0
  306. package/src/lib/curated-kb-seed.ts +164 -0
  307. package/src/lib/email.ts +77 -115
  308. package/src/lib/embeddings.ts +9 -0
  309. package/src/lib/ensure-curated-kb-catalog.ts +60 -0
  310. package/src/lib/env.ts +2 -7
  311. package/src/lib/inference.ts +1 -21
  312. package/src/lib/kb-meta.ts +81 -0
  313. package/src/lib/pdf.ts +23 -0
  314. package/src/lib/workspace-kb.ts +7 -0
  315. package/src/repositories/artifact.repository.ts +55 -0
  316. package/src/repositories/base.repository.ts +19 -0
  317. package/src/repositories/invitation.repository.ts +53 -0
  318. package/src/repositories/notification.repository.ts +53 -0
  319. package/src/repositories/user.repository.ts +44 -0
  320. package/src/repositories/workspace-member.repository.ts +38 -0
  321. package/src/repositories/workspace.repository.ts +89 -0
  322. package/src/routers/_app.ts +4 -0
  323. package/src/routers/admin.ts +124 -692
  324. package/src/routers/annotations.ts +25 -203
  325. package/src/routers/artifactVersions.ts +32 -0
  326. package/src/routers/auth.ts +82 -520
  327. package/src/routers/chat.ts +42 -245
  328. package/src/routers/copilot.ts +41 -666
  329. package/src/routers/flashcards.ts +108 -404
  330. package/src/routers/knowledgeBase.ts +216 -0
  331. package/src/routers/members.ts +60 -782
  332. package/src/routers/notifications.ts +15 -117
  333. package/src/routers/payment.ts +37 -446
  334. package/src/routers/podcast.ts +36 -898
  335. package/src/routers/studyguide.ts +5 -144
  336. package/src/routers/worksheets.ts +171 -735
  337. package/src/routers/workspace.ts +141 -1108
  338. package/src/scripts/purge-deleted-users.ts +2 -2
  339. package/src/server.ts +10 -3
  340. package/src/{lib/activity_human_description.test.ts → services/activity/activity-human-description.service.test.ts} +1 -1
  341. package/src/{lib/activity_log_service.test.ts → services/activity/activity-log.service.test.ts} +1 -1
  342. package/src/{lib/activity_log_service.ts → services/activity/activity-log.service.ts} +2 -2
  343. package/src/services/admin/admin.service.ts +612 -0
  344. package/src/services/ai/ai-session.service.ts +5 -0
  345. package/src/services/artifacts/annotation.service.ts +189 -0
  346. package/src/services/artifacts/artifact-version.service.ts +151 -0
  347. package/src/services/artifacts/chat.service.ts +197 -0
  348. package/src/services/artifacts/study-guide.service.ts +72 -0
  349. package/src/services/auth/auth.service.ts +473 -0
  350. package/src/services/base.service.ts +19 -0
  351. package/src/services/billing/payment.service.ts +433 -0
  352. package/src/{lib/subscription_service.ts → services/billing/subscription.service.ts} +5 -5
  353. package/src/services/billing/usage.service.ts +207 -0
  354. package/src/services/content/copilot.service.ts +587 -0
  355. package/src/services/{flashcard-progress.service.ts → content/flashcard-progress.service.ts} +18 -12
  356. package/src/services/content/flashcard.service.ts +417 -0
  357. package/src/services/content/media-analysis.service.ts +561 -0
  358. package/src/services/content/podcast.service.ts +777 -0
  359. package/src/services/content/worksheet-content.service.test.ts +83 -0
  360. package/src/services/content/worksheet-content.service.ts +117 -0
  361. package/src/{lib/worksheet-generation.test.ts → services/content/worksheet-generation.service.test.ts} +3 -3
  362. package/src/services/content/worksheet.service.ts +751 -0
  363. package/src/services/knowledge/knowledge-base.service.ts +705 -0
  364. package/src/services/members/invitation.service.ts +427 -0
  365. package/src/services/members/member.service.ts +241 -0
  366. package/src/{lib/notification-service.test.ts → services/notifications/notification.service.test.ts} +2 -2
  367. package/src/{lib/notification-service.ts → services/notifications/notification.service.ts} +102 -1
  368. package/src/services/workspace/workspace-analytics.service.ts +107 -0
  369. package/src/services/workspace/workspace-kb.service.ts +273 -0
  370. package/src/services/workspace/workspace.service.ts +488 -0
  371. package/src/trpc.ts +7 -15
  372. package/src/lib/ai-session.ts +0 -704
  373. package/src/lib/usage_service.ts +0 -74
  374. package/src/lib/workspace-access.ts +0 -13
  375. /package/{check-difficulty.cjs → scripts/debug/check-difficulty.cjs} +0 -0
  376. /package/{check-questions.cjs → scripts/debug/check-questions.cjs} +0 -0
  377. /package/{db-summary.cjs → scripts/debug/db-summary.cjs} +0 -0
  378. /package/{mcq-test.cjs → scripts/debug/mcq-test.cjs} +0 -0
  379. /package/{test-generate.js → scripts/debug/test-generate.js} +0 -0
  380. /package/{test-ratio.cjs → scripts/debug/test-ratio.cjs} +0 -0
  381. /package/{zod-test.cjs → scripts/debug/zod-test.cjs} +0 -0
  382. /package/src/{lib/activity_human_description.ts → services/activity/activity-human-description.service.ts} +0 -0
  383. /package/src/{lib/worksheet-generation.ts → services/content/worksheet-generation.service.ts} +0 -0
@@ -1,1147 +1,180 @@
1
1
  import { z } from 'zod';
2
- import { TRPCError } from '@trpc/server';
3
- import { router, publicProcedure, authedProcedure, verifiedProcedure } from '../trpc.js';
4
- import { supabaseClient } from '../lib/storage.js';
5
- import { ArtifactType } from '../lib/constants.js';
6
- import { aiSessionService } from '../lib/ai-session.js';
7
- import PusherService from '../lib/pusher.js';
2
+ import { router, authedProcedure, verifiedProcedure } from '../trpc.js';
8
3
  import { members } from './members.js';
9
- import { logger } from '../lib/logger.js';
10
- import type { PrismaClient } from '@prisma/client';
11
- import { getUserStorageLimit } from '../lib/subscription_service.js';
12
- import { getUserUsage, getUserPlanLimits } from '../lib/usage_service.js';
13
- import {
14
- notifyArtifactFailed,
15
- notifyArtifactReady,
16
- notifyWorkspaceDeleted,
17
- } from '../lib/notification-service.js';
18
-
19
- // Helper function to update and emit analysis progress
20
- async function updateAnalysisProgress(
21
- db: PrismaClient,
22
- workspaceId: string,
23
- progress: any
24
- ) {
25
- await db.workspace.update({
26
- where: { id: workspaceId },
27
- data: { analysisProgress: progress }
28
- });
29
- await PusherService.emitAnalysisProgress(workspaceId, progress);
30
- }
31
-
32
- // DRY helper to build progress steps for artifact generation pipeline
33
- type StepStatus = 'pending' | 'in_progress' | 'completed' | 'skipped' | 'error';
34
- const PIPELINE_STEPS = ['fileUpload', 'fileAnalysis', 'studyGuide', 'flashcards'] as const;
35
-
36
- function buildProgressSteps(
37
- currentStep: typeof PIPELINE_STEPS[number],
38
- currentStatus: StepStatus,
39
- config: { generateStudyGuide: boolean; generateFlashcards: boolean },
40
- overrides?: Partial<Record<typeof PIPELINE_STEPS[number], StepStatus>>
41
- ): Record<string, { order: number; status: StepStatus }> {
42
- const stepIndex = PIPELINE_STEPS.indexOf(currentStep);
43
- const steps: Record<string, { order: number; status: StepStatus }> = {};
44
-
45
- for (let i = 0; i < PIPELINE_STEPS.length; i++) {
46
- const step = PIPELINE_STEPS[i];
47
- let status: StepStatus;
48
-
49
- if (overrides?.[step]) {
50
- status = overrides[step]!;
51
- } else if (i < stepIndex) {
52
- status = 'completed';
53
- } else if (i === stepIndex) {
54
- status = currentStatus;
55
- } else {
56
- // Future steps: check if they're configured
57
- if (step === 'studyGuide' && !config.generateStudyGuide) {
58
- status = 'skipped';
59
- } else if (step === 'flashcards' && !config.generateFlashcards) {
60
- status = 'skipped';
61
- } else {
62
- status = 'pending';
63
- }
64
- }
65
-
66
- steps[step] = { order: i + 1, status };
67
- }
68
-
69
- return steps;
70
- }
71
-
72
- function buildProgress(
73
- status: string,
74
- filename: string,
75
- fileType: string,
76
- currentStep: typeof PIPELINE_STEPS[number],
77
- currentStepStatus: StepStatus,
78
- config: { generateStudyGuide: boolean; generateFlashcards: boolean },
79
- extra?: Record<string, any>
80
- ) {
81
- return {
82
- status,
83
- filename,
84
- fileType,
85
- startedAt: new Date().toISOString(),
86
- steps: buildProgressSteps(currentStep, currentStepStatus, config, extra as any),
87
- ...extra,
88
- };
89
- }
90
-
91
- // Helper function to calculate search relevance score
92
- function calculateRelevance(query: string, ...texts: (string | null | undefined)[]): number {
93
- const queryLower = query.toLowerCase();
94
- let score = 0;
95
-
96
- for (const text of texts) {
97
- if (!text) continue;
98
-
99
- const textLower = text.toLowerCase();
100
-
101
- // Exact match gets highest score
102
- if (textLower.includes(queryLower)) {
103
- score += 10;
104
- }
105
-
106
- // Word boundary matches get good score
107
- const words = queryLower.split(/\s+/);
108
- for (const word of words) {
109
- if (word.length > 2 && textLower.includes(word)) {
110
- score += 5;
111
- }
112
- }
113
-
114
- // Partial matches get lower score
115
- const queryChars = queryLower.split('');
116
- let consecutiveMatches = 0;
117
- for (const char of queryChars) {
118
- if (textLower.includes(char)) {
119
- consecutiveMatches++;
120
- } else {
121
- consecutiveMatches = 0;
122
- }
123
- }
124
- score += consecutiveMatches * 0.1;
125
- }
126
-
127
- return score;
128
- }
4
+ import { WorkspaceService } from '../services/workspace/workspace.service.js';
5
+ import { WorkspaceAnalyticsService } from '../services/workspace/workspace-analytics.service.js';
6
+ import { MediaAnalysisService } from '../services/content/media-analysis.service.js';
129
7
 
130
8
  export const workspace = router({
131
- // List current user's workspaces
132
9
  list: authedProcedure
133
- .input(z.object({
134
- parentId: z.string().optional(),
135
- }))
136
- .query(async ({ ctx, input }) => {
137
- const workspaces = await ctx.db.workspace.findMany({
138
- where: {
139
- ownerId: ctx.session.user.id,
140
- folderId: input.parentId ?? null,
141
- },
142
- orderBy: { updatedAt: 'desc' },
143
- });
144
-
145
- const folders = await ctx.db.folder.findMany({
146
- where: {
147
- ownerId: ctx.session.user.id,
148
- parentId: input.parentId ?? null,
149
- },
150
- });
151
-
152
- return { workspaces, folders };
153
- }),
154
-
155
- /**
156
- * Fetches the entire directory tree for the user.
157
- * Includes Folders, Workspaces (files), and Uploads (sub-files).
158
- */
159
- getTree: authedProcedure
160
- .query(async ({ ctx }) => {
161
- const userId = ctx.session.user.id;
162
-
163
- // 1. Fetch all folders
164
- const allFolders = await ctx.db.folder.findMany({
165
- where: { ownerId: userId },
166
- orderBy: { updatedAt: 'desc' },
167
- });
168
-
169
- // 2. Fetch all workspaces
170
- const allWorkspaces = await ctx.db.workspace.findMany({
171
- where: { ownerId: userId },
172
- include: {
173
- uploads: {
174
- select: {
175
- id: true,
176
- name: true,
177
- mimeType: true,
178
- createdAt: true,
179
- }
180
- }
181
- },
182
- orderBy: { updatedAt: 'desc' },
183
- });
184
-
185
- return {
186
- folders: allFolders,
187
- workspaces: allWorkspaces,
188
- };
189
- }),
10
+ .input(
11
+ z.object({
12
+ parentId: z.string().optional(),
13
+ }),
14
+ )
15
+ .query(({ ctx, input }) =>
16
+ new WorkspaceService(ctx.db).list(ctx.session.user.id, input.parentId ?? null),
17
+ ),
18
+
19
+ getTree: authedProcedure.query(({ ctx }) =>
20
+ new WorkspaceService(ctx.db).getTree(ctx.session.user.id),
21
+ ),
190
22
 
191
23
  create: authedProcedure
192
- .input(z.object({
193
- name: z.string().min(1).max(100),
194
- description: z.string().max(500).optional(),
195
- parentId: z.string().optional(),
196
- markerColor: z.string().nullable().optional(),
197
- }))
198
- .mutation(async ({ ctx, input }) => {
199
- const ws = await ctx.db.workspace.create({
200
- data: {
201
- title: input.name,
202
- description: input.description,
203
- ownerId: ctx.session.user.id,
204
- folderId: input.parentId ?? null,
205
- ...(input.markerColor !== undefined ? { markerColor: input.markerColor } : {}),
206
- artifacts: {
207
- create: {
208
- type: ArtifactType.FLASHCARD_SET,
209
- title: "New Flashcard Set",
210
- },
211
- createMany: {
212
- data: [
213
- { type: ArtifactType.WORKSHEET, title: "Worksheet 1" },
214
- { type: ArtifactType.WORKSHEET, title: "Worksheet 2" },
215
- ],
216
- },
217
- },
218
- },
219
- });
220
-
221
- await aiSessionService.initSession(ws.id, ctx.session.user.id).catch((err) => {
222
- logger.error('Failed to init AI session on workspace creation:', err);
223
- });
224
-
225
- await PusherService.emitLibraryUpdate(ctx.session.user.id);
24
+ .input(
25
+ z.object({
26
+ name: z.string().min(1).max(100),
27
+ description: z.string().max(500).optional(),
28
+ parentId: z.string().optional(),
29
+ markerColor: z.string().nullable().optional(),
30
+ }),
31
+ )
32
+ .mutation(({ ctx, input }) =>
33
+ new WorkspaceService(ctx.db).create(ctx.session.user.id, input),
34
+ ),
226
35
 
227
- return ws;
228
- }),
229
36
  createFolder: authedProcedure
230
- .input(z.object({
231
- name: z.string().min(1).max(100),
232
- color: z.string().optional(),
233
- parentId: z.string().optional(),
234
- }))
235
- .mutation(async ({ ctx, input }) => {
236
- const folder = await ctx.db.folder.create({
237
- data: {
238
- name: input.name,
239
- ownerId: ctx.session.user.id,
240
- color: input.color ?? '#9D00FF',
241
- parentId: input.parentId ?? null,
242
- },
243
- });
37
+ .input(
38
+ z.object({
39
+ name: z.string().min(1).max(100),
40
+ color: z.string().optional(),
41
+ parentId: z.string().optional(),
42
+ }),
43
+ )
44
+ .mutation(({ ctx, input }) =>
45
+ new WorkspaceService(ctx.db).createFolder(ctx.session.user.id, input),
46
+ ),
244
47
 
245
- await PusherService.emitLibraryUpdate(ctx.session.user.id);
246
-
247
- return folder;
248
- }),
249
48
  updateFolder: authedProcedure
250
- .input(z.object({
251
- id: z.string(),
252
- name: z.string().min(1).max(100).optional(),
253
- markerColor: z.string().nullable().optional(),
254
- }))
255
- .mutation(async ({ ctx, input }) => {
256
- const folder = await ctx.db.folder.update({
257
- where: { id: input.id },
258
- data: {
259
- name: input.name,
260
- markerColor: input.markerColor
261
- }
262
- });
263
-
264
- await PusherService.emitLibraryUpdate(ctx.session.user.id);
49
+ .input(
50
+ z.object({
51
+ id: z.string(),
52
+ name: z.string().min(1).max(100).optional(),
53
+ markerColor: z.string().nullable().optional(),
54
+ }),
55
+ )
56
+ .mutation(({ ctx, input }) =>
57
+ new WorkspaceService(ctx.db).updateFolder(ctx.session.user.id, input),
58
+ ),
265
59
 
266
- return folder;
267
- }),
268
60
  deleteFolder: authedProcedure
269
- .input(z.object({
270
- id: z.string(),
271
- }))
272
- .mutation(async ({ ctx, input }) => {
273
- const folder = await ctx.db.folder.delete({ where: { id: input.id } });
61
+ .input(z.object({ id: z.string() }))
62
+ .mutation(({ ctx, input }) =>
63
+ new WorkspaceService(ctx.db).deleteFolder(ctx.session.user.id, input.id),
64
+ ),
274
65
 
275
- await PusherService.emitLibraryUpdate(ctx.session.user.id);
276
-
277
- return folder;
278
- }),
279
66
  get: authedProcedure
280
- .input(z.object({
281
- id: z.string(),
282
- }))
283
- .query(async ({ ctx, input }) => {
284
- const ws = await ctx.db.workspace.findFirst({
285
- where: { id: input.id, ownerId: ctx.session.user.id },
286
- include: {
287
- artifacts: true,
288
- folder: true,
289
- uploads: true,
290
- },
291
- });
292
- if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
293
- return ws;
294
- }),
295
- getStats: authedProcedure
296
- .query(async ({ ctx }) => {
297
- const workspaces = await ctx.db.workspace.findMany({
298
- where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
299
- });
300
- const folders = await ctx.db.folder.findMany({
301
- where: { OR: [{ ownerId: ctx.session.user.id }] },
302
- });
303
- const lastUpdated = await ctx.db.workspace.findFirst({
304
- where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
305
- orderBy: { updatedAt: 'desc' },
306
- });
307
-
308
- const spaceLeft = await ctx.db.fileAsset.aggregate({
309
- where: { workspaceId: { in: workspaces.map((ws: any) => ws.id) }, userId: ctx.session.user.id },
310
- _sum: { size: true },
311
- });
312
-
313
- const storageLimit = await getUserStorageLimit(ctx.session.user.id);
314
-
315
- return {
316
- workspaces: workspaces.length,
317
- folders: folders.length,
318
- lastUpdated: lastUpdated?.updatedAt,
319
- spaceUsed: spaceLeft._sum?.size ?? 0,
320
- spaceTotal: storageLimit,
321
- };
322
- }),
323
-
324
- // Study analytics: streaks, flashcard mastery, worksheet accuracy
325
- getStudyAnalytics: authedProcedure
326
- .query(async ({ ctx }) => {
327
- const userId = ctx.session.user.id;
328
-
329
- // Gather all study activity dates
330
- const flashcardProgress = await ctx.db.flashcardProgress.findMany({
331
- where: { userId },
332
- select: { lastStudiedAt: true },
333
- });
334
-
335
- const worksheetProgress = await ctx.db.worksheetQuestionProgress.findMany({
336
- where: { userId },
337
- select: { updatedAt: true, completedAt: true },
338
- });
339
-
340
- // Build a set of unique study days (YYYY-MM-DD)
341
- const studyDays = new Set<string>();
342
- for (const fp of flashcardProgress) {
343
- if (fp.lastStudiedAt) {
344
- studyDays.add(fp.lastStudiedAt.toISOString().split('T')[0]);
345
- }
346
- }
347
- for (const wp of worksheetProgress) {
348
- if (wp.completedAt) {
349
- studyDays.add(wp.completedAt.toISOString().split('T')[0]);
350
- } else {
351
- studyDays.add(wp.updatedAt.toISOString().split('T')[0]);
352
- }
353
- }
354
-
355
- // Calculate streak (consecutive days ending today or yesterday)
356
- const sortedDays = [...studyDays].sort().reverse();
357
- let streak = 0;
358
-
359
- if (sortedDays.length > 0) {
360
- const today = new Date();
361
- today.setHours(0, 0, 0, 0);
362
- const yesterday = new Date(today);
363
- yesterday.setDate(yesterday.getDate() - 1);
364
-
365
- const todayStr = today.toISOString().split('T')[0];
366
- const yesterdayStr = yesterday.toISOString().split('T')[0];
367
-
368
- // Streak only counts if the most recent study day is today or yesterday
369
- if (sortedDays[0] === todayStr || sortedDays[0] === yesterdayStr) {
370
- streak = 1;
371
- for (let i = 1; i < sortedDays.length; i++) {
372
- const current = new Date(sortedDays[i - 1]);
373
- const prev = new Date(sortedDays[i]);
374
- const diffDays = (current.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
375
- if (diffDays === 1) {
376
- streak++;
377
- } else {
378
- break;
379
- }
380
- }
381
- }
382
- }
383
-
384
- // Weekly activity (last 7 days)
385
- const weeklyActivity: boolean[] = [];
386
- const today = new Date();
387
- today.setHours(0, 0, 0, 0);
388
- for (let i = 6; i >= 0; i--) {
389
- const d = new Date(today);
390
- d.setDate(d.getDate() - i);
391
- const dayStr = d.toISOString().split('T')[0];
392
- weeklyActivity.push(studyDays.has(dayStr));
393
- }
67
+ .input(z.object({ id: z.string() }))
68
+ .query(({ ctx, input }) => new WorkspaceService(ctx.db).get(ctx.session.user.id, input.id)),
394
69
 
395
- // Flashcard stats
396
- const totalCards = await ctx.db.flashcardProgress.count({ where: { userId } });
397
- const masteredCards = await ctx.db.flashcardProgress.count({
398
- where: { userId, masteryLevel: { gte: 80 } },
399
- });
400
- const dueCards = await ctx.db.flashcardProgress.count({
401
- where: { userId, nextReviewAt: { lte: new Date() } },
402
- });
70
+ getStats: authedProcedure.query(({ ctx }) =>
71
+ new WorkspaceService(ctx.db).getStats(ctx.session.user.id),
72
+ ),
403
73
 
404
- // Worksheet stats
405
- const completedQuestions = await ctx.db.worksheetQuestionProgress.count({
406
- where: { userId, completedAt: { not: null } },
407
- });
408
- const correctQuestions = await ctx.db.worksheetQuestionProgress.count({
409
- where: { userId, correct: true },
410
- });
74
+ getAccountSummary: authedProcedure.query(({ ctx }) =>
75
+ new WorkspaceService(ctx.db).getAccountSummary(ctx.session.user.id),
76
+ ),
411
77
 
412
- return {
413
- streak,
414
- totalStudyDays: studyDays.size,
415
- weeklyActivity,
416
- flashcards: {
417
- total: totalCards,
418
- mastered: masteredCards,
419
- dueForReview: dueCards,
420
- },
421
- worksheets: {
422
- completed: completedQuestions,
423
- correct: correctQuestions,
424
- accuracy: completedQuestions > 0 ? Math.round((correctQuestions / completedQuestions) * 100) : 0,
425
- },
426
- };
427
- }),
78
+ getStudyAnalytics: authedProcedure.query(({ ctx }) =>
79
+ new WorkspaceAnalyticsService(ctx.db).getStudyAnalytics(ctx.session.user.id),
80
+ ),
428
81
 
429
82
  update: authedProcedure
430
- .input(z.object({
431
- id: z.string(),
432
- name: z.string().min(1).max(100).optional(),
433
- description: z.string().max(500).optional(),
434
- markerColor: z.string().nullable().optional(),
435
- icon: z.string().optional(),
436
- }))
437
- .mutation(async ({ ctx, input }) => {
438
- const existed = await ctx.db.workspace.findFirst({
439
- where: { id: input.id, ownerId: ctx.session.user.id },
440
- });
441
- if (!existed) throw new TRPCError({ code: 'NOT_FOUND' });
442
- const updated = await ctx.db.workspace.update({
443
- where: { id: input.id },
444
- data: {
445
- title: input.name ?? existed.title,
446
- description: input.description,
447
- // Preserve explicit null ("None color") instead of falling back.
448
- markerColor: input.markerColor !== undefined ? input.markerColor : existed.markerColor,
449
- icon: input.icon ?? existed.icon,
450
- },
451
- });
452
-
453
- await PusherService.emitLibraryUpdate(ctx.session.user.id);
83
+ .input(
84
+ z.object({
85
+ id: z.string(),
86
+ name: z.string().min(1).max(100).optional(),
87
+ description: z.string().max(500).optional(),
88
+ markerColor: z.string().nullable().optional(),
89
+ icon: z.string().optional(),
90
+ }),
91
+ )
92
+ .mutation(({ ctx, input }) =>
93
+ new WorkspaceService(ctx.db).update(ctx.session.user.id, input),
94
+ ),
454
95
 
455
- return updated;
456
- }),
457
96
  delete: authedProcedure
458
- .input(z.object({
459
- id: z.string(),
460
- }))
461
- .mutation(async ({ ctx, input }) => {
462
- const workspaceToDelete = await ctx.db.workspace.findFirst({
463
- where: { id: input.id, ownerId: ctx.session.user.id },
464
- select: {
465
- id: true,
466
- title: true,
467
- ownerId: true,
468
- members: {
469
- select: { userId: true },
470
- },
471
- },
472
- });
473
-
474
- if (!workspaceToDelete) throw new TRPCError({ code: 'NOT_FOUND' });
475
-
476
- const actor = await ctx.db.user.findUnique({
477
- where: { id: ctx.session.user.id },
478
- select: { name: true, email: true },
479
- });
480
- const actorName = actor?.name || actor?.email || 'A user';
481
-
482
- await notifyWorkspaceDeleted(ctx.db, {
483
- recipientUserIds: workspaceToDelete.members.map((m) => m.userId),
484
- actorUserId: ctx.session.user.id,
485
- actorName,
486
- workspaceId: workspaceToDelete.id,
487
- workspaceTitle: workspaceToDelete.title,
488
- });
489
-
490
- const deleted = await ctx.db.workspace.deleteMany({
491
- where: { id: input.id, ownerId: ctx.session.user.id },
492
- });
493
- if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
494
-
495
- await PusherService.emitLibraryUpdate(ctx.session.user.id);
97
+ .input(z.object({ id: z.string() }))
98
+ .mutation(({ ctx, input }) =>
99
+ new WorkspaceService(ctx.db).delete(ctx.session.user.id, input.id),
100
+ ),
496
101
 
497
- return true;
498
- }),
499
102
  getFolderInformation: authedProcedure
500
- .input(z.object({
501
- id: z.string(),
502
- }))
503
- .query(async ({ ctx, input }) => {
504
- const folder = await ctx.db.folder.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
505
- // find all of its parents
506
- if (!folder) throw new TRPCError({ code: 'NOT_FOUND' });
507
-
508
- const parents = [];
509
- let current = folder;
510
-
511
- while (current.parentId) {
512
- const parent = await ctx.db.folder.findFirst({ where: { id: current.parentId, ownerId: ctx.session.user.id } });
513
- if (!parent) break;
514
- parents.push(parent);
515
- current = parent;
516
- }
517
-
518
- return { folder, parents };
519
- }),
103
+ .input(z.object({ id: z.string() }))
104
+ .query(({ ctx, input }) =>
105
+ new WorkspaceService(ctx.db).getFolderInformation(ctx.session.user.id, input.id),
106
+ ),
520
107
 
521
108
  getSharedWith: authedProcedure
522
- .input(z.object({
523
- id: z.string(),
524
- }))
525
- .query(async ({ ctx, input }) => {
109
+ .input(z.object({ id: z.string() }))
110
+ .query(({ ctx, input }) =>
111
+ new WorkspaceService(ctx.db).getSharedWith(ctx.session.user.id, input.id),
112
+ ),
526
113
 
527
- const user = await ctx.db.user.findFirst({ where: { id: ctx.session.user.id } });
528
- if (!user || !user.email) throw new TRPCError({ code: 'NOT_FOUND' });
529
- const sharedWith = await ctx.db.workspace.findMany({ where: { members: { some: { userId: ctx.session.user.id } } } });
530
- const invitations = await ctx.db.workspaceInvitation.findMany({
531
- where: { email: user.email, acceptedAt: null }, include: {
532
- workspace: true,
533
- }
534
- });
535
-
536
- return { shared: sharedWith, invitations };
537
- }),
538
114
  uploadFiles: authedProcedure
539
- .input(z.object({
540
- id: z.string(),
541
- files: z.array(
542
- z.object({
543
- filename: z.string().min(1).max(255),
544
- contentType: z.string().min(1).max(100),
545
- size: z.number().min(1), // size in bytes
546
- })
547
- ),
548
- }))
549
- .mutation(async ({ ctx, input }) => {
550
- // ensure workspace belongs to user
551
- const ws = await ctx.db.workspace.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
552
- if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
553
-
554
- // Check storage limit
555
- const workspaces = await ctx.db.workspace.findMany({
556
- where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
557
- });
558
- const spaceUsed = await ctx.db.fileAsset.aggregate({
559
- where: { workspaceId: { in: workspaces.map((w: any) => w.id) }, userId: ctx.session.user.id },
560
- _sum: { size: true },
561
- });
562
- const storageLimit = await getUserStorageLimit(ctx.session.user.id);
563
- const totalSize = input.files.reduce((acc, file) => acc + file.size, 0);
564
- if ((spaceUsed._sum?.size ?? 0) + totalSize > storageLimit) {
565
- logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${totalSize}, Limit: ${storageLimit}`);
566
- throw new TRPCError({
567
- code: 'FORBIDDEN',
568
- message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
569
- });
570
- }
571
- const results = [];
572
-
573
- for (const file of input.files) {
574
- // 1. Insert into DB
575
- const record = await ctx.db.fileAsset.create({
576
- data: {
577
- userId: ctx.session.user.id,
578
- name: file.filename,
579
- mimeType: file.contentType,
580
- size: file.size,
581
- workspaceId: input.id,
582
- },
583
- });
584
-
585
- // 2. Generate signed URL for direct upload
586
- const objectKey = `${ctx.session.user.id}/${record.id}-${file.filename}`;
587
- const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
588
- .from('media')
589
- .createSignedUploadUrl(objectKey); // 5 minutes
590
-
591
- if (signedUrlError) {
592
- throw new TRPCError({
593
- code: 'INTERNAL_SERVER_ERROR',
594
- message: `Failed to upload file`
595
- });
596
- }
597
-
598
- // 3. Update record with bucket info
599
- await ctx.db.fileAsset.update({
600
- where: { id: record.id },
601
- data: {
602
- bucket: 'media',
603
- objectKey: objectKey,
604
- },
605
- });
606
-
607
- results.push({
608
- fileId: record.id,
609
- uploadUrl: signedUrlData.signedUrl,
610
- });
611
- }
612
-
613
- return results;
115
+ .input(
116
+ z.object({
117
+ id: z.string(),
118
+ files: z.array(
119
+ z.object({
120
+ filename: z.string().min(1).max(255),
121
+ contentType: z.string().min(1).max(100),
122
+ size: z.number().min(1),
123
+ }),
124
+ ),
125
+ }),
126
+ )
127
+ .mutation(({ ctx, input }) =>
128
+ new WorkspaceService(ctx.db).uploadFiles(ctx.session.user.id, input),
129
+ ),
614
130
 
615
- }),
616
131
  deleteFiles: authedProcedure
617
- .input(z.object({
618
- fileId: z.array(z.string()),
619
- id: z.string(),
620
- }))
621
- .mutation(async ({ ctx, input }) => {
622
- // ensure files are in the user's workspace
623
- const files = await ctx.db.fileAsset.findMany({
624
- where: {
625
- id: { in: input.fileId },
626
- workspaceId: input.id,
627
- userId: ctx.session.user.id,
628
- },
629
- });
630
- // Delete from Supabase Storage (best-effort)
631
- for (const file of files) {
632
- if (file.bucket && file.objectKey) {
633
- supabaseClient.storage
634
- .from(file.bucket)
635
- .remove([file.objectKey])
636
- .catch((err: unknown) => {
637
- logger.error(`Error deleting file ${file.objectKey} from bucket ${file.bucket}:`, err);
638
- });
639
- }
640
- }
132
+ .input(
133
+ z.object({
134
+ fileId: z.array(z.string()),
135
+ id: z.string(),
136
+ }),
137
+ )
138
+ .mutation(({ ctx, input }) =>
139
+ new WorkspaceService(ctx.db).deleteFiles(ctx.session.user.id, input),
140
+ ),
641
141
 
642
- await ctx.db.fileAsset.deleteMany({
643
- where: {
644
- id: { in: input.fileId },
645
- workspaceId: input.id,
646
- userId: ctx.session.user.id,
647
- },
648
- });
649
- return true;
650
- }),
651
142
  getFileUploadUrl: authedProcedure
652
- .input(z.object({
653
- workspaceId: z.string(),
654
- filename: z.string(),
655
- contentType: z.string(),
656
- size: z.number(),
657
- }))
658
- .query(async ({ ctx, input }) => {
659
- // Check storage limit
660
- const workspaces = await ctx.db.workspace.findMany({
661
- where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
662
- });
663
- const spaceUsed = await ctx.db.fileAsset.aggregate({
664
- where: { workspaceId: { in: workspaces.map((w: any) => w.id) }, userId: ctx.session.user.id },
665
- _sum: { size: true },
666
- });
667
- const storageLimit = await getUserStorageLimit(ctx.session.user.id);
668
- if ((spaceUsed._sum?.size ?? 0) + input.size > storageLimit) {
669
- logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${input.size}, Limit: ${storageLimit}`);
670
- throw new TRPCError({
671
- code: 'FORBIDDEN',
672
- message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
673
- });
674
- }
143
+ .input(
144
+ z.object({
145
+ workspaceId: z.string(),
146
+ filename: z.string(),
147
+ contentType: z.string(),
148
+ size: z.number(),
149
+ }),
150
+ )
151
+ .query(({ ctx, input }) =>
152
+ new WorkspaceService(ctx.db).getFileUploadUrl(ctx.session.user.id, input),
153
+ ),
675
154
 
676
- const objectKey = `workspace_${ctx.session.user.id}/${input.workspaceId}-file_${input.filename}`;
677
- const fileAsset = await ctx.db.fileAsset.create({
678
- data: {
679
- workspaceId: input.workspaceId,
680
- name: input.filename,
681
- mimeType: input.contentType,
682
- size: input.size,
683
- userId: ctx.session.user.id,
684
- bucket: 'media',
685
- objectKey: objectKey,
686
- },
687
- });
688
- const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
689
- .from('media')
690
- .createSignedUploadUrl(objectKey, { upsert: true });
691
- if (signedUrlError) {
692
- logger.error('Signed upload URL error:', signedUrlError);
693
- throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to create upload URL: ${signedUrlError.message}` });
694
- }
695
-
696
- await ctx.db.workspace.update({
697
- where: { id: input.workspaceId },
698
- data: { needsAnalysis: true },
699
- });
700
-
701
- return {
702
- fileId: fileAsset.id,
703
- uploadUrl: signedUrlData.signedUrl,
704
- };
705
- }),
706
155
  uploadAndAnalyzeMedia: verifiedProcedure
707
- .input(z.object({
708
- workspaceId: z.string(),
709
- files: z.array(z.object({
710
- id: z.string(),
711
- })),
712
- generateStudyGuide: z.boolean().default(true),
713
- generateFlashcards: z.boolean().default(true),
714
- generateWorksheet: z.boolean().default(true),
715
- }))
716
- .mutation(async ({ ctx, input }) => {
717
- // Verify workspace ownership
718
- const workspace = await ctx.db.workspace.findFirst({
719
- where: { id: input.workspaceId, ownerId: ctx.session.user.id }
720
- });
721
- if (!workspace) {
722
- logger.error('Workspace not found', { workspaceId: input.workspaceId, userId: ctx.session.user.id });
723
- throw new TRPCError({ code: 'NOT_FOUND' });
724
- }
725
-
726
- // Check if analysis is already in progress
727
- if (workspace.fileBeingAnalyzed) {
728
- throw new TRPCError({
729
- code: 'CONFLICT',
730
- message: 'File analysis is already in progress for this workspace. Please wait for it to complete.'
731
- });
732
- }
733
-
734
- // Fetch files from database
735
- const files = await ctx.db.fileAsset.findMany({
736
- where: {
737
- id: { in: input.files.map(file => file.id) },
738
- workspaceId: input.workspaceId,
739
- userId: ctx.session.user.id,
740
- },
741
- });
742
-
743
- if (files.length === 0) {
744
- throw new TRPCError({
745
- code: 'NOT_FOUND',
746
- message: 'No files found with the provided IDs'
747
- });
748
- }
749
-
750
- // Validate all files have bucket and objectKey
751
- for (const file of files) {
752
- if (!file.bucket || !file.objectKey) {
753
- throw new TRPCError({
754
- code: 'BAD_REQUEST',
755
- message: `File ${file.id} does not have bucket or objectKey set`
756
- });
757
- }
758
- }
759
-
760
- // Use the first file for progress tracking and artifact naming
761
- const primaryFile = files[0];
762
- const fileType = primaryFile.mimeType.startsWith('image/') ? 'image' : 'pdf';
763
- try {
764
- // Set analysis in progress flag
765
- await ctx.db.workspace.update({
766
- where: { id: input.workspaceId },
767
- data: { fileBeingAnalyzed: true },
768
- });
769
-
770
- const genConfig = { generateStudyGuide: input.generateStudyGuide, generateFlashcards: input.generateFlashcards };
771
-
772
- PusherService.emitAnalysisProgress(input.workspaceId,
773
- buildProgress('starting', primaryFile.name, fileType, 'fileUpload', 'pending', genConfig)
774
- );
775
-
776
- try {
777
- await updateAnalysisProgress(ctx.db, input.workspaceId,
778
- buildProgress('starting', primaryFile.name, fileType, 'fileUpload', 'pending', genConfig)
779
- );
780
- } catch (error) {
781
- logger.error('Failed to update analysis progress:', error);
782
- await ctx.db.workspace.update({
783
- where: { id: input.workspaceId },
784
- data: { fileBeingAnalyzed: false },
785
- });
786
- await PusherService.emitError(input.workspaceId, `Failed to update analysis progress: ${error}`, 'file_analysis');
787
- throw error;
788
- }
789
-
790
- await updateAnalysisProgress(ctx.db, input.workspaceId,
791
- buildProgress('uploading', primaryFile.name, fileType, 'fileUpload', 'in_progress', genConfig)
792
- );
793
-
794
- // Process all files using the new process_file endpoint
795
- for (const file of files) {
796
- // TypeScript: We already validated bucket and objectKey exist above
797
- if (!file.bucket || !file.objectKey) {
798
- continue; // Skip if somehow missing (shouldn't happen due to validation above)
799
- }
800
-
801
- const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
802
- .from(file.bucket)
803
- .createSignedUrl(file.objectKey, 24 * 60 * 60); // 24 hours expiry
804
-
805
- if (signedUrlError) {
806
- await ctx.db.workspace.update({
807
- where: { id: input.workspaceId },
808
- data: { fileBeingAnalyzed: false },
809
- });
810
- throw new TRPCError({
811
- code: 'INTERNAL_SERVER_ERROR',
812
- message: `Failed to upload file`
813
- });
814
- }
815
-
816
- const fileUrl = signedUrlData.signedUrl;
817
- const currentFileType = file.mimeType.startsWith('image/') ? 'image' : 'pdf';
818
-
819
- // Use maxPages for large PDFs (>50 pages) to limit processing
820
- const maxPages = currentFileType === 'pdf' && file.size && file.size > 50 ? 50 : undefined;
821
-
822
- const processResult = await aiSessionService.processFile(
823
- input.workspaceId,
824
- ctx.session.user.id,
825
- fileUrl,
826
- currentFileType,
827
- maxPages
828
- );
829
-
830
- if (processResult.status === 'error') {
831
- logger.error(`Failed to process file ${file.name}:`, processResult.error);
832
- // Continue processing other files even if one fails
833
- // Optionally, you could throw an error or mark this file as failed
834
- } else {
835
- logger.info(`Successfully processed file ${file.name}: ${processResult.pageCount} pages`);
156
+ .input(
157
+ z.object({
158
+ workspaceId: z.string(),
159
+ files: z.array(z.object({ id: z.string() })),
160
+ generateStudyGuide: z.boolean().default(true),
161
+ generateFlashcards: z.boolean().default(true),
162
+ generateWorksheet: z.boolean().default(true),
163
+ }),
164
+ )
165
+ .mutation(({ ctx, input }) =>
166
+ new MediaAnalysisService(ctx.db).uploadAndAnalyzeMedia(input, ctx.session.user.id),
167
+ ),
836
168
 
837
- // Store the comprehensive description in aiTranscription field
838
- await ctx.db.fileAsset.update({
839
- where: { id: file.id },
840
- data: {
841
- aiTranscription: {
842
- comprehensiveDescription: processResult.comprehensiveDescription,
843
- textContent: processResult.textContent,
844
- imageDescriptions: processResult.imageDescriptions,
845
- },
846
- }
847
- });
848
- }
849
- }
850
-
851
- await updateAnalysisProgress(ctx.db, input.workspaceId,
852
- buildProgress('analyzing', primaryFile.name, fileType, 'fileAnalysis', 'in_progress', genConfig)
853
- );
854
-
855
- try {
856
- // Analyze all files - use PDF analysis if any file is a PDF, otherwise use image analysis
857
- // const hasPDF = files.some(f => !f.mimeType.startsWith('image/'));
858
- // if (hasPDF) {
859
- // await aiSessionService.analysePDF(input.workspaceId, ctx.session.user.id, file.id);
860
- // } else {
861
- // // If all files are images, analyze them
862
- // for (const file of files) {
863
- // await aiSessionService.analyseImage(input.workspaceId, ctx.session.user.id, file.id);
864
- // }
865
- // }
866
-
867
- await updateAnalysisProgress(ctx.db, input.workspaceId,
868
- buildProgress('generating_artifacts', primaryFile.name, fileType, 'studyGuide', 'pending', genConfig)
869
- );
870
- } catch (error) {
871
- logger.error('Failed to analyze files:', error);
872
- await updateAnalysisProgress(ctx.db, input.workspaceId,
873
- buildProgress('error', primaryFile.name, fileType, 'fileAnalysis', 'error', genConfig, {
874
- error: `Failed to analyze ${fileType}: ${error}`,
875
- studyGuide: 'skipped', flashcards: 'skipped',
876
- })
877
- );
878
- await ctx.db.workspace.update({
879
- where: { id: input.workspaceId },
880
- data: { fileBeingAnalyzed: false },
881
- });
882
- throw error;
883
- }
884
-
885
- const results: {
886
- filename: string;
887
- artifacts: {
888
- studyGuide: any | null;
889
- flashcards: any | null;
890
- worksheet: any | null;
891
- };
892
- } = {
893
- filename: primaryFile.name,
894
- artifacts: {
895
- studyGuide: null,
896
- flashcards: null,
897
- worksheet: null,
898
- }
899
- };
900
-
901
- // Ensure AI session is initialized before generating artifacts
902
- try {
903
- await aiSessionService.initSession(input.workspaceId, ctx.session.user.id);
904
- } catch (initError) {
905
- logger.error('Failed to init AI session (continuing with workspace context):', initError);
906
- }
907
-
908
- // Fetch current usage and limits to enforce plan restrictions for auto-generation
909
- const [usage, limits] = await Promise.all([
910
- getUserUsage(ctx.session.user.id),
911
- getUserPlanLimits(ctx.session.user.id)
912
- ]);
913
-
914
- // Generate artifacts - each step is isolated so failures don't block subsequent steps
915
- if (input.generateStudyGuide) {
916
- // Enforcement: Skip if limit reached
917
- if (limits && usage.studyGuides >= limits.maxStudyGuides) {
918
- await updateAnalysisProgress(ctx.db, input.workspaceId,
919
- buildProgress('skipped', primaryFile.name, fileType, 'studyGuide', 'skipped', genConfig)
920
- );
921
- await PusherService.emitError(input.workspaceId, 'Study guide skipped: Limit reached.', 'study_guide');
922
- await notifyArtifactFailed(ctx.db, {
923
- userId: ctx.session.user.id,
924
- workspaceId: input.workspaceId,
925
- artifactType: ArtifactType.STUDY_GUIDE,
926
- message: 'Study guide was skipped because your plan limit was reached.',
927
- }).catch(() => {});
928
- } else {
929
- try {
930
- await updateAnalysisProgress(ctx.db, input.workspaceId,
931
- buildProgress('generating_study_guide', primaryFile.name, fileType, 'studyGuide', 'in_progress', genConfig)
932
- );
933
-
934
- const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
935
-
936
- let artifact = await ctx.db.artifact.findFirst({
937
- where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
938
- });
939
- if (!artifact) {
940
- artifact = await ctx.db.artifact.create({
941
- data: {
942
- workspaceId: input.workspaceId,
943
- type: ArtifactType.STUDY_GUIDE,
944
- title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
945
- createdById: ctx.session.user.id,
946
- },
947
- });
948
- }
949
-
950
- const lastVersion = await ctx.db.artifactVersion.findFirst({
951
- where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
952
- orderBy: { version: 'desc' },
953
- });
954
-
955
- await ctx.db.artifactVersion.create({
956
- data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
957
- });
958
-
959
- results.artifacts.studyGuide = artifact;
960
- await PusherService.emitStudyGuideComplete(input.workspaceId, artifact);
961
- await notifyArtifactReady(ctx.db, {
962
- userId: ctx.session.user.id,
963
- workspaceId: input.workspaceId,
964
- artifactId: artifact.id,
965
- artifactType: ArtifactType.STUDY_GUIDE,
966
- title: artifact.title,
967
- }).catch(() => {});
968
- } catch (sgError) {
969
- logger.error('Study guide generation failed after retries:', sgError);
970
- await PusherService.emitError(input.workspaceId, 'Study guide generation failed. Please try regenerating later.', 'study_guide');
971
- await notifyArtifactFailed(ctx.db, {
972
- userId: ctx.session.user.id,
973
- workspaceId: input.workspaceId,
974
- artifactType: ArtifactType.STUDY_GUIDE,
975
- message: 'Study guide generation failed. Please try regenerating later.',
976
- }).catch(() => {});
977
- // Continue to flashcards - don't abort the whole pipeline
978
- }
979
- }
980
- }
981
-
982
- if (input.generateFlashcards) {
983
- // Enforcement: Skip if limit reached
984
- if (limits && usage.flashcards >= limits.maxFlashcards) {
985
- await updateAnalysisProgress(ctx.db, input.workspaceId,
986
- buildProgress('skipped', primaryFile.name, fileType, 'flashcards', 'skipped', genConfig)
987
- );
988
- await PusherService.emitError(input.workspaceId, 'Flashcards skipped: Limit reached.', 'flashcards');
989
- await notifyArtifactFailed(ctx.db, {
990
- userId: ctx.session.user.id,
991
- workspaceId: input.workspaceId,
992
- artifactType: ArtifactType.FLASHCARD_SET,
993
- message: 'Flashcards were skipped because your plan limit was reached.',
994
- }).catch(() => {});
995
- } else {
996
- try {
997
- const sgStatus = input.generateStudyGuide ? (results.artifacts.studyGuide ? 'completed' : 'error') : 'skipped';
998
- await updateAnalysisProgress(ctx.db, input.workspaceId,
999
- buildProgress('generating_flashcards', primaryFile.name, fileType, 'flashcards', 'in_progress', genConfig,
1000
- { studyGuide: sgStatus } as any)
1001
- );
1002
-
1003
- const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
1004
-
1005
- const artifact = await ctx.db.artifact.create({
1006
- data: {
1007
- workspaceId: input.workspaceId,
1008
- type: ArtifactType.FLASHCARD_SET,
1009
- title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
1010
- createdById: ctx.session.user.id,
1011
- },
1012
- });
1013
-
1014
- // Parse JSON flashcard content
1015
- try {
1016
- const parsed = typeof content === 'string' ? JSON.parse(content) : content;
1017
- const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
1018
-
1019
- for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
1020
- const card = flashcardData[i];
1021
- const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
1022
- const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
1023
-
1024
- await ctx.db.flashcard.create({
1025
- data: {
1026
- artifactId: artifact.id,
1027
- front: front,
1028
- back: back,
1029
- order: i,
1030
- tags: ['ai-generated', 'medium'],
1031
- },
1032
- });
1033
- }
1034
- } catch (parseError) {
1035
- console.error("Failed to parse flashcard JSON or create cards in workspace router:", parseError);
1036
- // Fallback to text parsing if JSON fails
1037
- const lines = content.split('\n').filter((line: string) => line.trim());
1038
- for (let i = 0; i < Math.min(lines.length, 10); i++) {
1039
- const line = lines[i];
1040
- if (line.includes(' - ')) {
1041
- const [front, back] = line.split(' - ');
1042
- await ctx.db.flashcard.create({
1043
- data: {
1044
- artifactId: artifact.id,
1045
- front: front.trim(),
1046
- back: back.trim(),
1047
- order: i,
1048
- tags: ['ai-generated', 'medium'],
1049
- },
1050
- });
1051
- }
1052
- }
1053
- }
1054
-
1055
- results.artifacts.flashcards = artifact;
1056
- await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
1057
- await notifyArtifactReady(ctx.db, {
1058
- userId: ctx.session.user.id,
1059
- workspaceId: input.workspaceId,
1060
- artifactId: artifact.id,
1061
- artifactType: ArtifactType.FLASHCARD_SET,
1062
- title: artifact.title,
1063
- }).catch(() => {});
1064
- } catch (fcError) {
1065
- logger.error('Flashcard generation failed after retries:', fcError);
1066
- await PusherService.emitError(input.workspaceId, 'Flashcard generation failed. Please try regenerating later.', 'flashcards');
1067
- await notifyArtifactFailed(ctx.db, {
1068
- userId: ctx.session.user.id,
1069
- workspaceId: input.workspaceId,
1070
- artifactType: ArtifactType.FLASHCARD_SET,
1071
- message: 'Flashcard generation failed. Please try regenerating later.',
1072
- }).catch(() => {});
1073
- }
1074
- }
1075
- }
1076
-
1077
- await ctx.db.workspace.update({
1078
- where: { id: input.workspaceId },
1079
- data: { fileBeingAnalyzed: false },
1080
- });
1081
-
1082
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
1083
- ...buildProgress('completed', primaryFile.name, fileType, 'flashcards', 'completed', genConfig),
1084
- completedAt: new Date().toISOString(),
1085
- });
1086
- return results;
1087
- } catch (error) {
1088
- logger.error('Failed to update analysis progress:', error);
1089
- await ctx.db.workspace.update({
1090
- where: { id: input.workspaceId },
1091
- data: { fileBeingAnalyzed: false },
1092
- });
1093
- await PusherService.emitError(input.workspaceId, `Failed to update analysis progress: ${error}`, 'file_analysis');
1094
- throw error;
1095
- }
1096
- }),
1097
169
  search: authedProcedure
1098
- .input(z.object({
1099
- query: z.string(),
1100
- color: z.string().optional(),
1101
- limit: z.number().min(1).max(100).default(20),
1102
- }))
1103
- .query(async ({ ctx, input }) => {
1104
- const { query, color } = input;
1105
-
1106
- // 1. Search Workspaces
1107
- const workspaces = await ctx.db.workspace.findMany({
1108
- where: {
1109
- ownerId: ctx.session.user.id,
1110
- markerColor: color || undefined,
1111
- ...(query ? {
1112
- OR: [
1113
- { title: { contains: query, mode: 'insensitive' } },
1114
- { description: { contains: query, mode: 'insensitive' } },
1115
- ],
1116
- } : {}),
1117
- },
1118
- orderBy: { updatedAt: 'desc' },
1119
- take: input.limit,
1120
- });
1121
-
1122
- // 2. Search Folders
1123
- const folders = await ctx.db.folder.findMany({
1124
- where: {
1125
- ownerId: ctx.session.user.id,
1126
- markerColor: color || undefined,
1127
- ...(query ? {
1128
- name: { contains: query, mode: 'insensitive' },
1129
- } : {}),
1130
- },
1131
- orderBy: { updatedAt: 'desc' },
1132
- take: input.limit,
1133
- });
1134
-
1135
- // Combined results with type discriminator
1136
- const results = [
1137
- ...workspaces.map((w: any) => ({ ...w, type: 'workspace' as const })),
1138
- ...folders.map((f: any) => ({ ...f, type: 'folder' as const, title: f.name })), // normalize name to title
1139
- ].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
1140
- .slice(0, input.limit);
1141
-
1142
- return results;
1143
- }),
170
+ .input(
171
+ z.object({
172
+ query: z.string(),
173
+ color: z.string().optional(),
174
+ limit: z.number().min(1).max(100).default(20),
175
+ }),
176
+ )
177
+ .query(({ ctx, input }) => new WorkspaceService(ctx.db).search(ctx.session.user.id, input)),
1144
178
 
1145
- // Members sub-router
1146
179
  members,
1147
180
  });