@intelligo-dev/core 1.0.0-beta.1 → 1.0.0-beta.13

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 (355) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +102 -0
  3. package/dist/attachments/errors.d.ts +7 -0
  4. package/dist/attachments/errors.d.ts.map +1 -0
  5. package/dist/attachments/errors.js +11 -0
  6. package/dist/attachments/errors.js.map +1 -0
  7. package/dist/attachments/index.d.ts +8 -0
  8. package/dist/attachments/index.d.ts.map +1 -0
  9. package/dist/attachments/index.js +7 -0
  10. package/dist/attachments/index.js.map +1 -0
  11. package/dist/attachments/service.d.ts +45 -0
  12. package/dist/attachments/service.d.ts.map +1 -0
  13. package/dist/attachments/service.js +100 -0
  14. package/dist/attachments/service.js.map +1 -0
  15. package/dist/attachments/types.d.ts +6 -0
  16. package/dist/attachments/types.d.ts.map +1 -0
  17. package/dist/attachments/types.js +2 -0
  18. package/dist/attachments/types.js.map +1 -0
  19. package/dist/conversations/errors.d.ts +2 -8
  20. package/dist/conversations/errors.d.ts.map +1 -1
  21. package/dist/conversations/errors.js +2 -8
  22. package/dist/conversations/errors.js.map +1 -1
  23. package/dist/conversations/index.d.ts +4 -12
  24. package/dist/conversations/index.d.ts.map +1 -1
  25. package/dist/conversations/index.js +2 -11
  26. package/dist/conversations/index.js.map +1 -1
  27. package/dist/conversations/service.d.ts +49 -61
  28. package/dist/conversations/service.d.ts.map +1 -1
  29. package/dist/conversations/service.js +110 -83
  30. package/dist/conversations/service.js.map +1 -1
  31. package/dist/conversations/types.d.ts +1 -4
  32. package/dist/conversations/types.d.ts.map +1 -1
  33. package/dist/conversations/types.js +0 -3
  34. package/dist/conversations/types.js.map +1 -1
  35. package/dist/db/client.d.ts +21 -10
  36. package/dist/db/client.d.ts.map +1 -1
  37. package/dist/db/client.js +43 -18
  38. package/dist/db/client.js.map +1 -1
  39. package/dist/db/index.d.ts +3 -3
  40. package/dist/db/index.d.ts.map +1 -1
  41. package/dist/db/index.js +3 -7
  42. package/dist/db/index.js.map +1 -1
  43. package/dist/db/schema/agents.d.ts +14 -51
  44. package/dist/db/schema/agents.d.ts.map +1 -1
  45. package/dist/db/schema/agents.js +9 -21
  46. package/dist/db/schema/agents.js.map +1 -1
  47. package/dist/db/schema/ai.d.ts +86 -1556
  48. package/dist/db/schema/ai.d.ts.map +1 -1
  49. package/dist/db/schema/ai.js +32 -236
  50. package/dist/db/schema/ai.js.map +1 -1
  51. package/dist/db/schema/auth.d.ts +5 -34
  52. package/dist/db/schema/auth.d.ts.map +1 -1
  53. package/dist/db/schema/auth.js +25 -56
  54. package/dist/db/schema/auth.js.map +1 -1
  55. package/dist/db/schema/billing.d.ts +136 -107
  56. package/dist/db/schema/billing.d.ts.map +1 -1
  57. package/dist/db/schema/billing.js +72 -60
  58. package/dist/db/schema/billing.js.map +1 -1
  59. package/dist/db/schema/feature-flags.d.ts +3 -12
  60. package/dist/db/schema/feature-flags.d.ts.map +1 -1
  61. package/dist/db/schema/feature-flags.js +3 -12
  62. package/dist/db/schema/feature-flags.js.map +1 -1
  63. package/dist/db/schema/identity.d.ts +12 -274
  64. package/dist/db/schema/identity.d.ts.map +1 -1
  65. package/dist/db/schema/identity.js +19 -104
  66. package/dist/db/schema/identity.js.map +1 -1
  67. package/dist/db/schema/index.d.ts +8 -15
  68. package/dist/db/schema/index.d.ts.map +1 -1
  69. package/dist/db/schema/index.js +12 -42
  70. package/dist/db/schema/index.js.map +1 -1
  71. package/dist/db/schema/notifications.d.ts +3 -17
  72. package/dist/db/schema/notifications.d.ts.map +1 -1
  73. package/dist/db/schema/notifications.js +3 -17
  74. package/dist/db/schema/notifications.js.map +1 -1
  75. package/dist/db/schema/usage.d.ts +94 -77
  76. package/dist/db/schema/usage.d.ts.map +1 -1
  77. package/dist/db/schema/usage.js +59 -89
  78. package/dist/db/schema/usage.js.map +1 -1
  79. package/dist/db/validate.d.ts +2 -11
  80. package/dist/db/validate.d.ts.map +1 -1
  81. package/dist/db/validate.js +6 -17
  82. package/dist/db/validate.js.map +1 -1
  83. package/dist/db/workspace-queries.d.ts +5 -29
  84. package/dist/db/workspace-queries.d.ts.map +1 -1
  85. package/dist/db/workspace-queries.js +5 -29
  86. package/dist/db/workspace-queries.js.map +1 -1
  87. package/dist/documents/classifier.d.ts +5 -12
  88. package/dist/documents/classifier.d.ts.map +1 -1
  89. package/dist/documents/classifier.js +16 -16
  90. package/dist/documents/classifier.js.map +1 -1
  91. package/dist/documents/errors.d.ts +2 -10
  92. package/dist/documents/errors.d.ts.map +1 -1
  93. package/dist/documents/errors.js +2 -10
  94. package/dist/documents/errors.js.map +1 -1
  95. package/dist/documents/index.d.ts +4 -13
  96. package/dist/documents/index.d.ts.map +1 -1
  97. package/dist/documents/index.js +3 -12
  98. package/dist/documents/index.js.map +1 -1
  99. package/dist/documents/service.d.ts +19 -26
  100. package/dist/documents/service.d.ts.map +1 -1
  101. package/dist/documents/service.js +44 -38
  102. package/dist/documents/service.js.map +1 -1
  103. package/dist/documents/types.d.ts +6 -10
  104. package/dist/documents/types.d.ts.map +1 -1
  105. package/dist/documents/types.js +0 -3
  106. package/dist/documents/types.js.map +1 -1
  107. package/dist/email/brand.d.ts +17 -0
  108. package/dist/email/brand.d.ts.map +1 -0
  109. package/dist/email/brand.js +9 -0
  110. package/dist/email/brand.js.map +1 -0
  111. package/dist/email/index.d.ts +4 -4
  112. package/dist/email/index.js +4 -4
  113. package/dist/email/provider.d.ts +1 -7
  114. package/dist/email/provider.d.ts.map +1 -1
  115. package/dist/email/provider.js +10 -19
  116. package/dist/email/provider.js.map +1 -1
  117. package/dist/email/send.d.ts +12 -12
  118. package/dist/email/send.d.ts.map +1 -1
  119. package/dist/email/send.js +5 -19
  120. package/dist/email/send.js.map +1 -1
  121. package/dist/email/senders.d.ts +17 -30
  122. package/dist/email/senders.d.ts.map +1 -1
  123. package/dist/email/senders.js +33 -47
  124. package/dist/email/senders.js.map +1 -1
  125. package/dist/email/templates/base-layout.d.ts +1 -1
  126. package/dist/email/templates/base-layout.d.ts.map +1 -1
  127. package/dist/email/templates/base-layout.js +4 -6
  128. package/dist/email/templates/base-layout.js.map +1 -1
  129. package/dist/email/templates/index.d.ts +10 -10
  130. package/dist/email/templates/index.d.ts.map +1 -1
  131. package/dist/email/templates/index.js +10 -16
  132. package/dist/email/templates/index.js.map +1 -1
  133. package/dist/email/templates/password-reset.d.ts +2 -2
  134. package/dist/email/templates/password-reset.d.ts.map +1 -1
  135. package/dist/email/templates/password-reset.js +10 -22
  136. package/dist/email/templates/password-reset.js.map +1 -1
  137. package/dist/email/templates/payment-failed.d.ts +2 -2
  138. package/dist/email/templates/payment-failed.d.ts.map +1 -1
  139. package/dist/email/templates/payment-failed.js +11 -24
  140. package/dist/email/templates/payment-failed.js.map +1 -1
  141. package/dist/email/templates/quota-warning.d.ts +4 -4
  142. package/dist/email/templates/quota-warning.d.ts.map +1 -1
  143. package/dist/email/templates/quota-warning.js +19 -39
  144. package/dist/email/templates/quota-warning.js.map +1 -1
  145. package/dist/email/templates/subscription-confirmed.d.ts +2 -2
  146. package/dist/email/templates/subscription-confirmed.d.ts.map +1 -1
  147. package/dist/email/templates/subscription-confirmed.js +13 -28
  148. package/dist/email/templates/subscription-confirmed.js.map +1 -1
  149. package/dist/email/templates/trial-expiry.d.ts +2 -2
  150. package/dist/email/templates/trial-expiry.d.ts.map +1 -1
  151. package/dist/email/templates/trial-expiry.js +12 -36
  152. package/dist/email/templates/trial-expiry.js.map +1 -1
  153. package/dist/email/templates/trial-warning.d.ts +2 -2
  154. package/dist/email/templates/trial-warning.d.ts.map +1 -1
  155. package/dist/email/templates/trial-warning.js +15 -32
  156. package/dist/email/templates/trial-warning.js.map +1 -1
  157. package/dist/email/templates/verify-email.d.ts +2 -2
  158. package/dist/email/templates/verify-email.d.ts.map +1 -1
  159. package/dist/email/templates/verify-email.js +10 -22
  160. package/dist/email/templates/verify-email.js.map +1 -1
  161. package/dist/email/templates/welcome.d.ts +2 -2
  162. package/dist/email/templates/welcome.d.ts.map +1 -1
  163. package/dist/email/templates/welcome.js +13 -20
  164. package/dist/email/templates/welcome.js.map +1 -1
  165. package/dist/email/templates/workspace-invitation.d.ts +2 -2
  166. package/dist/email/templates/workspace-invitation.d.ts.map +1 -1
  167. package/dist/email/templates/workspace-invitation.js +12 -26
  168. package/dist/email/templates/workspace-invitation.js.map +1 -1
  169. package/dist/env.d.ts +5 -10
  170. package/dist/env.d.ts.map +1 -1
  171. package/dist/env.js +41 -31
  172. package/dist/env.js.map +1 -1
  173. package/dist/identity/audit.d.ts +11 -24
  174. package/dist/identity/audit.d.ts.map +1 -1
  175. package/dist/identity/audit.js +13 -27
  176. package/dist/identity/audit.js.map +1 -1
  177. package/dist/identity/errors.d.ts +4 -12
  178. package/dist/identity/errors.d.ts.map +1 -1
  179. package/dist/identity/errors.js +2 -8
  180. package/dist/identity/errors.js.map +1 -1
  181. package/dist/identity/index.d.ts +5 -13
  182. package/dist/identity/index.d.ts.map +1 -1
  183. package/dist/identity/index.js +4 -12
  184. package/dist/identity/index.js.map +1 -1
  185. package/dist/identity/service.d.ts +13 -47
  186. package/dist/identity/service.d.ts.map +1 -1
  187. package/dist/identity/service.js +31 -78
  188. package/dist/identity/service.js.map +1 -1
  189. package/dist/identity/types.d.ts +1 -4
  190. package/dist/identity/types.d.ts.map +1 -1
  191. package/dist/identity/types.js +0 -3
  192. package/dist/identity/types.js.map +1 -1
  193. package/dist/index.d.ts +1 -2
  194. package/dist/index.d.ts.map +1 -1
  195. package/dist/index.js +3 -10
  196. package/dist/index.js.map +1 -1
  197. package/dist/logger.d.ts +7 -18
  198. package/dist/logger.d.ts.map +1 -1
  199. package/dist/logger.js +45 -49
  200. package/dist/logger.js.map +1 -1
  201. package/dist/money.d.ts +95 -0
  202. package/dist/money.d.ts.map +1 -0
  203. package/dist/money.js +208 -0
  204. package/dist/money.js.map +1 -0
  205. package/dist/notifications/index.d.ts +9 -43
  206. package/dist/notifications/index.d.ts.map +1 -1
  207. package/dist/notifications/index.js +9 -49
  208. package/dist/notifications/index.js.map +1 -1
  209. package/dist/notifications/triggers.d.ts +9 -32
  210. package/dist/notifications/triggers.d.ts.map +1 -1
  211. package/dist/notifications/triggers.js +21 -58
  212. package/dist/notifications/triggers.js.map +1 -1
  213. package/dist/notifications/types.d.ts +1 -10
  214. package/dist/notifications/types.d.ts.map +1 -1
  215. package/dist/notifications/types.js +0 -9
  216. package/dist/notifications/types.js.map +1 -1
  217. package/dist/prompt.d.ts +38 -0
  218. package/dist/prompt.d.ts.map +1 -0
  219. package/dist/prompt.js +118 -0
  220. package/dist/prompt.js.map +1 -0
  221. package/dist/registry.d.ts +32 -0
  222. package/dist/registry.d.ts.map +1 -0
  223. package/dist/registry.js +60 -0
  224. package/dist/registry.js.map +1 -0
  225. package/dist/request-context.d.ts +49 -0
  226. package/dist/request-context.d.ts.map +1 -0
  227. package/dist/request-context.js +100 -0
  228. package/dist/request-context.js.map +1 -0
  229. package/dist/storage/index.d.ts +65 -0
  230. package/dist/storage/index.d.ts.map +1 -0
  231. package/dist/storage/index.js +109 -0
  232. package/dist/storage/index.js.map +1 -0
  233. package/package.json +74 -24
  234. package/src/attachments/errors.ts +18 -0
  235. package/src/attachments/index.ts +22 -0
  236. package/src/attachments/service.ts +178 -0
  237. package/src/attachments/types.ts +6 -0
  238. package/src/conversations/errors.ts +45 -0
  239. package/src/conversations/index.ts +39 -0
  240. package/src/conversations/service.ts +594 -0
  241. package/src/conversations/types.ts +15 -0
  242. package/src/db/client.ts +91 -0
  243. package/src/db/index.ts +3 -0
  244. package/src/db/migrations/0000_baseline.sql +652 -0
  245. package/src/db/migrations/0001_reconcile_chain_built_databases.sql +53 -0
  246. package/src/db/migrations/0002_webhook_claim_lease.sql +1 -0
  247. package/src/db/migrations/README.md +74 -36
  248. package/src/db/migrations/legacy-chain.json +197 -0
  249. package/src/db/migrations/meta/0000_snapshot.json +4182 -157
  250. package/src/db/migrations/meta/0002_snapshot.json +2478 -368
  251. package/src/db/migrations/meta/_journal.json +6 -279
  252. package/src/db/schema/agents.ts +57 -0
  253. package/src/db/schema/ai.ts +174 -0
  254. package/src/db/schema/auth.ts +183 -0
  255. package/src/db/schema/billing.ts +209 -0
  256. package/src/db/schema/feature-flags.ts +20 -0
  257. package/src/db/schema/identity.ts +250 -0
  258. package/src/db/schema/index.ts +12 -0
  259. package/src/db/schema/notifications.ts +31 -0
  260. package/src/db/schema/usage.ts +269 -0
  261. package/src/db/validate.ts +63 -0
  262. package/src/db/workspace-queries.ts +64 -0
  263. package/src/documents/classifier.ts +61 -0
  264. package/src/documents/errors.ts +44 -0
  265. package/src/documents/index.ts +24 -0
  266. package/src/documents/service.ts +256 -0
  267. package/src/documents/types.ts +24 -0
  268. package/src/email/brand.ts +24 -0
  269. package/src/email/index.ts +19 -0
  270. package/src/email/provider.ts +278 -0
  271. package/src/email/send.ts +169 -0
  272. package/src/email/senders.ts +292 -0
  273. package/src/email/templates/base-layout.tsx +145 -0
  274. package/src/email/templates/index.ts +28 -0
  275. package/src/email/templates/password-reset.tsx +72 -0
  276. package/src/email/templates/payment-failed.tsx +77 -0
  277. package/src/email/templates/quota-warning.tsx +118 -0
  278. package/src/email/templates/subscription-confirmed.tsx +91 -0
  279. package/src/email/templates/trial-expiry.tsx +124 -0
  280. package/src/email/templates/trial-warning.tsx +104 -0
  281. package/src/email/templates/verify-email.tsx +72 -0
  282. package/src/email/templates/welcome.tsx +64 -0
  283. package/src/email/templates/workspace-invitation.tsx +105 -0
  284. package/src/env.ts +180 -0
  285. package/src/identity/audit.ts +65 -0
  286. package/src/identity/errors.ts +44 -0
  287. package/src/identity/index.ts +26 -0
  288. package/src/identity/service.ts +202 -0
  289. package/src/identity/types.ts +28 -0
  290. package/src/index.ts +4 -0
  291. package/src/logger.ts +198 -0
  292. package/src/money.ts +270 -0
  293. package/src/notifications/index.ts +108 -0
  294. package/src/notifications/triggers.ts +158 -0
  295. package/src/notifications/types.ts +18 -0
  296. package/src/prompt.ts +142 -0
  297. package/src/registry.ts +75 -0
  298. package/src/request-context.ts +137 -0
  299. package/src/storage/index.ts +153 -0
  300. package/dist/db/schema/rag.d.ts +0 -171
  301. package/dist/db/schema/rag.d.ts.map +0 -1
  302. package/dist/db/schema/rag.js +0 -42
  303. package/dist/db/schema/rag.js.map +0 -1
  304. package/src/db/migrations/0000_sharp_night_nurse.sql +0 -52
  305. package/src/db/migrations/0001_hesitant_vapor.sql +0 -40
  306. package/src/db/migrations/0002_numerous_lady_vermin.sql +0 -235
  307. package/src/db/migrations/0003_mighty_jean_grey.sql +0 -37
  308. package/src/db/migrations/0004_cynical_grandmaster.sql +0 -2
  309. package/src/db/migrations/0005_careful_switch.sql +0 -8
  310. package/src/db/migrations/0006_tense_blue_marvel.sql +0 -16
  311. package/src/db/migrations/0007_next_squadron_sinister.sql +0 -1
  312. package/src/db/migrations/0008_powerful_scream.sql +0 -39
  313. package/src/db/migrations/0009_sad_ink.sql +0 -1
  314. package/src/db/migrations/0010_shocking_omega_flight.sql +0 -2
  315. package/src/db/migrations/0011_ambiguous_otto_octavius.sql +0 -151
  316. package/src/db/migrations/0011_hnsw_vector_index.sql +0 -17
  317. package/src/db/migrations/0012_user_profiles.sql +0 -30
  318. package/src/db/migrations/0013_trending_seasonal_columns.sql +0 -9
  319. package/src/db/migrations/0014_add_product_type_to_agents.sql +0 -6
  320. package/src/db/migrations/0015_new_v1_tables.sql +0 -149
  321. package/src/db/migrations/0016_document_types.sql +0 -30
  322. package/src/db/migrations/0017_document_metadata.sql +0 -2
  323. package/src/db/migrations/0018_identity_graph.sql +0 -200
  324. package/src/db/migrations/0019_db_driven_prompts.sql +0 -20
  325. package/src/db/migrations/0020_pending_extractions.sql +0 -35
  326. package/src/db/migrations/0021_rag_documents.sql +0 -39
  327. package/src/db/migrations/0022_user_quotas_usage_jsonb.sql +0 -70
  328. package/src/db/migrations/0023_user_facts_user_scoped_unique.sql +0 -17
  329. package/src/db/migrations/0024_user_memories_hnsw.sql +0 -23
  330. package/src/db/migrations/0025_audit_trigger_errcode.sql +0 -21
  331. package/src/db/migrations/0026_user_profile_snapshots_composite_pk.sql +0 -17
  332. package/src/db/migrations/0027_billing_settings_mnt_columns.sql +0 -54
  333. package/src/db/migrations/0028_trial_credits_normalized_email.sql +0 -37
  334. package/src/db/migrations/0029_usage_records_conversation_idx.sql +0 -13
  335. package/src/db/migrations/0030_pending_extractions_composite_idx.sql +0 -11
  336. package/src/db/migrations/0031_rag_documents_hnsw.sql +0 -16
  337. package/src/db/migrations/0032_rate_limit_unique_idx.sql +0 -16
  338. package/src/db/migrations/0033_pending_extractions_partial_idx.sql +0 -18
  339. package/src/db/migrations/0034_rate_limit_count.sql +0 -9
  340. package/src/db/migrations/0035_credit_reservations.sql +0 -24
  341. package/src/db/migrations/0036_executions_audit_jobs.sql +0 -96
  342. package/src/db/migrations/0037_rate_limit_minute_bucket.sql +0 -46
  343. package/src/db/migrations/0038_user_quotas_generic_usage.sql +0 -25
  344. package/src/db/migrations/0039_platform_admin_impersonation.sql +0 -27
  345. package/src/db/migrations/0040_ee_audit_chain.sql +0 -23
  346. package/src/db/migrations/meta/0001_snapshot.json +0 -631
  347. package/src/db/migrations/meta/0003_snapshot.json +0 -2488
  348. package/src/db/migrations/meta/0004_snapshot.json +0 -2500
  349. package/src/db/migrations/meta/0005_snapshot.json +0 -2548
  350. package/src/db/migrations/meta/0006_snapshot.json +0 -2650
  351. package/src/db/migrations/meta/0007_snapshot.json +0 -2657
  352. package/src/db/migrations/meta/0008_snapshot.json +0 -2719
  353. package/src/db/migrations/meta/0009_snapshot.json +0 -2726
  354. package/src/db/migrations/meta/0010_snapshot.json +0 -2739
  355. package/src/db/migrations/meta/0011_snapshot.json +0 -3886
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Server-only persistence for AI-generated documents.
3
+ *
4
+ * Callers pass a resolved actor (workspaceId, userId); core cannot depend on
5
+ * `@intelligo-dev/auth`. Every query filters by both ids — the caller is
6
+ * never trusted to have done it. Failures throw `DocumentServiceError`.
7
+ */
8
+
9
+ import { and, desc, eq, gt } from "drizzle-orm";
10
+ import { db } from "../db";
11
+ import { documents } from "../db/schema";
12
+ import { classifyDocumentTitle, isProductDocument } from "./classifier";
13
+ import { DocumentServiceError } from "./errors";
14
+ import type { DocumentActor, DocumentFilter, DocumentListItem } from "./types";
15
+
16
+ type DocumentRow = {
17
+ id: string;
18
+ title: string;
19
+ kind: string;
20
+ createdAt: Date;
21
+ content: string | null;
22
+ metadata?: unknown;
23
+ };
24
+
25
+ /**
26
+ * `metadata` is untyped `jsonb` written by whoever saved the document,
27
+ * so it is read defensively: anything that is not a non-empty string
28
+ * is no link at all.
29
+ */
30
+ function conversationIdOf(metadata: unknown): string | null {
31
+ if (!metadata || typeof metadata !== "object") return null;
32
+ const value = (metadata as { conversationId?: unknown }).conversationId;
33
+ return typeof value === "string" && value.length > 0 ? value : null;
34
+ }
35
+
36
+ function toListItem(doc: DocumentRow): DocumentListItem {
37
+ return {
38
+ id: doc.id,
39
+ title: doc.title,
40
+ kind: doc.kind,
41
+ createdAt: doc.createdAt.toISOString(),
42
+ agentLabel: classifyDocumentTitle(doc.title).agentLabel,
43
+ content: doc.content,
44
+ conversationId: conversationIdOf(doc.metadata),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * The latest version of each of the actor's documents, newest first.
50
+ */
51
+ export async function getUserDocuments(
52
+ actor: DocumentActor,
53
+ filter?: DocumentFilter
54
+ ): Promise<DocumentListItem[]> {
55
+ const uniqueDocs = await db
56
+ .selectDistinctOn([documents.id])
57
+ .from(documents)
58
+ .where(
59
+ and(
60
+ eq(documents.workspaceId, actor.workspaceId),
61
+ eq(documents.userId, actor.userId)
62
+ )
63
+ )
64
+ .orderBy(documents.id, desc(documents.createdAt));
65
+
66
+ let result: DocumentListItem[] = uniqueDocs.map(toListItem);
67
+
68
+ const filterType = filter?.type ?? "all";
69
+ if (filterType !== "all") {
70
+ result =
71
+ filterType === "report"
72
+ ? result.filter((doc) => isProductDocument(doc.title))
73
+ : result.filter((doc) => doc.kind === filterType);
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * The latest version of one document, scoped to the actor.
81
+ * Throws DocumentServiceError("not_found") if no such document exists.
82
+ */
83
+ export async function getDocument(
84
+ actor: DocumentActor,
85
+ id: string
86
+ ): Promise<DocumentListItem> {
87
+ const [doc] = await db
88
+ .selectDistinctOn([documents.id])
89
+ .from(documents)
90
+ .where(
91
+ and(
92
+ eq(documents.id, id),
93
+ eq(documents.workspaceId, actor.workspaceId),
94
+ eq(documents.userId, actor.userId)
95
+ )
96
+ )
97
+ .orderBy(documents.id, desc(documents.createdAt));
98
+
99
+ if (!doc) {
100
+ throw new DocumentServiceError("not_found", "Document not found");
101
+ }
102
+
103
+ return toListItem(doc);
104
+ }
105
+
106
+ /**
107
+ * Every version of a document, newest first — what a canvas version
108
+ * picker walks. Throws ("not_found") when the actor has no such id.
109
+ */
110
+ export async function getDocumentVersions(
111
+ actor: DocumentActor,
112
+ id: string
113
+ ): Promise<DocumentListItem[]> {
114
+ const rows = await db
115
+ .select()
116
+ .from(documents)
117
+ .where(
118
+ and(
119
+ eq(documents.id, id),
120
+ eq(documents.workspaceId, actor.workspaceId),
121
+ eq(documents.userId, actor.userId)
122
+ )
123
+ )
124
+ .orderBy(desc(documents.createdAt));
125
+
126
+ if (rows.length === 0) {
127
+ throw new DocumentServiceError("not_found", "Document not found");
128
+ }
129
+ return rows.map(toListItem);
130
+ }
131
+
132
+ /**
133
+ * Saves a document as a new version.
134
+ * Throws DocumentServiceError("forbidden") if a document with this id
135
+ * already exists in the workspace under a different user.
136
+ */
137
+ export async function saveDocument(
138
+ actor: DocumentActor,
139
+ params: {
140
+ id: string;
141
+ title: string;
142
+ content: string;
143
+ kind: string;
144
+ /**
145
+ * The conversation this was written in, stored on `metadata` so
146
+ * the artifacts page can link back to it. Optional: a document
147
+ * saved by a job or an import belongs to no conversation.
148
+ */
149
+ conversationId?: string;
150
+ }
151
+ ): Promise<DocumentListItem> {
152
+ const existingDocs = await db
153
+ .select()
154
+ .from(documents)
155
+ .where(
156
+ and(
157
+ eq(documents.id, params.id),
158
+ eq(documents.workspaceId, actor.workspaceId)
159
+ )
160
+ );
161
+
162
+ if (existingDocs.length > 0) {
163
+ const firstDoc = existingDocs[0];
164
+ if (firstDoc && firstDoc.userId !== actor.userId) {
165
+ throw new DocumentServiceError(
166
+ "forbidden",
167
+ "Document belongs to a different user"
168
+ );
169
+ }
170
+ }
171
+
172
+ // Every save writes a new version row, so an edit that does not resend
173
+ // the conversation link would drop it. Carry the known one forward.
174
+ const conversationId =
175
+ params.conversationId ??
176
+ existingDocs.map((doc) => conversationIdOf(doc.metadata)).find(Boolean) ??
177
+ null;
178
+
179
+ const [newDocument] = await db
180
+ .insert(documents)
181
+ .values({
182
+ id: params.id,
183
+ userId: actor.userId,
184
+ workspaceId: actor.workspaceId,
185
+ title: params.title,
186
+ content: params.content,
187
+ kind: params.kind,
188
+ createdAt: new Date(),
189
+ metadata: conversationId ? { conversationId } : null,
190
+ })
191
+ .returning();
192
+
193
+ if (!newDocument) {
194
+ throw new DocumentServiceError("database_error", "Failed to save document");
195
+ }
196
+
197
+ return toListItem(newDocument);
198
+ }
199
+
200
+ const MAX_DELETE_WINDOW_DAYS = 30;
201
+
202
+ /**
203
+ * Delete all versions of a document created after `timestamp`.
204
+ * Throws DocumentServiceError("invalid_input") for an unparsable
205
+ * timestamp or one older than the delete window, and ("not_found") if
206
+ * no document exists at that id within the actor's scope.
207
+ */
208
+ export async function deleteDocumentVersions(
209
+ actor: DocumentActor,
210
+ id: string,
211
+ timestamp: string
212
+ ): Promise<DocumentListItem[]> {
213
+ const parsedTimestamp = new Date(timestamp);
214
+ if (Number.isNaN(parsedTimestamp.getTime())) {
215
+ throw new DocumentServiceError("invalid_input", "Invalid timestamp");
216
+ }
217
+
218
+ const oldestAllowed = new Date(
219
+ Date.now() - MAX_DELETE_WINDOW_DAYS * 24 * 60 * 60 * 1000
220
+ );
221
+ if (parsedTimestamp < oldestAllowed) {
222
+ throw new DocumentServiceError(
223
+ "invalid_input",
224
+ `timestamp must be within the last ${MAX_DELETE_WINDOW_DAYS} days`
225
+ );
226
+ }
227
+
228
+ const existingDocs = await db
229
+ .select()
230
+ .from(documents)
231
+ .where(
232
+ and(
233
+ eq(documents.id, id),
234
+ eq(documents.workspaceId, actor.workspaceId),
235
+ eq(documents.userId, actor.userId)
236
+ )
237
+ );
238
+
239
+ if (existingDocs.length === 0) {
240
+ throw new DocumentServiceError("not_found", "Document not found");
241
+ }
242
+
243
+ const deletedDocs = await db
244
+ .delete(documents)
245
+ .where(
246
+ and(
247
+ eq(documents.id, id),
248
+ eq(documents.workspaceId, actor.workspaceId),
249
+ eq(documents.userId, actor.userId),
250
+ gt(documents.createdAt, parsedTimestamp)
251
+ )
252
+ )
253
+ .returning();
254
+
255
+ return deletedDocs.map(toListItem);
256
+ }
@@ -0,0 +1,24 @@
1
+ /** Resolved actor identity — every query is scoped to this pair. */
2
+ export type DocumentActor = {
3
+ workspaceId: string;
4
+ userId: string;
5
+ };
6
+
7
+ export type DocumentFilter = {
8
+ type?: "all" | "report" | "text" | "code" | "sheet" | "image";
9
+ };
10
+
11
+ export type DocumentListItem = {
12
+ id: string;
13
+ title: string;
14
+ kind: string;
15
+ createdAt: string; // ISO string, so it serializes to the client
16
+ agentLabel: string;
17
+ content: string | null;
18
+ /**
19
+ * The conversation this document was written in, when known. Read from
20
+ * `metadata.conversationId` rather than a column: a document may also be
21
+ * written from a job or an import.
22
+ */
23
+ conversationId: string | null;
24
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Whose emails these are. Every deployment sends as itself, so the name
3
+ * and the support address come from its environment, never from the
4
+ * framework:
5
+ *
6
+ * - `APP_NAME` — the product's name. Falls back to the display name in
7
+ * `EMAIL_FROM` ("Acme <noreply@acme.com>" → "Acme").
8
+ * - `SUPPORT_EMAIL` — where a reader can write back. Without it the
9
+ * footer offers no address rather than someone else's.
10
+ */
11
+ export type EmailBrand = {
12
+ /** Empty when neither variable names one. */
13
+ name: string;
14
+ supportEmail: string | null;
15
+ };
16
+
17
+ export function emailBrand(): EmailBrand {
18
+ const from = process.env.EMAIL_FROM?.trim() ?? "";
19
+ const display = /^"?([^"<]+?)"?\s*</.exec(from)?.[1]?.trim() ?? "";
20
+ return {
21
+ name: process.env.APP_NAME?.trim() || display,
22
+ supportEmail: process.env.SUPPORT_EMAIL?.trim() || null,
23
+ };
24
+ }
@@ -0,0 +1,19 @@
1
+ export {
2
+ sendEmail,
3
+ type SendEmailParams,
4
+ type SendEmailWithComponentParams,
5
+ type SendEmailResult,
6
+ } from "./send";
7
+ export {
8
+ getEmailProvider,
9
+ resetEmailProviderCache,
10
+ type EmailProvider,
11
+ type EmailProviderName,
12
+ type EmailSendParams,
13
+ type EmailTemplateRef,
14
+ ResendProvider,
15
+ LoopsProvider,
16
+ ConsoleProvider,
17
+ } from "./provider";
18
+ export * from "./templates";
19
+ export * from "./senders";
@@ -0,0 +1,278 @@
1
+ import { Resend } from "resend";
2
+
3
+ // The provider is chosen at runtime from EMAIL_PROVIDER or the API keys set:
4
+ // - ResendProvider sends the rendered React Email HTML.
5
+ // - LoopsProvider sends by template id (Loops accepts no raw HTML).
6
+ // - ConsoleProvider is the development fallback when no key is set.
7
+
8
+ /**
9
+ * Provider-neutral template descriptor attached to every framework email.
10
+ *
11
+ * HTML-capable providers (Resend) ignore it — they send the rendered HTML.
12
+ * Template-based providers (Loops) require it: `key` is resolved to the
13
+ * provider-side template id and `variables` become the template's dynamic
14
+ * data. Keys are stable framework identifiers, e.g. "verify-email".
15
+ */
16
+ export interface EmailTemplateRef {
17
+ key: string;
18
+ variables?: Record<string, string | number>;
19
+ }
20
+
21
+ export interface EmailSendParams {
22
+ to: string | string[];
23
+ subject: string;
24
+ html: string;
25
+ from?: string;
26
+ replyTo?: string;
27
+ template?: EmailTemplateRef;
28
+ }
29
+
30
+ export interface EmailProvider {
31
+ send(params: EmailSendParams): Promise<{ id: string }>;
32
+ }
33
+
34
+ /** Error with an HTTP-ish status code so send.ts can decide retryability. */
35
+ function statusError(message: string, statusCode: number): Error {
36
+ const err = new Error(message) as Error & { statusCode?: number };
37
+ err.statusCode = statusCode;
38
+ return err;
39
+ }
40
+
41
+ export class ResendProvider implements EmailProvider {
42
+ private client: Resend;
43
+
44
+ constructor(apiKey: string) {
45
+ this.client = new Resend(apiKey);
46
+ }
47
+
48
+ async send(params: EmailSendParams): Promise<{ id: string }> {
49
+ // A sender is an address on a domain the deployment verified with
50
+ // Resend, which only the deployment knows.
51
+ if (!params.from) {
52
+ throw statusError(
53
+ `No sender for "${params.subject}" — set EMAIL_FROM to an address on a domain verified with Resend, e.g. "Acme <noreply@acme.com>"`,
54
+ 400
55
+ );
56
+ }
57
+ const { data, error } = await this.client.emails.send({
58
+ from: params.from,
59
+ to: Array.isArray(params.to) ? params.to : [params.to],
60
+ subject: params.subject,
61
+ html: params.html,
62
+ replyTo: params.replyTo,
63
+ });
64
+
65
+ if (error) {
66
+ const err = new Error(error.message) as Error & { statusCode?: number };
67
+ // Resend error names map to HTTP status categories
68
+ if (
69
+ error.name === "validation_error" ||
70
+ error.name === "missing_required_field"
71
+ ) {
72
+ err.statusCode = 400;
73
+ }
74
+ throw err;
75
+ }
76
+
77
+ return { id: data?.id ?? "unknown" };
78
+ }
79
+ }
80
+
81
+ // Loops transactional emails are designed in the Loops dashboard and sent by
82
+ // template id — the API accepts no raw HTML and exactly one recipient per
83
+ // call. Template keys resolve to ids through (in order):
84
+ // 1. the `transactionalIds` map passed to the constructor
85
+ // 2. env: LOOPS_TRANSACTIONAL_ID_<KEY> (key upper-snaked,
86
+ // e.g. "verify-email" → LOOPS_TRANSACTIONAL_ID_VERIFY_EMAIL)
87
+ // `from`/`replyTo` are ignored — the sender is configured per template in
88
+ // Loops itself.
89
+
90
+ const LOOPS_API_URL = "https://app.loops.so/api/v1/transactional";
91
+
92
+ export class LoopsProvider implements EmailProvider {
93
+ constructor(
94
+ private apiKey: string,
95
+ private transactionalIds?: Record<string, string>
96
+ ) {}
97
+
98
+ private resolveTransactionalId(key: string): string | undefined {
99
+ const mapped = this.transactionalIds?.[key];
100
+ if (mapped) {
101
+ return mapped;
102
+ }
103
+ const envName = `LOOPS_TRANSACTIONAL_ID_${key
104
+ .toUpperCase()
105
+ .replace(/-/g, "_")}`;
106
+ return process.env[envName];
107
+ }
108
+
109
+ async send(params: EmailSendParams): Promise<{ id: string }> {
110
+ const template = params.template;
111
+ if (!template) {
112
+ throw statusError(
113
+ `Loops requires a template key (subject: "${params.subject}") — Loops cannot send raw HTML. Use a framework sender or pass { template }.`,
114
+ 400
115
+ );
116
+ }
117
+
118
+ const transactionalId = this.resolveTransactionalId(template.key);
119
+ if (!transactionalId) {
120
+ throw statusError(
121
+ `No Loops transactional id mapped for template "${template.key}". Set LOOPS_TRANSACTIONAL_ID_${template.key
122
+ .toUpperCase()
123
+ .replace(
124
+ /-/g,
125
+ "_"
126
+ )} or pass a transactionalIds map to LoopsProvider.`,
127
+ 400
128
+ );
129
+ }
130
+
131
+ const recipients = Array.isArray(params.to) ? params.to : [params.to];
132
+
133
+ // Loops accepts one recipient per request. Sequential, so a failure
134
+ // surfaces with normal retry semantics in send.ts.
135
+ for (const email of recipients) {
136
+ const response = await fetch(LOOPS_API_URL, {
137
+ method: "POST",
138
+ headers: {
139
+ Authorization: `Bearer ${this.apiKey}`,
140
+ "Content-Type": "application/json",
141
+ },
142
+ body: JSON.stringify({
143
+ transactionalId,
144
+ email,
145
+ dataVariables: template.variables ?? {},
146
+ }),
147
+ });
148
+
149
+ if (!response.ok) {
150
+ let detail = "";
151
+ try {
152
+ const body = (await response.json()) as {
153
+ message?: string;
154
+ error?: { message?: string };
155
+ };
156
+ detail = body.message ?? body.error?.message ?? "";
157
+ } catch {
158
+ // Non-JSON error body — status code alone is enough
159
+ }
160
+ throw statusError(
161
+ `Loops send failed (${response.status}) for template "${template.key}"${detail ? `: ${detail}` : ""}`,
162
+ response.status
163
+ );
164
+ }
165
+ }
166
+
167
+ // Loops returns { success: true } with no message id
168
+ return { id: `loops:${transactionalId}` };
169
+ }
170
+ }
171
+
172
+ export class ConsoleProvider implements EmailProvider {
173
+ async send(params: EmailSendParams): Promise<{ id: string }> {
174
+ const recipient = Array.isArray(params.to)
175
+ ? params.to.join(", ")
176
+ : params.to;
177
+
178
+ console.log(
179
+ `[Email] Would send to: ${recipient}, subject: ${params.subject}`
180
+ );
181
+
182
+ return { id: `console-${Date.now()}` };
183
+ }
184
+ }
185
+
186
+ export type EmailProviderName = "resend" | "loops" | "console";
187
+
188
+ let cachedProvider: EmailProvider | null = null;
189
+
190
+ /** Test-only: clear the cached provider so env changes take effect. */
191
+ export function resetEmailProviderCache(): void {
192
+ cachedProvider = null;
193
+ }
194
+
195
+ /**
196
+ * The email provider for this environment, cached after the first call.
197
+ *
198
+ * Priority:
199
+ * 1. EMAIL_PROVIDER env var → explicit selection (with API key validation)
200
+ * 2. RESEND_API_KEY → ResendProvider (auto-detect; preferred because it can
201
+ * send every framework email without per-template setup)
202
+ * 3. LOOPS_API_KEY → LoopsProvider (auto-detect; needs
203
+ * LOOPS_TRANSACTIONAL_ID_* mappings for the templates you use)
204
+ * 4. (none) → ConsoleProvider
205
+ */
206
+ export function getEmailProvider(): EmailProvider {
207
+ if (cachedProvider) {
208
+ return cachedProvider;
209
+ }
210
+
211
+ const explicit = process.env.EMAIL_PROVIDER?.toLowerCase();
212
+
213
+ if (explicit) {
214
+ cachedProvider = resolveExplicitProvider(explicit);
215
+ if (cachedProvider) {
216
+ return cachedProvider;
217
+ }
218
+ // Unknown value — warn and fall through to auto-detect
219
+ console.warn(
220
+ `[Email] Unknown EMAIL_PROVIDER="${process.env.EMAIL_PROVIDER}", falling back to auto-detect`
221
+ );
222
+ }
223
+
224
+ // Auto-detect from the API keys set
225
+ const resendKey = process.env.RESEND_API_KEY;
226
+ if (resendKey) {
227
+ console.log("[Email] Using Resend provider");
228
+ cachedProvider = new ResendProvider(resendKey);
229
+ return cachedProvider;
230
+ }
231
+
232
+ const loopsKey = process.env.LOOPS_API_KEY;
233
+ if (loopsKey) {
234
+ console.log("[Email] Using Loops provider");
235
+ cachedProvider = new LoopsProvider(loopsKey);
236
+ return cachedProvider;
237
+ }
238
+
239
+ console.log("[Email] Using console provider (no API key set)");
240
+ cachedProvider = new ConsoleProvider();
241
+ return cachedProvider;
242
+ }
243
+
244
+ /**
245
+ * Resolve an explicit EMAIL_PROVIDER value to a provider instance.
246
+ * Returns null for unknown values so the caller can fall through to auto-detect.
247
+ */
248
+ function resolveExplicitProvider(provider: string): EmailProvider | null {
249
+ switch (provider) {
250
+ case "resend": {
251
+ const key = process.env.RESEND_API_KEY;
252
+ if (!key) {
253
+ console.warn(
254
+ "[Email] EMAIL_PROVIDER=resend but RESEND_API_KEY is not set, falling back to console"
255
+ );
256
+ return new ConsoleProvider();
257
+ }
258
+ console.log("[Email] Using Resend provider (explicit)");
259
+ return new ResendProvider(key);
260
+ }
261
+ case "loops": {
262
+ const key = process.env.LOOPS_API_KEY;
263
+ if (!key) {
264
+ console.warn(
265
+ "[Email] EMAIL_PROVIDER=loops but LOOPS_API_KEY is not set, falling back to console"
266
+ );
267
+ return new ConsoleProvider();
268
+ }
269
+ console.log("[Email] Using Loops provider (explicit)");
270
+ return new LoopsProvider(key);
271
+ }
272
+ case "console":
273
+ console.log("[Email] Using console provider (explicit)");
274
+ return new ConsoleProvider();
275
+ default:
276
+ return null;
277
+ }
278
+ }