@aglyn/plugins-ai 1.0.0-beta.218 → 1.0.0-beta.220

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 (311) hide show
  1. package/README.md +2 -2
  2. package/package.json +18 -18
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/activity/ai-activity-actions.js +8 -1
  6. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  7. package/src/lib/billing/ai-month-meter.d.ts +51 -0
  8. package/src/lib/billing/ai-month-meter.js +89 -0
  9. package/src/lib/billing/ai-month-meter.js.map +1 -0
  10. package/src/lib/billing/ai-overage-close.d.ts +11 -33
  11. package/src/lib/billing/ai-overage-close.js +4 -3
  12. package/src/lib/billing/ai-overage-close.js.map +1 -1
  13. package/src/lib/billing/ai-overage-cutover.d.ts +28 -12
  14. package/src/lib/billing/ai-overage-cutover.js +1 -28
  15. package/src/lib/billing/ai-overage-cutover.js.map +1 -1
  16. package/src/lib/billing/ai-overage-ledger.js +1 -1
  17. package/src/lib/billing/ai-overage-ledger.js.map +1 -1
  18. package/src/lib/billing/ai-overage-standing.js +1 -1
  19. package/src/lib/billing/ai-overage-standing.js.map +1 -1
  20. package/src/lib/components/ai-allotment-editor.component.js +1 -1
  21. package/src/lib/components/ai-allotment-editor.component.js.map +1 -1
  22. package/src/lib/components/ai-assist-provider.component.js +5 -4
  23. package/src/lib/components/ai-assist-provider.component.js.map +1 -1
  24. package/src/lib/components/ai-automation-zones.d.ts +63 -0
  25. package/src/lib/components/ai-automation-zones.js +31 -0
  26. package/src/lib/components/ai-automation-zones.js.map +1 -0
  27. package/src/lib/components/ai-brief-dialog.component.js +3 -3
  28. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  29. package/src/lib/components/ai-describe-automation.component.d.ts +1 -1
  30. package/src/lib/components/ai-describe-automation.component.js.map +1 -1
  31. package/src/lib/components/ai-describe-button.component.d.ts +6 -3
  32. package/src/lib/components/ai-describe-button.component.js +4 -1
  33. package/src/lib/components/ai-describe-button.component.js.map +1 -1
  34. package/src/lib/components/ai-experiment-cards.component.js +1 -1
  35. package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
  36. package/src/lib/components/ai-explain-automation.component.d.ts +1 -1
  37. package/src/lib/components/ai-explain-automation.component.js.map +1 -1
  38. package/src/lib/components/ai-job-plan.component.d.ts +8 -0
  39. package/src/lib/components/ai-job-plan.component.js +21 -4
  40. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  41. package/src/lib/components/ai-permissions-on-host.component.d.ts +12 -2
  42. package/src/lib/components/ai-permissions-on-host.component.js +12 -3
  43. package/src/lib/components/ai-permissions-on-host.component.js.map +1 -1
  44. package/src/lib/components/ai-product-copy-card.component.d.ts +1 -1
  45. package/src/lib/components/ai-product-copy-card.component.js.map +1 -1
  46. package/src/lib/components/ai-product-import-option.component.d.ts +1 -1
  47. package/src/lib/components/ai-product-import-option.component.js.map +1 -1
  48. package/src/lib/components/ai-product-zones.d.ts +124 -0
  49. package/src/lib/components/ai-product-zones.js +32 -0
  50. package/src/lib/components/ai-product-zones.js.map +1 -0
  51. package/src/lib/components/ai-products-hub-card.component.d.ts +1 -1
  52. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  53. package/src/lib/components/ai-products-proposals.component.d.ts +1 -1
  54. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  55. package/src/lib/components/ai-seo-audit-card.component.js +35 -92
  56. package/src/lib/components/ai-seo-audit-card.component.js.map +1 -1
  57. package/src/lib/components/ai-seo-fields-card.component.js.map +1 -1
  58. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  59. package/src/lib/components/assist-jobs-drawer.component.js +21 -15
  60. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  61. package/src/lib/components/assist-panel.component.js +2 -2
  62. package/src/lib/components/assist-panel.component.js.map +1 -1
  63. package/src/lib/components/assist-signals-page.component.js +2 -2
  64. package/src/lib/components/assist-signals-page.component.js.map +1 -1
  65. package/src/lib/components/billing-ai-allotments.component.js +1 -1
  66. package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
  67. package/src/lib/components/billing-assist-overage-card.component.js +1 -1
  68. package/src/lib/components/billing-assist-overage-card.component.js.map +1 -1
  69. package/src/lib/constants.d.ts +5 -0
  70. package/src/lib/constants.js +4 -0
  71. package/src/lib/constants.js.map +1 -1
  72. package/src/lib/declarations.js +2 -0
  73. package/src/lib/declarations.js.map +1 -1
  74. package/src/lib/declarations.server.js +24 -10
  75. package/src/lib/declarations.server.js.map +1 -1
  76. package/src/lib/insights/ai-dataset-catalog.d.ts +49 -0
  77. package/src/lib/insights/ai-dataset-catalog.js +57 -0
  78. package/src/lib/insights/ai-dataset-catalog.js.map +1 -0
  79. package/src/lib/insights/ai-figure-readers.d.ts +6 -39
  80. package/src/lib/insights/ai-figure-readers.js +2 -391
  81. package/src/lib/insights/ai-figure-readers.js.map +1 -1
  82. package/src/lib/insights/ai-insight-digest.d.ts +10 -1
  83. package/src/lib/insights/ai-insight-digest.js +16 -4
  84. package/src/lib/insights/ai-insight-digest.js.map +1 -1
  85. package/src/lib/insights/ai-insight-readers.d.ts +1 -1
  86. package/src/lib/insights/ai-insight-readers.js.map +1 -1
  87. package/src/lib/jobs/ai-email-bindings.d.ts +28 -21
  88. package/src/lib/jobs/ai-email-bindings.js +75 -42
  89. package/src/lib/jobs/ai-email-bindings.js.map +1 -1
  90. package/src/lib/jobs/ai-job-campaign-step.d.ts +0 -2
  91. package/src/lib/jobs/ai-job-campaign-step.js +5 -10
  92. package/src/lib/jobs/ai-job-campaign-step.js.map +1 -1
  93. package/src/lib/jobs/ai-job-component-step.d.ts +6 -1
  94. package/src/lib/jobs/ai-job-component-step.js +32 -9
  95. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  96. package/src/lib/jobs/ai-job-draft-ids.js +11 -0
  97. package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
  98. package/src/lib/jobs/ai-job-drafts.js +1 -1
  99. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  100. package/src/lib/jobs/ai-job-email-step.d.ts +5 -3
  101. package/src/lib/jobs/ai-job-email-step.js +11 -4
  102. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  103. package/src/lib/jobs/ai-job-experiment-step.d.ts +1 -1
  104. package/src/lib/jobs/ai-job-experiment-step.js +1 -1
  105. package/src/lib/jobs/ai-job-experiment-step.js.map +1 -1
  106. package/src/lib/jobs/ai-job-form-step.d.ts +17 -1
  107. package/src/lib/jobs/ai-job-form-step.js +60 -8
  108. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  109. package/src/lib/jobs/ai-job-generation.d.ts +22 -1
  110. package/src/lib/jobs/ai-job-generation.js +63 -3
  111. package/src/lib/jobs/ai-job-generation.js.map +1 -1
  112. package/src/lib/jobs/ai-job-insight-step.js +1 -1
  113. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  114. package/src/lib/jobs/ai-job-layout-step.d.ts +15 -4
  115. package/src/lib/jobs/ai-job-layout-step.js +46 -13
  116. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  117. package/src/lib/jobs/ai-job-page-sections.d.ts +4 -2
  118. package/src/lib/jobs/ai-job-page-sections.js +7 -4
  119. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  120. package/src/lib/jobs/ai-job-page-step.js +7 -2
  121. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  122. package/src/lib/jobs/ai-job-plan-conformance.d.ts +5 -0
  123. package/src/lib/jobs/ai-job-plan-conformance.js +40 -1
  124. package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
  125. package/src/lib/jobs/ai-job-plan-creations.d.ts +80 -0
  126. package/src/lib/jobs/ai-job-plan-creations.js +199 -0
  127. package/src/lib/jobs/ai-job-plan-creations.js.map +1 -0
  128. package/src/lib/jobs/ai-job-plan-step.d.ts +11 -2
  129. package/src/lib/jobs/ai-job-plan-step.js +49 -23
  130. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  131. package/src/lib/jobs/ai-job-plugin-drafts.d.ts +14 -6
  132. package/src/lib/jobs/ai-job-plugin-drafts.js +12 -4
  133. package/src/lib/jobs/ai-job-plugin-drafts.js.map +1 -1
  134. package/src/lib/jobs/ai-job-products-step.d.ts +7 -0
  135. package/src/lib/jobs/ai-job-products-step.js +54 -32
  136. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  137. package/src/lib/jobs/ai-job-seo-step.d.ts +3 -24
  138. package/src/lib/jobs/ai-job-seo-step.js +35 -108
  139. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  140. package/src/lib/jobs/ai-job-site-step.d.ts +2 -0
  141. package/src/lib/jobs/ai-job-site-step.js +6 -6
  142. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  143. package/src/lib/jobs/ai-job-template-step.d.ts +9 -6
  144. package/src/lib/jobs/ai-job-template-step.js +73 -15
  145. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  146. package/src/lib/jobs/ai-job-theme-step.d.ts +1 -1
  147. package/src/lib/jobs/ai-job-theme-step.js +1 -1
  148. package/src/lib/jobs/ai-job-theme-step.js.map +1 -1
  149. package/src/lib/jobs/ai-job-workflow-step.js +17 -9
  150. package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
  151. package/src/lib/jobs/ai-jobs.js +1 -1
  152. package/src/lib/jobs/ai-jobs.js.map +1 -1
  153. package/src/lib/jobs/ai-workflow-records.d.ts +20 -11
  154. package/src/lib/jobs/ai-workflow-records.js +80 -43
  155. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  156. package/src/lib/model/ai-allotments.d.ts +27 -6
  157. package/src/lib/model/ai-allotments.js +54 -14
  158. package/src/lib/model/ai-allotments.js.map +1 -1
  159. package/src/lib/model/ai-automation-outline.d.ts +21 -2
  160. package/src/lib/model/ai-automation-outline.js.map +1 -1
  161. package/src/lib/model/ai-build-plan.d.ts +57 -0
  162. package/src/lib/model/ai-build-plan.js +116 -7
  163. package/src/lib/model/ai-build-plan.js.map +1 -1
  164. package/src/lib/model/ai-job-creations.d.ts +55 -0
  165. package/src/lib/model/ai-job-creations.js +85 -0
  166. package/src/lib/model/ai-job-creations.js.map +1 -0
  167. package/src/lib/model/ai-page-job.d.ts +3 -2
  168. package/src/lib/model/ai-page-job.js +4 -3
  169. package/src/lib/model/ai-page-job.js.map +1 -1
  170. package/src/lib/model/ai-seo.d.ts +11 -51
  171. package/src/lib/model/ai-seo.js +0 -17
  172. package/src/lib/model/ai-seo.js.map +1 -1
  173. package/src/lib/model/ai-site-inventory.js +1 -1
  174. package/src/lib/model/ai-site-inventory.js.map +1 -1
  175. package/src/lib/model/ai-site-job.d.ts +4 -2
  176. package/src/lib/model/ai-site-job.js +8 -4
  177. package/src/lib/model/ai-site-job.js.map +1 -1
  178. package/src/lib/model/ai-template-subjects.d.ts +10 -0
  179. package/src/lib/model/ai-template-subjects.js +16 -2
  180. package/src/lib/model/ai-template-subjects.js.map +1 -1
  181. package/src/lib/model/ai-theme-proposal.d.ts +1 -1
  182. package/src/lib/model/ai-theme-proposal.js +1 -1
  183. package/src/lib/model/ai-theme-proposal.js.map +1 -1
  184. package/src/lib/model/ai-tokens.js +1 -1
  185. package/src/lib/model/ai-tokens.js.map +1 -1
  186. package/src/lib/model/ai-usage-by-user.js +1 -1
  187. package/src/lib/model/ai-usage-by-user.js.map +1 -1
  188. package/src/lib/model/ai-workflow-job.d.ts +1 -3
  189. package/src/lib/model/ai-workflow-job.js +1 -2
  190. package/src/lib/model/ai-workflow-job.js.map +1 -1
  191. package/src/lib/plan-entitlements.d.ts +149 -0
  192. package/src/lib/plan-entitlements.js +168 -0
  193. package/src/lib/plan-entitlements.js.map +1 -0
  194. package/src/lib/plugin.js +1 -1
  195. package/src/lib/plugin.js.map +1 -1
  196. package/src/lib/providers/catalog.js +1 -1
  197. package/src/lib/providers/catalog.js.map +1 -1
  198. package/src/lib/providers/model-choice.js +1 -1
  199. package/src/lib/providers/model-choice.js.map +1 -1
  200. package/src/lib/runtime/ai-device-audit.d.ts +1 -1
  201. package/src/lib/runtime/ai-device-audit.js +1 -1
  202. package/src/lib/runtime/ai-device-audit.js.map +1 -1
  203. package/src/lib/runtime/ai-doctrine-validators.d.ts +25 -4
  204. package/src/lib/runtime/ai-doctrine-validators.js +178 -21
  205. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  206. package/src/lib/runtime/ai-doctrine.js +5 -5
  207. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  208. package/src/lib/runtime/ai-eval-live.js +30 -15
  209. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  210. package/src/lib/runtime/ai-eval.d.ts +9 -2
  211. package/src/lib/runtime/ai-eval.js +29 -2
  212. package/src/lib/runtime/ai-eval.js.map +1 -1
  213. package/src/lib/runtime/ai-gate.js +1 -1
  214. package/src/lib/runtime/ai-gate.js.map +1 -1
  215. package/src/lib/runtime/ai-node-tree.js +3 -3
  216. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  217. package/src/lib/runtime/ai-palette.d.ts +7 -1
  218. package/src/lib/runtime/ai-palette.generated.d.ts +1 -1
  219. package/src/lib/runtime/ai-palette.generated.js +16 -13
  220. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  221. package/src/lib/runtime/ai-palette.js +7 -1
  222. package/src/lib/runtime/ai-palette.js.map +1 -1
  223. package/src/lib/runtime/seo-audit.d.ts +13 -63
  224. package/src/lib/runtime/seo-audit.js +22 -207
  225. package/src/lib/runtime/seo-audit.js.map +1 -1
  226. package/src/lib/runtime/site-inventory.js +29 -23
  227. package/src/lib/runtime/site-inventory.js.map +1 -1
  228. package/src/lib/server/ai-admin-org.js +1 -1
  229. package/src/lib/server/ai-admin-org.js.map +1 -1
  230. package/src/lib/server/ai-admin-orgs-spend.js +1 -1
  231. package/src/lib/server/ai-admin-orgs-spend.js.map +1 -1
  232. package/src/lib/server/ai-admin-signals.js +1 -1
  233. package/src/lib/server/ai-admin-signals.js.map +1 -1
  234. package/src/lib/server/ai-allotments.js +1 -1
  235. package/src/lib/server/ai-allotments.js.map +1 -1
  236. package/src/lib/server/ai-assist-prompts.js +3 -3
  237. package/src/lib/server/ai-assist-prompts.js.map +1 -1
  238. package/src/lib/server/ai-assist.js +2 -2
  239. package/src/lib/server/ai-assist.js.map +1 -1
  240. package/src/lib/server/assist-chat.d.ts +1 -1
  241. package/src/lib/server/assist-chat.js +13 -12
  242. package/src/lib/server/assist-chat.js.map +1 -1
  243. package/src/lib/server/assist-view-context.js +23 -23
  244. package/src/lib/server/assist-view-context.js.map +1 -1
  245. package/src/lib/server/billing-credits.d.ts +5 -0
  246. package/src/lib/server/billing-credits.js +8 -2
  247. package/src/lib/server/billing-credits.js.map +1 -1
  248. package/src/lib/server/billing-overage.js +2 -2
  249. package/src/lib/server/billing-overage.js.map +1 -1
  250. package/src/lib/server.d.ts +1 -0
  251. package/src/lib/server.js.map +1 -1
  252. package/src/lib/testing/stand-in-automation-index.d.ts +20 -0
  253. package/src/lib/testing/stand-in-automation-index.js +102 -0
  254. package/src/lib/testing/stand-in-automation-index.js.map +1 -0
  255. package/src/lib/testing/stand-in-dataset-index.d.ts +20 -0
  256. package/src/lib/testing/stand-in-dataset-index.js +80 -0
  257. package/src/lib/testing/stand-in-dataset-index.js.map +1 -0
  258. package/src/lib/testing/stand-in-list-send-time.d.ts +4 -0
  259. package/src/lib/testing/stand-in-list-send-time.js +77 -0
  260. package/src/lib/testing/stand-in-list-send-time.js.map +1 -0
  261. package/src/lib/testing/stand-in-pipeline-index.d.ts +32 -0
  262. package/src/lib/testing/stand-in-pipeline-index.js +59 -0
  263. package/src/lib/testing/stand-in-pipeline-index.js.map +1 -0
  264. package/src/lib/testing/stand-in-product-index.d.ts +20 -0
  265. package/src/lib/testing/stand-in-product-index.js +105 -0
  266. package/src/lib/testing/stand-in-product-index.js.map +1 -0
  267. package/src/lib/tools/ai-component-tool.js +2 -2
  268. package/src/lib/tools/ai-component-tool.js.map +1 -1
  269. package/src/lib/tools/ai-duplicate-resource-tool.js +1 -1
  270. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  271. package/src/lib/tools/ai-seo-tool.d.ts +6 -18
  272. package/src/lib/tools/ai-seo-tool.js +7 -38
  273. package/src/lib/tools/ai-seo-tool.js.map +1 -1
  274. package/src/lib/tools/ai-workflow-tool.d.ts +1 -1
  275. package/src/lib/tools/ai-workflow-tool.js +1 -1
  276. package/src/lib/tools/ai-workflow-tool.js.map +1 -1
  277. package/src/lib/usage/ai-allotment-alerts.d.ts +10 -7
  278. package/src/lib/usage/ai-allotment-alerts.js +39 -15
  279. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
  280. package/src/lib/usage/ai-quota-checks.d.ts +37 -0
  281. package/src/lib/usage/ai-quota-checks.js +69 -0
  282. package/src/lib/usage/ai-quota-checks.js.map +1 -0
  283. package/src/lib/usage/ai-usage-alerts.js +22 -10
  284. package/src/lib/usage/ai-usage-alerts.js.map +1 -1
  285. package/src/lib/usage/ai-usage-by-user.js +1 -1
  286. package/src/lib/usage/ai-usage-by-user.js.map +1 -1
  287. package/src/lib/usage/assist-ceiling.d.ts +2 -2
  288. package/src/lib/usage/assist-ceiling.js +4 -4
  289. package/src/lib/usage/assist-ceiling.js.map +1 -1
  290. package/src/lib/usage/assist-credits.d.ts +494 -0
  291. package/src/lib/usage/assist-credits.js +524 -0
  292. package/src/lib/usage/assist-credits.js.map +1 -0
  293. package/src/lib/usage/assist-free-taste.d.ts +1 -1
  294. package/src/lib/usage/assist-free-taste.js +3 -2
  295. package/src/lib/usage/assist-free-taste.js.map +1 -1
  296. package/src/lib/usage/assist-signal-mining.js +1 -1
  297. package/src/lib/usage/assist-signal-mining.js.map +1 -1
  298. package/src/lib/usage/assist-spend-guards.d.ts +153 -0
  299. package/src/lib/usage/assist-spend-guards.js +182 -0
  300. package/src/lib/usage/assist-spend-guards.js.map +1 -0
  301. package/src/lib/usage/assist-usage.d.ts +1 -1
  302. package/src/lib/usage/assist-usage.js +1 -1
  303. package/src/lib/usage/assist-usage.js.map +1 -1
  304. package/src/lib/usage/staff-org-ai.js +1 -1
  305. package/src/lib/usage/staff-org-ai.js.map +1 -1
  306. package/src/lib/usage-axes.d.ts +49 -0
  307. package/src/lib/usage-axes.js +108 -0
  308. package/src/lib/usage-axes.js.map +1 -0
  309. package/src/lib/runtime/seo-page-facts.d.ts +0 -61
  310. package/src/lib/runtime/seo-page-facts.js +0 -150
  311. package/src/lib/runtime/seo-page-facts.js.map +0 -1
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
18
+ import { usageNotificationLevel } from "@aglyn/aglyn/app-utils/notifications";
18
19
  import { sendEmail } from "@aglyn/shared-util-email";
19
20
  import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
20
21
  import { meterPlatformEmail } from "@aglyn/tenant-data-admin/server/email-metering";
@@ -51,9 +52,18 @@ const DEFAULT_CHANNELS = {
51
52
  return true;
52
53
  });
53
54
  }
55
+ /** The name a site's managers know it by, then its address, then its id. */ async function siteName(firestore, hostId) {
56
+ var _host_get, _host_get1;
57
+ const host = await firestore.collection('hosts').doc(hostId).get();
58
+ return String((_host_get = host.get('displayName')) != null ? _host_get : '').trim() || String((_host_get1 = host.get('subdomain')) != null ? _host_get1 : '').trim() || hostId;
59
+ }
54
60
  async function recipientsFor(firestore, orgId, standing, reachable) {
61
+ var _org_get, _org_get1;
55
62
  const orgRef = firestore.collection('orgs').doc(orgId);
56
- const members = await orgRef.collection('members').get();
63
+ const [org, members] = await Promise.all([
64
+ orgRef.get(),
65
+ orgRef.collection('members').get()
66
+ ]);
57
67
  // A locked or disabled account is told nothing, by either channel; it is
58
68
  // still named when the alert is about it.
59
69
  const open = new Set(await reachable(members.docs.map((member)=>member.id)));
@@ -75,17 +85,16 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
75
85
  subjectName = String((_member_get1 = member.get('displayName')) != null ? _member_get1 : '').trim() || email;
76
86
  }
77
87
  }
78
- if (standing.scope === 'host' && standing.hostId) {
79
- var _host_get, _host_get1;
80
- const host = await firestore.collection('hosts').doc(standing.hostId).get();
81
- subjectName = String((_host_get = host.get('displayName')) != null ? _host_get : '').trim() || String((_host_get1 = host.get('subdomain')) != null ? _host_get1 : '').trim();
82
- }
88
+ const site = standing.hostId ? await siteName(firestore, standing.hostId) : '';
89
+ if (standing.scope === 'host') subjectName = site;
83
90
  return {
84
91
  managerUids,
85
92
  managerEmails,
86
93
  subjectUid: standing.scope === 'host' || !standing.uid || !open.has(standing.uid) ? null : standing.uid,
87
94
  subjectEmail: standing.scope === 'host' ? null : subjectEmail,
88
- subjectName: subjectName || (standing.scope === 'host' ? 'this' : 'A member')
95
+ subjectName: subjectName || (standing.scope === 'host' ? 'A site' : 'A member'),
96
+ workspaceName: String((_org_get = org.get('name')) != null ? _org_get : '').trim() || String((_org_get1 = org.get('slug')) != null ? _org_get1 : '').trim() || 'your workspace',
97
+ siteName: site
89
98
  };
90
99
  }
91
100
  /**
@@ -99,32 +108,47 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
99
108
  const claimed = await claimAiAllotmentAlert(firestore, input.orgId, standing.subject, input.month, threshold);
100
109
  if (!claimed) continue;
101
110
  const recipients = await recipientsFor(firestore, input.orgId, standing, reachable);
102
- const copy = aiAllotmentAlertCopy({
111
+ const words = {
103
112
  scope: standing.scope,
104
113
  threshold,
105
114
  used: standing.used,
106
115
  credits: standing.credits,
107
- name: recipients.subjectName
108
- });
116
+ name: recipients.subjectName,
117
+ workspace: recipients.workspaceName,
118
+ site: recipients.siteName
119
+ };
120
+ const copy = aiAllotmentAlertCopy(words);
121
+ const ownCopy = aiAllotmentAlertCopy(_extends({}, words, {
122
+ reader: 'subject'
123
+ }));
109
124
  const link = input.orgSlug ? `${buildRoute(Route.MANAGE_BILLING_USAGE, {
110
125
  orgSlug: input.orgSlug
111
126
  })}#ai-allotments` : null;
127
+ // `skipEmail`: the email below is this crossing's email, so the
128
+ // per-person channel does not send a second one to anybody who switched
129
+ // billing email on (AGL-3431).
112
130
  await notify(recipients.managerUids, _extends({
113
131
  type: 'billing.usage',
114
132
  title: copy.title,
115
133
  body: copy.body,
134
+ level: usageNotificationLevel(threshold),
116
135
  orgId: input.orgId
117
136
  }, link ? {
118
137
  link
119
- } : {}));
138
+ } : {}), {
139
+ skipEmail: true
140
+ });
120
141
  if (recipients.subjectUid && !recipients.managerUids.includes(recipients.subjectUid)) {
121
142
  await notify([
122
143
  recipients.subjectUid
123
144
  ], {
124
145
  type: 'billing.usage',
125
- title: copy.title,
126
- body: copy.body,
146
+ title: ownCopy.title,
147
+ body: ownCopy.body,
148
+ level: usageNotificationLevel(threshold),
127
149
  orgId: input.orgId
150
+ }, {
151
+ skipEmail: true
128
152
  });
129
153
  }
130
154
  const origin = consoleOrigin();
@@ -145,8 +169,8 @@ async function recipientsFor(firestore, orgId, standing, reachable) {
145
169
  if (subjectAddress.length) {
146
170
  const result = await send({
147
171
  to: subjectAddress,
148
- subject: copy.title,
149
- text: copy.body,
172
+ subject: ownCopy.title,
173
+ text: ownCopy.body,
150
174
  context: 'ai-allotment-alert'
151
175
  });
152
176
  if (result.sent) await meter().catch(()=>undefined);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-allotment-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport { meterPlatformEmail } from '@aglyn/tenant-data-admin/server/email-metering'\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport {\n aiAllotmentAlertCopy,\n type AiAllotmentStanding,\n type AiAllotmentThreshold,\n} from '../model/ai-allotments'\nimport { aiAllotmentRef } from './ai-allotments'\n\n/**\n * A SOFT allotment's 80% and 100% (AGL-2942), through the alert pipeline's\n * two channels: the console notification every usage threshold writes\n * (`billing.usage`, to `users/{uid}/notifications`) and the email beside it,\n * sent past the platform suppression list and metered as platform mail —\n * the same primitives the usage-alert sweep composes.\n *\n * Told when the request that finds the subject at a threshold is admitted,\n * rather than on the next daily sweep: an allotment is a line a manager drew\n * for one person or one site, and a day's delay is most of what a soft line\n * is for. The sweep is untouched.\n *\n * ## Who is told\n *\n * The workspace's owners and admins — the audience of every usage alert —\n * and, for a member's or a collaborator's allotment, the person it is for.\n * The person gets the words without the Billing link, which a collaborator\n * cannot open.\n *\n * ## Once per threshold per month\n *\n * The marker is `alerted: { month, threshold }` on the allotment itself,\n * claimed in a transaction before anything is sent, so two requests at the\n * line cannot both announce it. A write to the allotment clears the marker,\n * so a raised allotment announces its own next crossing.\n */\n\n/** A crossing to announce, as the reservation measured it. */\nexport interface AiAllotmentAlertInput {\n orgId: string\n /** The workspace's slug, for the Billing link; `null` omits the link. */\n orgSlug: string | null\n month: string\n alerts: ReadonlyArray<{ standing: AiAllotmentStanding; threshold: AiAllotmentThreshold }>\n}\n\n/** The pipeline's primitives, replaceable in a spec. */\nexport interface AiAllotmentAlertChannels {\n notify: typeof notifyUsers\n send: typeof sendEmail\n filter: typeof filterSuppressedEmails\n meter: typeof meterPlatformEmail\n /** The uids given, minus locked or disabled accounts (AGL-3418). */\n reachable: typeof withoutMailWithheldAccounts\n}\n\nconst DEFAULT_CHANNELS: AiAllotmentAlertChannels = {\n notify: notifyUsers,\n send: sendEmail,\n filter: filterSuppressedEmails,\n meter: meterPlatformEmail,\n reachable: withoutMailWithheldAccounts,\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/** Take the marker for `threshold` this month; `false` when it was taken. */\nexport async function claimAiAllotmentAlert(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n subject: string,\n month: string,\n threshold: AiAllotmentThreshold,\n): Promise<boolean> {\n const ref = aiAllotmentRef(firestore.collection('orgs').doc(orgId), subject)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return false\n const alerted = snapshot.get('alerted') as { month?: unknown; threshold?: unknown } | null\n if (alerted?.month === month && Number(alerted?.threshold) >= threshold) return false\n tx.set(ref, { alerted: { month, threshold } }, { merge: true })\n return true\n })\n}\n\ninterface Recipients {\n managerUids: string[]\n managerEmails: string[]\n subjectUid: string | null\n subjectEmail: string | null\n subjectName: string\n}\n\nasync function recipientsFor(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n standing: AiAllotmentStanding,\n reachable: AiAllotmentAlertChannels['reachable'],\n): Promise<Recipients> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const members = await orgRef.collection('members').get()\n // A locked or disabled account is told nothing, by either channel; it is\n // still named when the alert is about it.\n const open = new Set(await reachable(members.docs.map((member) => member.id)))\n const managerUids: string[] = []\n const managerEmails: string[] = []\n let subjectEmail: string | null = null\n let subjectName = ''\n for (const member of members.docs) {\n const role = member.get('role')\n const email = String(member.get('email') ?? '').trim()\n if ((role === 'owner' || role === 'admin') && open.has(member.id)) {\n managerUids.push(member.id)\n if (email.includes('@')) managerEmails.push(email)\n }\n if (standing.uid && member.id === standing.uid) {\n subjectEmail = email.includes('@') && open.has(member.id) ? email : null\n subjectName = String(member.get('displayName') ?? '').trim() || email\n }\n }\n if (standing.scope === 'host' && standing.hostId) {\n const host = await firestore.collection('hosts').doc(standing.hostId).get()\n subjectName =\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim()\n }\n return {\n managerUids,\n managerEmails,\n subjectUid:\n standing.scope === 'host' || !standing.uid || !open.has(standing.uid)\n ? null\n : standing.uid,\n subjectEmail: standing.scope === 'host' ? null : subjectEmail,\n subjectName: subjectName || (standing.scope === 'host' ? 'this' : 'A member'),\n }\n}\n\n/**\n * Announce every crossing the reservation found. Never throws, and answers\n * how many were announced — one failing crossing does not stop the next.\n */\nexport async function announceAiAllotmentAlerts(\n firestore: FirebaseFirestore.Firestore,\n input: AiAllotmentAlertInput,\n channels: Partial<AiAllotmentAlertChannels> = {},\n): Promise<number> {\n const { notify, send, filter, meter, reachable } = { ...DEFAULT_CHANNELS, ...channels }\n let announced = 0\n for (const { standing, threshold } of input.alerts) {\n try {\n const claimed = await claimAiAllotmentAlert(\n firestore,\n input.orgId,\n standing.subject,\n input.month,\n threshold,\n )\n if (!claimed) continue\n const recipients = await recipientsFor(firestore, input.orgId, standing, reachable)\n const copy = aiAllotmentAlertCopy({\n scope: standing.scope,\n threshold,\n used: standing.used,\n credits: standing.credits,\n name: recipients.subjectName,\n })\n const link = input.orgSlug\n ? `${buildRoute(Route.MANAGE_BILLING_USAGE, { orgSlug: input.orgSlug })}#ai-allotments`\n : null\n await notify(recipients.managerUids, {\n type: 'billing.usage',\n title: copy.title,\n body: copy.body,\n orgId: input.orgId,\n ...(link ? { link } : {}),\n })\n if (recipients.subjectUid && !recipients.managerUids.includes(recipients.subjectUid)) {\n await notify([recipients.subjectUid], {\n type: 'billing.usage',\n title: copy.title,\n body: copy.body,\n orgId: input.orgId,\n })\n }\n const origin = consoleOrigin()\n const managerText = link && origin ? `${copy.body}\\n\\n${origin}${link}` : copy.body\n const managers = await filter(recipients.managerEmails)\n if (managers.length) {\n const result = await send({\n to: managers,\n subject: copy.title,\n text: managerText,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n const subjectAddress = recipients.subjectEmail\n ? (await filter([recipients.subjectEmail])).filter(\n (address) => !managers.includes(address),\n )\n : []\n if (subjectAddress.length) {\n const result = await send({\n to: subjectAddress,\n subject: copy.title,\n text: copy.body,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n announced += 1\n } catch (error) {\n console.error('[ai-allotments] announcing a crossing failed', input.orgId, standing.subject, error)\n }\n }\n return announced\n}\n"],"names":["buildRoute","Route","sendEmail","withoutMailWithheldAccounts","meterPlatformEmail","filterSuppressedEmails","notifyUsers","aiAllotmentAlertCopy","aiAllotmentRef","DEFAULT_CHANNELS","notify","send","filter","meter","reachable","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","claimAiAllotmentAlert","firestore","orgId","subject","month","threshold","ref","collection","doc","runTransaction","tx","snapshot","get","exists","alerted","Number","set","merge","recipientsFor","standing","orgRef","members","open","Set","docs","map","member","id","managerUids","managerEmails","subjectEmail","subjectName","role","email","String","has","push","includes","uid","scope","hostId","host","subjectUid","announceAiAllotmentAlerts","input","channels","announced","alerts","claimed","recipients","copy","used","credits","name","link","orgSlug","MANAGE_BILLING_USAGE","type","title","body","origin","managerText","managers","length","result","to","text","context","sent","catch","undefined","subjectAddress","address","error","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-allotment-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport { usageNotificationLevel } from '@aglyn/aglyn/app-utils/notifications'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport { meterPlatformEmail } from '@aglyn/tenant-data-admin/server/email-metering'\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport {\n aiAllotmentAlertCopy,\n type AiAllotmentStanding,\n type AiAllotmentThreshold,\n} from '../model/ai-allotments'\nimport { aiAllotmentRef } from './ai-allotments'\n\n/**\n * A SOFT allotment's 75, 80, 90 and 100% steps (AGL-2942, AGL-3431),\n * through the alert pipeline's two channels: the console notification every\n * usage threshold writes (`billing.usage`, to `users/{uid}/notifications`)\n * and the email beside it, sent past the platform suppression list and\n * metered as platform mail — the same primitives the usage-alert sweep\n * composes.\n *\n * Told when the request that finds the subject at a threshold is admitted,\n * rather than on the next daily sweep: an allotment is a line a manager drew\n * for one person or one site, and a day's delay is most of what a soft line\n * is for. The sweep is untouched.\n *\n * ## Who is told\n *\n * The workspace's owners and admins — the audience of every usage alert —\n * and, for a member's or a collaborator's allotment, the person it is for.\n * The person gets their own words, in the second person and without the\n * Billing link or the \"make it hard\" step, neither of which they can use.\n * Both name the workspace, and a collaborator's the site, because an owner\n * of two workspaces cannot tell from a figure which one it is about.\n *\n * ## Once per threshold per month\n *\n * The marker is `alerted: { month, threshold }` on the allotment itself,\n * claimed in a transaction before anything is sent, so two requests at the\n * line cannot both announce it. A write to the allotment clears the marker,\n * so a raised allotment announces its own next crossing.\n */\n\n/** A crossing to announce, as the reservation measured it. */\nexport interface AiAllotmentAlertInput {\n orgId: string\n /** The workspace's slug, for the Billing link; `null` omits the link. */\n orgSlug: string | null\n month: string\n alerts: ReadonlyArray<{ standing: AiAllotmentStanding; threshold: AiAllotmentThreshold }>\n}\n\n/** The pipeline's primitives, replaceable in a spec. */\nexport interface AiAllotmentAlertChannels {\n notify: typeof notifyUsers\n send: typeof sendEmail\n filter: typeof filterSuppressedEmails\n meter: typeof meterPlatformEmail\n /** The uids given, minus locked or disabled accounts (AGL-3418). */\n reachable: typeof withoutMailWithheldAccounts\n}\n\nconst DEFAULT_CHANNELS: AiAllotmentAlertChannels = {\n notify: notifyUsers,\n send: sendEmail,\n filter: filterSuppressedEmails,\n meter: meterPlatformEmail,\n reachable: withoutMailWithheldAccounts,\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/** Take the marker for `threshold` this month; `false` when it was taken. */\nexport async function claimAiAllotmentAlert(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n subject: string,\n month: string,\n threshold: AiAllotmentThreshold,\n): Promise<boolean> {\n const ref = aiAllotmentRef(firestore.collection('orgs').doc(orgId), subject)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return false\n const alerted = snapshot.get('alerted') as { month?: unknown; threshold?: unknown } | null\n if (alerted?.month === month && Number(alerted?.threshold) >= threshold) return false\n tx.set(ref, { alerted: { month, threshold } }, { merge: true })\n return true\n })\n}\n\ninterface Recipients {\n managerUids: string[]\n managerEmails: string[]\n subjectUid: string | null\n subjectEmail: string | null\n subjectName: string\n /** The workspace's name, for the copy. */\n workspaceName: string\n /** The site a collaborator's allotment is on, for the copy. */\n siteName: string\n}\n\n/** The name a site's managers know it by, then its address, then its id. */\nasync function siteName(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<string> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n return (\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim() ||\n hostId\n )\n}\n\nasync function recipientsFor(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n standing: AiAllotmentStanding,\n reachable: AiAllotmentAlertChannels['reachable'],\n): Promise<Recipients> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [org, members] = await Promise.all([orgRef.get(), orgRef.collection('members').get()])\n // A locked or disabled account is told nothing, by either channel; it is\n // still named when the alert is about it.\n const open = new Set(await reachable(members.docs.map((member) => member.id)))\n const managerUids: string[] = []\n const managerEmails: string[] = []\n let subjectEmail: string | null = null\n let subjectName = ''\n for (const member of members.docs) {\n const role = member.get('role')\n const email = String(member.get('email') ?? '').trim()\n if ((role === 'owner' || role === 'admin') && open.has(member.id)) {\n managerUids.push(member.id)\n if (email.includes('@')) managerEmails.push(email)\n }\n if (standing.uid && member.id === standing.uid) {\n subjectEmail = email.includes('@') && open.has(member.id) ? email : null\n subjectName = String(member.get('displayName') ?? '').trim() || email\n }\n }\n const site = standing.hostId ? await siteName(firestore, standing.hostId) : ''\n if (standing.scope === 'host') subjectName = site\n return {\n managerUids,\n managerEmails,\n subjectUid:\n standing.scope === 'host' || !standing.uid || !open.has(standing.uid)\n ? null\n : standing.uid,\n subjectEmail: standing.scope === 'host' ? null : subjectEmail,\n subjectName: subjectName || (standing.scope === 'host' ? 'A site' : 'A member'),\n workspaceName:\n String(org.get('name') ?? '').trim() ||\n String(org.get('slug') ?? '').trim() ||\n 'your workspace',\n siteName: site,\n }\n}\n\n/**\n * Announce every crossing the reservation found. Never throws, and answers\n * how many were announced — one failing crossing does not stop the next.\n */\nexport async function announceAiAllotmentAlerts(\n firestore: FirebaseFirestore.Firestore,\n input: AiAllotmentAlertInput,\n channels: Partial<AiAllotmentAlertChannels> = {},\n): Promise<number> {\n const { notify, send, filter, meter, reachable } = { ...DEFAULT_CHANNELS, ...channels }\n let announced = 0\n for (const { standing, threshold } of input.alerts) {\n try {\n const claimed = await claimAiAllotmentAlert(\n firestore,\n input.orgId,\n standing.subject,\n input.month,\n threshold,\n )\n if (!claimed) continue\n const recipients = await recipientsFor(firestore, input.orgId, standing, reachable)\n const words = {\n scope: standing.scope,\n threshold,\n used: standing.used,\n credits: standing.credits,\n name: recipients.subjectName,\n workspace: recipients.workspaceName,\n site: recipients.siteName,\n }\n const copy = aiAllotmentAlertCopy(words)\n const ownCopy = aiAllotmentAlertCopy({ ...words, reader: 'subject' })\n const link = input.orgSlug\n ? `${buildRoute(Route.MANAGE_BILLING_USAGE, { orgSlug: input.orgSlug })}#ai-allotments`\n : null\n // `skipEmail`: the email below is this crossing's email, so the\n // per-person channel does not send a second one to anybody who switched\n // billing email on (AGL-3431).\n await notify(\n recipients.managerUids,\n {\n type: 'billing.usage',\n title: copy.title,\n body: copy.body,\n level: usageNotificationLevel(threshold),\n orgId: input.orgId,\n ...(link ? { link } : {}),\n },\n { skipEmail: true },\n )\n if (recipients.subjectUid && !recipients.managerUids.includes(recipients.subjectUid)) {\n await notify(\n [recipients.subjectUid],\n {\n type: 'billing.usage',\n title: ownCopy.title,\n body: ownCopy.body,\n level: usageNotificationLevel(threshold),\n orgId: input.orgId,\n },\n { skipEmail: true },\n )\n }\n const origin = consoleOrigin()\n const managerText = link && origin ? `${copy.body}\\n\\n${origin}${link}` : copy.body\n const managers = await filter(recipients.managerEmails)\n if (managers.length) {\n const result = await send({\n to: managers,\n subject: copy.title,\n text: managerText,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n const subjectAddress = recipients.subjectEmail\n ? (await filter([recipients.subjectEmail])).filter(\n (address) => !managers.includes(address),\n )\n : []\n if (subjectAddress.length) {\n const result = await send({\n to: subjectAddress,\n subject: ownCopy.title,\n text: ownCopy.body,\n context: 'ai-allotment-alert',\n })\n if (result.sent) await meter().catch(() => undefined)\n }\n announced += 1\n } catch (error) {\n console.error('[ai-allotments] announcing a crossing failed', input.orgId, standing.subject, error)\n }\n }\n return announced\n}\n"],"names":["buildRoute","Route","usageNotificationLevel","sendEmail","withoutMailWithheldAccounts","meterPlatformEmail","filterSuppressedEmails","notifyUsers","aiAllotmentAlertCopy","aiAllotmentRef","DEFAULT_CHANNELS","notify","send","filter","meter","reachable","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","claimAiAllotmentAlert","firestore","orgId","subject","month","threshold","ref","collection","doc","runTransaction","tx","snapshot","get","exists","alerted","Number","set","merge","siteName","hostId","host","String","recipientsFor","standing","org","orgRef","members","Promise","all","open","Set","docs","map","member","id","managerUids","managerEmails","subjectEmail","subjectName","role","email","has","push","includes","uid","site","scope","subjectUid","workspaceName","announceAiAllotmentAlerts","input","channels","announced","alerts","claimed","recipients","words","used","credits","name","workspace","copy","ownCopy","reader","link","orgSlug","MANAGE_BILLING_USAGE","type","title","body","level","skipEmail","origin","managerText","managers","length","result","to","text","context","sent","catch","undefined","subjectAddress","address","error","console"],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { UsageQuotaCheck, UsageQuotaContext } from '@aglyn/aglyn/plugin-manager/usage-alert-contributors';
18
+ /** The guard key the credits band is warned under. */
19
+ export declare const AI_CREDITS_QUOTA_KEY = "assistCredits";
20
+ /**
21
+ * The AI credits band the WORKSPACE is warned about (AGL-2898), as a quota
22
+ * check the usage-alerts sweep runs beside its own.
23
+ *
24
+ * Measured in CREDITS — the unit the customer was sold and the meter shows —
25
+ * from this plugin's line of the month's spend (the billed `estCostUsd`)
26
+ * through the one conversion, so this alert and the Billing meter cannot
27
+ * disagree about how far into the band the org is. A plan with no band
28
+ * resolves `null` and reads as a limit of 0, which the sweep skips the way it
29
+ * skips Free's zero-quota dimensions.
30
+ *
31
+ * Approaching the band is the sweep's generic warning; REACHING it says what
32
+ * happens next, which differs by plan and by the org's own controls — metered
33
+ * at a rate unless a stop is set, or stopped until next month with nothing
34
+ * ever billed — and telling a Free org to "set a monthly cap" on a charge it
35
+ * can never incur would be the surprise bill in reverse.
36
+ */
37
+ export declare function aiQuotaChecks(context: UsageQuotaContext): Promise<readonly UsageQuotaCheck[]>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { ASSIST_HARD_CAP_CONTROL_LABEL, ASSIST_HARD_CAP_CONTROL_LOCATION, assistBandRefuses, assistCreditsFromUsd, resolveAssistCreditBudget, resolveAssistHardCap, resolveAssistOverageRateUsdPer1k } from "./assist-credits.js";
17
+ import { AI_ASSIST_SPEND_LINE_ID } from "../usage-axes.js";
18
+ /** The guard key the credits band is warned under. */ export const AI_CREDITS_QUOTA_KEY = 'assistCredits';
19
+ /**
20
+ * The AI credits band the WORKSPACE is warned about (AGL-2898), as a quota
21
+ * check the usage-alerts sweep runs beside its own.
22
+ *
23
+ * Measured in CREDITS — the unit the customer was sold and the meter shows —
24
+ * from this plugin's line of the month's spend (the billed `estCostUsd`)
25
+ * through the one conversion, so this alert and the Billing meter cannot
26
+ * disagree about how far into the band the org is. A plan with no band
27
+ * resolves `null` and reads as a limit of 0, which the sweep skips the way it
28
+ * skips Free's zero-quota dimensions.
29
+ *
30
+ * Approaching the band is the sweep's generic warning; REACHING it says what
31
+ * happens next, which differs by plan and by the org's own controls — metered
32
+ * at a rate unless a stop is set, or stopped until next month with nothing
33
+ * ever billed — and telling a Free org to "set a monthly cap" on a charge it
34
+ * can never incur would be the surprise bill in reverse.
35
+ */ export async function aiQuotaChecks(context) {
36
+ var _ref;
37
+ var _context_spend_lines_find;
38
+ const org = context.org;
39
+ const bandCredits = resolveAssistCreditBudget(org);
40
+ const spentUsd = (_ref = (_context_spend_lines_find = context.spend.lines.find((line)=>line.id === AI_ASSIST_SPEND_LINE_ID)) == null ? void 0 : _context_spend_lines_find.usd) != null ? _ref : 0;
41
+ // Whether crossing the band produces an invoice line or a stop. The same
42
+ // predicate the reservation refuses on, so the words and the behavior
43
+ // cannot drift: a band that refuses — the org's own switch, or a plan with
44
+ // no rate — is a wall, and everything else is sold past.
45
+ const bandIsWall = assistBandRefuses(org);
46
+ // Through the same resolver `assistBandRefuses` asks, so the alert's rate
47
+ // and its wall/sold verdict cannot disagree about one org. Read straight off
48
+ // `PLAN_PRICING` they did: Starter's listed rate is null and the add-on's is
49
+ // added by the resolver, so a Starter workspace with the add-on was told
50
+ // "sold past the band" at "$0.00 per 1,000" in the same sentence (AGL-3014).
51
+ const rateUsdPer1k = resolveAssistOverageRateUsdPer1k(org);
52
+ const rate = `$${(rateUsdPer1k != null ? rateUsdPer1k : 0).toFixed(2)} per 1,000`;
53
+ return [
54
+ {
55
+ key: AI_CREDITS_QUOTA_KEY,
56
+ label: 'AI assist credits',
57
+ noun: 'AI assist credits',
58
+ used: assistCreditsFromUsd(spentUsd),
59
+ limit: bandCredits != null ? bandCredits : 0,
60
+ cadence: 'monthly',
61
+ outcome: bandIsWall ? 'stops' : 'bills',
62
+ reachedTitle: bandIsWall ? "You've used your included AI assist credits" : "You've used your included AI assist credits — extra credits " + 'are now billed',
63
+ reached: bandIsWall ? 'AI assist stops here until next month — nothing past the band ' + 'is ever billed. Upgrade in Billing to add credits' + (resolveAssistHardCap(org) && rateUsdPer1k !== null ? `, or turn off "${ASSIST_HARD_CAP_CONTROL_LABEL}" ` + `under ${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep ` + `going at your plan’s rate.` : '.') : 'The assistant keeps answering, and the extra credits are ' + `metered on your monthly invoice at ${rate} — unless you set a ` + `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}: stop at the ` + 'included band, or stop once this month’s overage reaches a ' + 'figure you choose.',
64
+ approach: bandIsWall ? 'Nothing changes and nothing is charged — at the included band, ' + 'AI assist stops until next month, and nothing past it is ever ' + 'billed.' : 'Nothing is charged yet — past the included band, extra credits ' + `are metered on your monthly invoice at ${rate} unless you set a ` + `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}.`
65
+ }
66
+ ];
67
+ }
68
+
69
+ //# sourceMappingURL=ai-quota-checks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-quota-checks.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ASSIST_HARD_CAP_CONTROL_LABEL,\n ASSIST_HARD_CAP_CONTROL_LOCATION,\n assistBandRefuses,\n assistCreditsFromUsd,\n resolveAssistCreditBudget,\n resolveAssistHardCap,\n resolveAssistOverageRateUsdPer1k,\n} from './assist-credits'\nimport type {\n UsageQuotaCheck,\n UsageQuotaContext,\n} from '@aglyn/aglyn/plugin-manager/usage-alert-contributors'\nimport { AI_ASSIST_SPEND_LINE_ID } from '../usage-axes'\n\n/** The guard key the credits band is warned under. */\nexport const AI_CREDITS_QUOTA_KEY = 'assistCredits'\n\n/**\n * The AI credits band the WORKSPACE is warned about (AGL-2898), as a quota\n * check the usage-alerts sweep runs beside its own.\n *\n * Measured in CREDITS — the unit the customer was sold and the meter shows —\n * from this plugin's line of the month's spend (the billed `estCostUsd`)\n * through the one conversion, so this alert and the Billing meter cannot\n * disagree about how far into the band the org is. A plan with no band\n * resolves `null` and reads as a limit of 0, which the sweep skips the way it\n * skips Free's zero-quota dimensions.\n *\n * Approaching the band is the sweep's generic warning; REACHING it says what\n * happens next, which differs by plan and by the org's own controls — metered\n * at a rate unless a stop is set, or stopped until next month with nothing\n * ever billed — and telling a Free org to \"set a monthly cap\" on a charge it\n * can never incur would be the surprise bill in reverse.\n */\nexport async function aiQuotaChecks(\n context: UsageQuotaContext,\n): Promise<readonly UsageQuotaCheck[]> {\n const org = context.org as never\n const bandCredits = resolveAssistCreditBudget(org)\n const spentUsd =\n context.spend.lines.find((line) => line.id === AI_ASSIST_SPEND_LINE_ID)\n ?.usd ?? 0\n // Whether crossing the band produces an invoice line or a stop. The same\n // predicate the reservation refuses on, so the words and the behavior\n // cannot drift: a band that refuses — the org's own switch, or a plan with\n // no rate — is a wall, and everything else is sold past.\n const bandIsWall = assistBandRefuses(org)\n // Through the same resolver `assistBandRefuses` asks, so the alert's rate\n // and its wall/sold verdict cannot disagree about one org. Read straight off\n // `PLAN_PRICING` they did: Starter's listed rate is null and the add-on's is\n // added by the resolver, so a Starter workspace with the add-on was told\n // \"sold past the band\" at \"$0.00 per 1,000\" in the same sentence (AGL-3014).\n const rateUsdPer1k = resolveAssistOverageRateUsdPer1k(org)\n const rate = `$${(rateUsdPer1k ?? 0).toFixed(2)} per 1,000`\n return [\n {\n key: AI_CREDITS_QUOTA_KEY,\n label: 'AI assist credits',\n noun: 'AI assist credits',\n used: assistCreditsFromUsd(spentUsd),\n limit: bandCredits ?? 0,\n cadence: 'monthly',\n outcome: bandIsWall ? 'stops' : 'bills',\n reachedTitle: bandIsWall\n ? \"You've used your included AI assist credits\"\n : \"You've used your included AI assist credits — extra credits \" +\n 'are now billed',\n reached: bandIsWall\n ? 'AI assist stops here until next month — nothing past the band ' +\n 'is ever billed. Upgrade in Billing to add credits' +\n (resolveAssistHardCap(org) && rateUsdPer1k !== null\n ? `, or turn off \"${ASSIST_HARD_CAP_CONTROL_LABEL}\" ` +\n `under ${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep ` +\n `going at your plan’s rate.`\n : '.')\n : 'The assistant keeps answering, and the extra credits are ' +\n `metered on your monthly invoice at ${rate} — unless you set a ` +\n `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}: stop at the ` +\n 'included band, or stop once this month’s overage reaches a ' +\n 'figure you choose.',\n approach: bandIsWall\n ? 'Nothing changes and nothing is charged — at the included band, ' +\n 'AI assist stops until next month, and nothing past it is ever ' +\n 'billed.'\n : 'Nothing is charged yet — past the included band, extra credits ' +\n `are metered on your monthly invoice at ${rate} unless you set a ` +\n `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}.`,\n },\n ]\n}\n"],"names":["ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_HARD_CAP_CONTROL_LOCATION","assistBandRefuses","assistCreditsFromUsd","resolveAssistCreditBudget","resolveAssistHardCap","resolveAssistOverageRateUsdPer1k","AI_ASSIST_SPEND_LINE_ID","AI_CREDITS_QUOTA_KEY","aiQuotaChecks","context","org","bandCredits","spentUsd","spend","lines","find","line","id","usd","bandIsWall","rateUsdPer1k","rate","toFixed","key","label","noun","used","limit","cadence","outcome","reachedTitle","reached","approach"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,6BAA6B,EAC7BC,gCAAgC,EAChCC,iBAAiB,EACjBC,oBAAoB,EACpBC,yBAAyB,EACzBC,oBAAoB,EACpBC,gCAAgC,QAC3B,sBAAkB;AAKzB,SAASC,uBAAuB,QAAQ,mBAAe;AAEvD,oDAAoD,GACpD,OAAO,MAAMC,uBAAuB,gBAAe;AAEnD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,eAAeC,cACpBC,OAA0B;;QAKxBA;IAHF,MAAMC,MAAMD,QAAQC,GAAG;IACvB,MAAMC,cAAcR,0BAA0BO;IAC9C,MAAME,oBACJH,4BAAAA,QAAQI,KAAK,CAACC,KAAK,CAACC,IAAI,CAAC,CAACC,OAASA,KAAKC,EAAE,KAAKX,6CAA/CG,0BACIS,GAAG,mBAAI;IACb,yEAAyE;IACzE,sEAAsE;IACtE,2EAA2E;IAC3E,yDAAyD;IACzD,MAAMC,aAAalB,kBAAkBS;IACrC,0EAA0E;IAC1E,6EAA6E;IAC7E,6EAA6E;IAC7E,yEAAyE;IACzE,6EAA6E;IAC7E,MAAMU,eAAef,iCAAiCK;IACtD,MAAMW,OAAO,CAAC,CAAC,EAAE,CAACD,uBAAAA,eAAgB,GAAGE,OAAO,CAAC,GAAG,UAAU,CAAC;IAC3D,OAAO;QACL;YACEC,KAAKhB;YACLiB,OAAO;YACPC,MAAM;YACNC,MAAMxB,qBAAqBU;YAC3Be,KAAK,EAAEhB,sBAAAA,cAAe;YACtBiB,SAAS;YACTC,SAASV,aAAa,UAAU;YAChCW,cAAcX,aACV,gDACA,iEACA;YACJY,SAASZ,aACL,mEACA,sDACCf,CAAAA,qBAAqBM,QAAQU,iBAAiB,OAC3C,CAAC,eAAe,EAAErB,8BAA8B,EAAE,CAAC,GACnD,CAAC,MAAM,EAAEC,iCAAiC,SAAS,CAAC,GACpD,CAAC,0BAA0B,CAAC,GAC5B,GAAE,IACN,8DACA,CAAC,mCAAmC,EAAEqB,KAAK,oBAAoB,CAAC,GAChE,CAAC,WAAW,EAAErB,iCAAiC,cAAc,CAAC,GAC9D,gEACA;YACJgC,UAAUb,aACN,oEACA,mEACA,YACA,oEACA,CAAC,uCAAuC,EAAEE,KAAK,kBAAkB,CAAC,GAClE,CAAC,WAAW,EAAErB,iCAAiC,CAAC,CAAC;QACvD;KACD;AACH"}
@@ -13,11 +13,12 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { resolveAssistCreditBudget } from "@aglyn/aglyn/app-utils/assist-credits";
16
+ */ import { resolveAssistCreditBudget } from "./assist-credits.js";
17
17
  import { aiAddonName, hasAiAddon, isUncappedPlanComp } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
18
  import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
19
- import { assistCeilingBreach, assistCogsAlertThresholdUsd, assistMarginBreach, assistMarginMultiple } from "@aglyn/aglyn/app-utils/usage-budget";
19
+ import { assistCeilingBreach, assistCogsAlertThresholdUsd, assistMarginBreach, assistMarginMultiple } from "./assist-spend-guards.js";
20
20
  import { assistBackstopCeilingUsd } from "./assist-ceiling.js";
21
+ import { AI_ASSIST_SPEND_LINE_ID } from "../usage-axes.js";
21
22
  /**
22
23
  * The AI plugin's staff alerts on provider spend (AGL-2984), evaluated for
23
24
  * every org by the usage-alerts sweep through its usage alert contributor
@@ -56,23 +57,33 @@ import { assistBackstopCeilingUsd } from "./assist-ceiling.js";
56
57
  await alertOnMarginGuard(context);
57
58
  await alertOnHardCeiling(context);
58
59
  }
60
+ /**
61
+ * The month's assist spend in dollars: this plugin's own line of the spend
62
+ * the sweep built (`usageAxes` → `spendLines`), or nothing when the line is
63
+ * absent.
64
+ */ function assistSpendUsd({ spend }) {
65
+ var _ref;
66
+ var _spend_lines_find;
67
+ return (_ref = (_spend_lines_find = spend.lines.find((line)=>line.id === AI_ASSIST_SPEND_LINE_ID)) == null ? void 0 : _spend_lines_find.usd) != null ? _ref : 0;
68
+ }
59
69
  /** How both alerts name the org: its slug, or its id when it has none. */ function orgName({ orgSlug, orgId }) {
60
70
  return orgSlug != null ? orgSlug : orgId;
61
71
  }
62
72
  async function alertOnMarginGuard(context) {
63
- const { org, month, spend } = context;
73
+ const { org, month } = context;
74
+ const assistUsd = assistSpendUsd(context);
64
75
  const thresholdUsd = assistCogsAlertThresholdUsd(process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD);
65
76
  // The guard records the whole multiple announced, which is 0 for a zero or
66
77
  // non-finite threshold — a reading the breach predicate never agrees to.
67
- const multiple = assistMarginMultiple(spend.assistUsd, thresholdUsd);
78
+ const multiple = assistMarginMultiple(assistUsd, thresholdUsd);
68
79
  const due = assistMarginBreach({
69
- assistUsd: spend.assistUsd,
80
+ assistUsd,
70
81
  thresholdUsd,
71
82
  guard: context.guards[AI_MARGIN_GUARD_KEY],
72
83
  month
73
84
  });
74
85
  if (!due || !context.recordAlert(AI_MARGIN_GUARD_KEY, multiple)) return;
75
- const title = `Assist token spend is $${spend.assistUsd.toFixed(2)} for one org this month`;
86
+ const title = `Assist token spend is $${assistUsd.toFixed(2)} for one org this month`;
76
87
  // The add-on state and the band: the same dollar figure is a finding on an
77
88
  // org paying for the add-on and an incident on one that is not, and the
78
89
  // reader should not have to open the console to know which.
@@ -83,18 +94,19 @@ async function alertOnMarginGuard(context) {
83
94
  quota: AI_MARGIN_GUARD_KEY,
84
95
  threshold: multiple,
85
96
  title,
86
- body: `${orgName(context)} has run about ` + `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` + 'threshold. Assist is a plan entitlement with no per-token ' + 'price, so this is margin, not revenue. ' + `${addonClause}, with ${bandClause}.`,
97
+ body: `${orgName(context)} has run about ` + `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` + 'threshold. Assist is a plan entitlement with no per-token ' + 'price, so this is margin, not revenue. ' + `${addonClause}, with ${bandClause}.`,
87
98
  link: STAFF_ORGS_LINK,
88
99
  emailContext: 'assist-margin'
89
100
  });
90
101
  }
91
102
  async function alertOnHardCeiling(context) {
92
- const { org, month, spend } = context;
103
+ const { org, month } = context;
104
+ const assistUsd = assistSpendUsd(context);
93
105
  // The same composition the reservation refuses on, for this org, so the
94
106
  // figure announced and the figure enforced cannot drift apart.
95
107
  const ceilingUsd = assistBackstopCeilingUsd(org);
96
108
  const due = assistCeilingBreach({
97
- assistUsd: spend.assistUsd,
109
+ assistUsd,
98
110
  ceilingUsd,
99
111
  guard: context.guards[AI_CEILING_GUARD_KEY],
100
112
  month
@@ -105,7 +117,7 @@ async function alertOnHardCeiling(context) {
105
117
  quota: AI_CEILING_GUARD_KEY,
106
118
  threshold: 1,
107
119
  title: `Assist is REFUSING ${orgName(context)} — the ` + `${ceiling} monthly spend ceiling is crossed`,
108
- body: `${orgName(context)} has run about ` + `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the ` + `${ceiling} ceiling. Every further ` + 'Assist request from this organization is refused until the month ' + 'rolls over — the assistant is not degraded or slowed, it is off. ' + 'The customer keeps whatever messages their plan has left, and the ' + 'refusal says so in its own words.',
120
+ body: `${orgName(context)} has run about ` + `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the ` + `${ceiling} ceiling. Every further ` + 'Assist request from this organization is refused until the month ' + 'rolls over — the assistant is not degraded or slowed, it is off. ' + 'The customer keeps whatever messages their plan has left, and the ' + 'refusal says so in its own words.',
109
121
  link: STAFF_ORGS_LINK,
110
122
  emailContext: 'assist-ceiling'
111
123
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { resolveAssistCreditBudget } from '@aglyn/aglyn/app-utils/assist-credits'\nimport {\n aiAddonName,\n hasAiAddon,\n isUncappedPlanComp,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n assistCeilingBreach,\n assistCogsAlertThresholdUsd,\n assistMarginBreach,\n assistMarginMultiple,\n} from '@aglyn/aglyn/app-utils/usage-budget'\nimport type { UsageAlertContext } from '@aglyn/aglyn/plugin-manager/usage-alert-contributors'\nimport { assistBackstopCeilingUsd } from './assist-ceiling'\n\n/**\n * The AI plugin's staff alerts on provider spend (AGL-2984), evaluated for\n * every org by the usage-alerts sweep through its usage alert contributor\n * seam. Two alerts, in this order, each deduped under its own guard key:\n *\n * - THE MARGIN GUARD warns. The meter caps an entitled org by MESSAGE count,\n * and a month of long, cache-cold exchanges on a large model is a\n * three-figure provider bill against a subscription that did not move. One\n * org's spend crossing the review threshold tells staff, and every further\n * whole multiple of the threshold tells them again, so an escalating cost\n * never goes quiet because it already spoke.\n * - THE HARD CEILING reports a stop. Past the monthly spend ceiling the\n * reservation refuses every assist request from the org, so its assistant\n * has stopped answering, and staff are told so in those words. It cannot\n * ride the margin guard: that one speaks in whole multiples of its\n * threshold, so an org climbing from the threshold to the ceiling is still\n * at 1x and says nothing. Announced once a month, because crossing is a\n * state rather than an escalating sum.\n *\n * The ceiling is the one THIS org's reservations refuse at —\n * `assistBackstopCeilingUsd` — not the repo default for everyone. That\n * default binds only a workspace with no band of its own; a band sold past,\n * a band that is a wall, and an uncapped staff comp are each measured\n * against something else, and announcing a $40 stop the reservation never\n * makes would send staff after an assistant that is still answering.\n *\n * Both are STAFF alerts. The org is not charged for this spend and has done\n * nothing wrong, so mailing the customer about our cost would be alarming and\n * meaningless. Both read the spend figure the sweep built, record through the\n * sweep's guard and send through the sweep's senders, so the first-sweep\n * seeding and the run's report apply to them exactly as to core's alerts.\n */\n\n/** The margin guard's key: the whole multiples announced this month. */\nexport const AI_MARGIN_GUARD_KEY = 'assistCogs'\n\n/** The hard ceiling's key: announced once for the month. */\nexport const AI_CEILING_GUARD_KEY = 'assistCeiling'\n\n/** Both alerts send staff to the org list. */\nconst STAFF_ORGS_LINK = '/admin/orgs'\n\n/** Evaluates one org: the margin guard, then the hard ceiling. */\nexport async function evaluateAiUsageAlerts(\n context: UsageAlertContext,\n): Promise<void> {\n await alertOnMarginGuard(context)\n await alertOnHardCeiling(context)\n}\n\n/** How both alerts name the org: its slug, or its id when it has none. */\nfunction orgName({ orgSlug, orgId }: UsageAlertContext): string {\n return orgSlug ?? orgId\n}\n\nasync function alertOnMarginGuard(context: UsageAlertContext): Promise<void> {\n const { org, month, spend } = context\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n // The guard records the whole multiple announced, which is 0 for a zero or\n // non-finite threshold — a reading the breach predicate never agrees to.\n const multiple = assistMarginMultiple(spend.assistUsd, thresholdUsd)\n const due = assistMarginBreach({\n assistUsd: spend.assistUsd,\n thresholdUsd,\n guard: context.guards[AI_MARGIN_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_MARGIN_GUARD_KEY, multiple)) return\n\n const title = `Assist token spend is $${spend.assistUsd.toFixed(2)} for one org this month`\n // The add-on state and the band: the same dollar figure is a finding on an\n // org paying for the add-on and an incident on one that is not, and the\n // reader should not have to open the console to know which.\n const addonClause = hasAiAddon(org as never)\n ? `The ${aiAddonName()} add-on is on`\n : `The ${aiAddonName()} add-on is off`\n const bandCredits = resolveAssistCreditBudget(org as never)\n const bandClause =\n bandCredits !== null\n ? `an AI credit band of ${bandCredits.toLocaleString()}`\n : isUncappedPlanComp(org as never)\n ? 'no AI credit band (an uncapped staff comp)'\n : 'no AI credit band'\n await context.alertStaff({\n quota: AI_MARGIN_GUARD_KEY,\n threshold: multiple,\n title,\n body:\n `${orgName(context)} has run about ` +\n `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` +\n 'threshold. Assist is a plan entitlement with no per-token ' +\n 'price, so this is margin, not revenue. ' +\n `${addonClause}, with ${bandClause}.`,\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-margin',\n })\n}\n\nasync function alertOnHardCeiling(context: UsageAlertContext): Promise<void> {\n const { org, month, spend } = context\n // The same composition the reservation refuses on, for this org, so the\n // figure announced and the figure enforced cannot drift apart.\n const ceilingUsd = assistBackstopCeilingUsd(org as never)\n const due = assistCeilingBreach({\n assistUsd: spend.assistUsd,\n ceilingUsd,\n guard: context.guards[AI_CEILING_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_CEILING_GUARD_KEY, 1)) return\n\n const ceiling = `$${Number(ceilingUsd).toFixed(0)}`\n await context.alertStaff({\n quota: AI_CEILING_GUARD_KEY,\n threshold: 1,\n title:\n `Assist is REFUSING ${orgName(context)} — the ` +\n `${ceiling} monthly spend ceiling is crossed`,\n body:\n `${orgName(context)} has run about ` +\n `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the ` +\n `${ceiling} ceiling. Every further ` +\n 'Assist request from this organization is refused until the month ' +\n 'rolls over — the assistant is not degraded or slowed, it is off. ' +\n 'The customer keeps whatever messages their plan has left, and the ' +\n 'refusal says so in its own words.',\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-ceiling',\n })\n}\n"],"names":["resolveAssistCreditBudget","aiAddonName","hasAiAddon","isUncappedPlanComp","PLATFORM_BRAND_NAME","assistCeilingBreach","assistCogsAlertThresholdUsd","assistMarginBreach","assistMarginMultiple","assistBackstopCeilingUsd","AI_MARGIN_GUARD_KEY","AI_CEILING_GUARD_KEY","STAFF_ORGS_LINK","evaluateAiUsageAlerts","context","alertOnMarginGuard","alertOnHardCeiling","orgName","orgSlug","orgId","org","month","spend","thresholdUsd","process","env","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","multiple","assistUsd","due","guard","guards","recordAlert","title","toFixed","addonClause","bandCredits","bandClause","toLocaleString","alertStaff","quota","threshold","body","link","emailContext","ceilingUsd","ceiling","Number"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { resolveAssistCreditBudget } from './assist-credits'\nimport {\n aiAddonName,\n hasAiAddon,\n isUncappedPlanComp,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n assistCeilingBreach,\n assistCogsAlertThresholdUsd,\n assistMarginBreach,\n assistMarginMultiple,\n} from './assist-spend-guards'\nimport type { UsageAlertContext } from '@aglyn/aglyn/plugin-manager/usage-alert-contributors'\nimport { assistBackstopCeilingUsd } from './assist-ceiling'\nimport { AI_ASSIST_SPEND_LINE_ID } from '../usage-axes'\n\n/**\n * The AI plugin's staff alerts on provider spend (AGL-2984), evaluated for\n * every org by the usage-alerts sweep through its usage alert contributor\n * seam. Two alerts, in this order, each deduped under its own guard key:\n *\n * - THE MARGIN GUARD warns. The meter caps an entitled org by MESSAGE count,\n * and a month of long, cache-cold exchanges on a large model is a\n * three-figure provider bill against a subscription that did not move. One\n * org's spend crossing the review threshold tells staff, and every further\n * whole multiple of the threshold tells them again, so an escalating cost\n * never goes quiet because it already spoke.\n * - THE HARD CEILING reports a stop. Past the monthly spend ceiling the\n * reservation refuses every assist request from the org, so its assistant\n * has stopped answering, and staff are told so in those words. It cannot\n * ride the margin guard: that one speaks in whole multiples of its\n * threshold, so an org climbing from the threshold to the ceiling is still\n * at 1x and says nothing. Announced once a month, because crossing is a\n * state rather than an escalating sum.\n *\n * The ceiling is the one THIS org's reservations refuse at —\n * `assistBackstopCeilingUsd` — not the repo default for everyone. That\n * default binds only a workspace with no band of its own; a band sold past,\n * a band that is a wall, and an uncapped staff comp are each measured\n * against something else, and announcing a $40 stop the reservation never\n * makes would send staff after an assistant that is still answering.\n *\n * Both are STAFF alerts. The org is not charged for this spend and has done\n * nothing wrong, so mailing the customer about our cost would be alarming and\n * meaningless. Both read the spend figure the sweep built, record through the\n * sweep's guard and send through the sweep's senders, so the first-sweep\n * seeding and the run's report apply to them exactly as to core's alerts.\n */\n\n/** The margin guard's key: the whole multiples announced this month. */\nexport const AI_MARGIN_GUARD_KEY = 'assistCogs'\n\n/** The hard ceiling's key: announced once for the month. */\nexport const AI_CEILING_GUARD_KEY = 'assistCeiling'\n\n/** Both alerts send staff to the org list. */\nconst STAFF_ORGS_LINK = '/admin/orgs'\n\n/** Evaluates one org: the margin guard, then the hard ceiling. */\nexport async function evaluateAiUsageAlerts(\n context: UsageAlertContext,\n): Promise<void> {\n await alertOnMarginGuard(context)\n await alertOnHardCeiling(context)\n}\n\n/**\n * The month's assist spend in dollars: this plugin's own line of the spend\n * the sweep built (`usageAxes` → `spendLines`), or nothing when the line is\n * absent.\n */\nfunction assistSpendUsd({ spend }: UsageAlertContext): number {\n return spend.lines.find((line) => line.id === AI_ASSIST_SPEND_LINE_ID)?.usd ?? 0\n}\n\n/** How both alerts name the org: its slug, or its id when it has none. */\nfunction orgName({ orgSlug, orgId }: UsageAlertContext): string {\n return orgSlug ?? orgId\n}\n\nasync function alertOnMarginGuard(context: UsageAlertContext): Promise<void> {\n const { org, month } = context\n const assistUsd = assistSpendUsd(context)\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n // The guard records the whole multiple announced, which is 0 for a zero or\n // non-finite threshold — a reading the breach predicate never agrees to.\n const multiple = assistMarginMultiple(assistUsd, thresholdUsd)\n const due = assistMarginBreach({\n assistUsd,\n thresholdUsd,\n guard: context.guards[AI_MARGIN_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_MARGIN_GUARD_KEY, multiple)) return\n\n const title = `Assist token spend is $${assistUsd.toFixed(2)} for one org this month`\n // The add-on state and the band: the same dollar figure is a finding on an\n // org paying for the add-on and an incident on one that is not, and the\n // reader should not have to open the console to know which.\n const addonClause = hasAiAddon(org as never)\n ? `The ${aiAddonName()} add-on is on`\n : `The ${aiAddonName()} add-on is off`\n const bandCredits = resolveAssistCreditBudget(org as never)\n const bandClause =\n bandCredits !== null\n ? `an AI credit band of ${bandCredits.toLocaleString()}`\n : isUncappedPlanComp(org as never)\n ? 'no AI credit band (an uncapped staff comp)'\n : 'no AI credit band'\n await context.alertStaff({\n quota: AI_MARGIN_GUARD_KEY,\n threshold: multiple,\n title,\n body:\n `${orgName(context)} has run about ` +\n `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` +\n 'threshold. Assist is a plan entitlement with no per-token ' +\n 'price, so this is margin, not revenue. ' +\n `${addonClause}, with ${bandClause}.`,\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-margin',\n })\n}\n\nasync function alertOnHardCeiling(context: UsageAlertContext): Promise<void> {\n const { org, month } = context\n const assistUsd = assistSpendUsd(context)\n // The same composition the reservation refuses on, for this org, so the\n // figure announced and the figure enforced cannot drift apart.\n const ceilingUsd = assistBackstopCeilingUsd(org as never)\n const due = assistCeilingBreach({\n assistUsd,\n ceilingUsd,\n guard: context.guards[AI_CEILING_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_CEILING_GUARD_KEY, 1)) return\n\n const ceiling = `$${Number(ceilingUsd).toFixed(0)}`\n await context.alertStaff({\n quota: AI_CEILING_GUARD_KEY,\n threshold: 1,\n title:\n `Assist is REFUSING ${orgName(context)} — the ` +\n `${ceiling} monthly spend ceiling is crossed`,\n body:\n `${orgName(context)} has run about ` +\n `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the ` +\n `${ceiling} ceiling. Every further ` +\n 'Assist request from this organization is refused until the month ' +\n 'rolls over — the assistant is not degraded or slowed, it is off. ' +\n 'The customer keeps whatever messages their plan has left, and the ' +\n 'refusal says so in its own words.',\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-ceiling',\n })\n}\n"],"names":["resolveAssistCreditBudget","aiAddonName","hasAiAddon","isUncappedPlanComp","PLATFORM_BRAND_NAME","assistCeilingBreach","assistCogsAlertThresholdUsd","assistMarginBreach","assistMarginMultiple","assistBackstopCeilingUsd","AI_ASSIST_SPEND_LINE_ID","AI_MARGIN_GUARD_KEY","AI_CEILING_GUARD_KEY","STAFF_ORGS_LINK","evaluateAiUsageAlerts","context","alertOnMarginGuard","alertOnHardCeiling","assistSpendUsd","spend","lines","find","line","id","usd","orgName","orgSlug","orgId","org","month","assistUsd","thresholdUsd","process","env","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","multiple","due","guard","guards","recordAlert","title","toFixed","addonClause","bandCredits","bandClause","toLocaleString","alertStaff","quota","threshold","body","link","emailContext","ceilingUsd","ceiling","Number"],"mappings":"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"}
@@ -20,7 +20,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
21
  import { AI_USAGE_BY_USER_COLLECTION, AI_USAGE_BY_USER_MONTHS_COLLECTION, AI_USAGE_BY_USER_RETENTION_MONTHS, aiUsageByUserExpiry, aiUsageByUserMonthFrom, aiUsageMonthKeys, aiUsageShare } from "../model/ai-usage-by-user.js";
22
22
  import { AI_USAGE_TOKENS_FIELD } from "../model/ai-tokens.js";
23
- import { assistCreditsFromUsd } from "@aglyn/aglyn/app-utils/assist-credits";
23
+ import { assistCreditsFromUsd } from "./assist-credits.js";
24
24
  /**
25
25
  * A request's four token counts as increments, under the names every rollup
26
26
  * keeps them by (`model/ai-tokens.ts`).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-by-user.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Straight from the SDK, not the admin lib, for the reason `assist-usage.ts`\n// gives at its head: the statics need no app, and the admin lib would drag the\n// default-app initialization into every unit test that touches a counter.\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n AI_USAGE_BY_USER_COLLECTION,\n AI_USAGE_BY_USER_MONTHS_COLLECTION,\n AI_USAGE_BY_USER_RETENTION_MONTHS,\n aiUsageByUserExpiry,\n aiUsageByUserMonthFrom,\n aiUsageMonthKeys,\n aiUsageShare,\n type AiUsageByUserMonth,\n type AiUsageKind,\n} from '../model/ai-usage-by-user'\nimport { AI_USAGE_TOKENS_FIELD } from '../model/ai-tokens'\nimport type { AiUsage } from '../providers/contract'\nimport { assistCreditsFromUsd } from '@aglyn/aglyn/app-utils/assist-credits'\n\n/**\n * PER-USER AI USAGE (AGL-2928): the writer and the readers.\n *\n * One document per person per month, under the org they spent in:\n *\n * orgs/{orgId}/aiUsageByUser/{uid}/months/{YYYY-MM}\n * { uid, month, credits, estCostUsd, requests, refusals,\n * tokens: { input, cached, cacheWrite, output },\n * byKind: { kind: credits }, byHost: { hostId: credits },\n * updatedAt, expiresAt }\n *\n * ## Written in the org rollup's own batch\n *\n * `recordUserAiUsage` takes the BATCH the org rollup is being written into\n * and adds this document to it, so the two cannot disagree: a batch commits\n * whole or not at all, and there is no path on which the workspace's month\n * moves and the person's does not. Both assist entrypoints hand it the batch\n * from inside `recordAssistCost` / `recordAssistExchange`, and the job\n * runner meters through the first of those, so a generation step lands here\n * with the job's kind and creator.\n *\n * ## No prose, no PII past the uid\n *\n * The exchange's verbatim half already expires at 180 days for the reason\n * `assist-usage.ts` gives; this rollup is integers keyed by an id, and it\n * is kept longer (`AI_USAGE_BY_USER_RETENTION_MONTHS`) because a workspace\n * admin's question is month-over-month. It is still a document ABOUT a\n * person, so it is readable only by the person and by members holding\n * `billing.view` or `org.auditLog` (the rules, and every route that serves\n * it), and an account erasure sweeps it (`eraseUserAiUsage`).\n */\n\n/** What one metered request attributes to a person. */\nexport interface RecordUserAiUsageInput {\n /** The person who asked. No uid, no attribution — the write is skipped. */\n uid: string | null | undefined\n /** The billing month the org rollup is being written into. */\n month: string\n /** The request's measured provider spend, as the org rollup counts it. */\n estCostUsd: number\n hostId: string | null | undefined\n kind: AiUsageKind\n /** The request's four token counts (AGL-2937), added to the month's `tokens`. */\n usage?: AiUsage\n}\n\n/**\n * A request's four token counts as increments, under the names every rollup\n * keeps them by (`model/ai-tokens.ts`).\n */\nexport function aiTokenIncrements(usage: AiUsage): Record<string, FirebaseFirestore.FieldValue> {\n const increment = FieldValue.increment\n return {\n input: increment(usage.inputTokens),\n cached: increment(usage.cacheReadTokens),\n cacheWrite: increment(usage.cacheWriteTokens),\n output: increment(usage.outputTokens),\n }\n}\n\nexport function userAiUsageMonthRef(\n orgRef: FirebaseFirestore.DocumentReference,\n uid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return orgRef\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .doc(month)\n}\n\n/**\n * Fold one request into a person's month, on the caller's batch.\n *\n * Returns whether anything was queued: a request with no uid (a meter that\n * could not say who asked) attributes to nobody rather than to a placeholder\n * id that would rank on every table. `set(…, { merge: true })`, never\n * `update()`: the document does not exist before a person's first request\n * of the month, and a merge into a map field adds the key rather than\n * replacing the map.\n */\nexport function recordUserAiUsage(\n batch: FirebaseFirestore.WriteBatch,\n orgRef: FirebaseFirestore.DocumentReference,\n input: RecordUserAiUsageInput,\n): boolean {\n const uid = String(input.uid ?? '').trim()\n if (!uid) return false\n const estCostUsd =\n Number.isFinite(input.estCostUsd) && input.estCostUsd > 0\n ? input.estCostUsd\n : 0\n const credits = assistCreditsFromUsd(estCostUsd)\n const increment = FieldValue.increment\n const hostId = String(input.hostId ?? '').trim()\n batch.set(\n userAiUsageMonthRef(orgRef, uid, input.month),\n {\n uid,\n month: input.month,\n credits: increment(credits),\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n ...(input.usage ? { [AI_USAGE_TOKENS_FIELD]: aiTokenIncrements(input.usage) } : {}),\n byKind: { [input.kind]: increment(credits) },\n ...(hostId ? { byHost: { [hostId]: increment(credits) } } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n // The retention window, as a Timestamp the TTL policy can act on.\n expiresAt: aiUsageByUserExpiry(input.month),\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The slice of a reservation a refusal is counted from: whether the gate\n * admitted the request, and the billing month it decided in. Structural, so\n * every door can hand over the reservation it already holds.\n */\nexport interface UserAiRefusalSource {\n allowed: boolean\n monthKey: string\n}\n\n/**\n * Count a refused reservation against the person who asked — the per-person\n * twin of `recordAssistRefusal`, and shaped the same way: fire-and-forget,\n * off the refusal's await path, swallowing every error, because a refusal\n * is the gate's cheap exit and a counter must not make it dearer than the\n * thing it counts.\n *\n * Takes the whole reservation rather than a month so every door calls it\n * the same way, once, right after reserving: an admitted reservation records\n * nothing, so the call needs no branch of its own and cannot drift from the\n * door's refusal branch. The month is the reservation's, not the clock's,\n * because that is the month the org's own refusal was counted in.\n */\nexport function recordUserAiRefusal(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string | null | undefined,\n reservation: UserAiRefusalSource,\n): void {\n if (reservation.allowed) return\n const subject = String(uid ?? '').trim()\n if (!subject) return\n try {\n const ref = userAiUsageMonthRef(\n firestore.collection('orgs').doc(orgId),\n subject,\n reservation.monthKey,\n )\n void Promise.resolve(\n ref.set(\n {\n uid: subject,\n month: reservation.monthKey,\n refusals: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n expiresAt: aiUsageByUserExpiry(reservation.monthKey),\n },\n { merge: true },\n ),\n ).catch(() => undefined)\n } catch {\n // A double without `set`, a ref that could not be built — the refusal\n // has already been decided and this must not change that.\n }\n}\n\n/** One roster row's month, with the person the roster names. */\nexport interface OrgAiUsageByUserRow extends AiUsageByUserMonth {\n /** The display name, else the email, else the uid — never blank. */\n name: string\n email: string | null\n role: string | null\n /** This person's share of the org's measured spend, in `[0, 1]`. */\n share: number\n}\n\nexport interface OrgAiUsageByUserMonth {\n month: string\n /** The org rollup's spend for the month — the denominator of every share. */\n orgEstCostUsd: number\n orgCredits: number\n /** Every current member with a document for the month, dearest first. */\n rows: OrgAiUsageByUserRow[]\n}\n\n/** How many references one `getAll` carries. */\nconst GET_ALL_CHUNK = 100\n\n/**\n * The month for every current member of the org, dearest first.\n *\n * Reads the ROSTER and then each member's month document by path, rather\n * than a collection-group query over `months`: the roster is the join the\n * table needs anyway (a uid is not a name), it is bounded by the seats the\n * plan sells, and a path read rides no index that has to be deployed before\n * a workspace can open its Usage page. A person who has left the org is not\n * listed — the roster is the set of people the reader may see — and their\n * documents expire on the retention window.\n */\nexport async function readOrgAiUsageByUser(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<OrgAiUsageByUserMonth> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [roster, rollup] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection('assistUsage').doc(month).get(),\n ])\n const orgEstCostUsd = Number(rollup.get('estCostUsd') ?? 0)\n const denominator =\n Number.isFinite(orgEstCostUsd) && orgEstCostUsd > 0 ? orgEstCostUsd : 0\n\n const members = roster.docs.map((doc) => ({\n uid: doc.id,\n email: typeof doc.get('email') === 'string' ? String(doc.get('email')) : null,\n displayName:\n typeof doc.get('displayName') === 'string' && String(doc.get('displayName')).trim()\n ? String(doc.get('displayName')).trim()\n : null,\n role: typeof doc.get('role') === 'string' ? String(doc.get('role')) : null,\n }))\n\n const rows: OrgAiUsageByUserRow[] = []\n for (let start = 0; start < members.length; start += GET_ALL_CHUNK) {\n const chunk = members.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(\n ...chunk.map((member) => userAiUsageMonthRef(orgRef, member.uid, month)),\n )\n snapshots.forEach((snapshot, index) => {\n if (!snapshot.exists) return\n const member = chunk[index]\n const usage = aiUsageByUserMonthFrom(snapshot.data(), member.uid, month)\n rows.push({\n ...usage,\n name: member.displayName ?? member.email ?? member.uid,\n email: member.email,\n role: member.role,\n share: aiUsageShare(usage.estCostUsd, denominator),\n })\n })\n }\n rows.sort(\n (a, b) =>\n b.credits - a.credits ||\n b.requests - a.requests ||\n a.name.localeCompare(b.name),\n )\n return {\n month,\n orgEstCostUsd: denominator,\n orgCredits: assistCreditsFromUsd(denominator),\n rows,\n }\n}\n\n/**\n * One person's months in one org, newest first — the member card and the\n * staff page. The document id IS the month key, so no field every writer must\n * remember carries the order.\n *\n * ## Asked for by id, never ordered (AGL-3143 §13)\n *\n * This read `.orderBy(documentId(), 'desc').limit(n)`, which looks\n * like the obvious way to say \"newest first\" and **500s in production**:\n * Firestore indexes `__name__` ASCENDING automatically and descending NOT at\n * all, so the query answers `FAILED_PRECONDITION: the query requires an\n * index`. `GET /api/ai/usage?uid=…` returned 500 on every call and the member\n * card read \"Could not read this member's AI usage — a failed read, not zero\n * usage\", while the org-wide table beside it, which orders on a real field,\n * was fine.\n *\n * ⛔ The fix is deliberately NOT the index the error offers. A promotion ships\n * Vercel and nothing else — it deploys no rules and no indexes — so a fix that\n * needs one would land green, ship, and stay broken until someone remembered a\n * separate deploy. There is nothing to remember here.\n *\n * The whole key space is knowable without a query: `aiUsageMonthKeys` is the\n * retention window and the TTL reaps everything older, so the thirteen ids are\n * asked for directly and the ones that exist come back in the order they were\n * asked. Bounded at thirteen point reads, and it cannot return a month outside\n * retention, which is what the door promises its callers.\n */\nexport async function readUserAiUsageMonths(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n limit: number = AI_USAGE_BY_USER_RETENTION_MONTHS,\n now: Date = new Date(),\n): Promise<AiUsageByUserMonth[]> {\n const months = firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n const wanted = aiUsageMonthKeys(now)\n const snapshots = await firestore.getAll(...wanted.map((month) => months.doc(month)))\n return snapshots\n .filter((doc) => doc.exists)\n .slice(0, Math.max(1, limit))\n .map((doc) => aiUsageByUserMonthFrom(doc.data(), uid, doc.id))\n}\n\nexport interface EraseUserAiUsageResult {\n /** Orgs whose `aiUsageByUser/{uid}` subtree was deleted by path. */\n orgs: number\n /**\n * Month documents found by the collection-group sweep and deleted — the\n * ones in orgs the person had already left. `null` when the sweep could\n * not run (its index is not deployed), which is not zero.\n */\n sweptMonths: number | null\n}\n\n/**\n * Remove a person's usage documents on account erasure.\n *\n * Two passes, because the documents live under the ORG and are keyed by the\n * uid: `recursiveDelete(users/{uid})` cannot see them, and neither can a\n * walk of current memberships find the months left behind in an org the\n * person was removed from. So every org the erasure knows of is deleted by\n * path first — no index, cannot fail for want of one — and then a\n * collection-group query on `uid` catches the rest. The second pass is\n * reported as `null` rather than thrown when it cannot run: the erasure has\n * a legal clock, and the by-path pass has already removed everything a\n * current membership could reach.\n */\nexport async function eraseUserAiUsage(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n orgIds: readonly string[],\n): Promise<EraseUserAiUsageResult> {\n let orgs = 0\n for (const orgId of orgIds) {\n await firestore.recursiveDelete(\n firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid),\n )\n orgs += 1\n }\n let sweptMonths: number | null = null\n try {\n const stray = await firestore\n .collectionGroup(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .where('uid', '==', uid)\n .get()\n sweptMonths = 0\n for (let start = 0; start < stray.docs.length; start += GET_ALL_CHUNK) {\n const batch = firestore.batch()\n for (const doc of stray.docs.slice(start, start + GET_ALL_CHUNK)) {\n batch.delete(doc.ref)\n sweptMonths += 1\n }\n await batch.commit()\n }\n } catch (error) {\n console.error(`eraseUserAiUsage: collection-group sweep failed for ${uid}`, error)\n }\n return { orgs, sweptMonths }\n}\n"],"names":["FieldValue","AI_USAGE_BY_USER_COLLECTION","AI_USAGE_BY_USER_MONTHS_COLLECTION","AI_USAGE_BY_USER_RETENTION_MONTHS","aiUsageByUserExpiry","aiUsageByUserMonthFrom","aiUsageMonthKeys","aiUsageShare","AI_USAGE_TOKENS_FIELD","assistCreditsFromUsd","aiTokenIncrements","usage","increment","input","inputTokens","cached","cacheReadTokens","cacheWrite","cacheWriteTokens","output","outputTokens","userAiUsageMonthRef","orgRef","uid","month","collection","doc","recordUserAiUsage","batch","String","trim","estCostUsd","Number","isFinite","credits","hostId","set","requests","byKind","kind","byHost","updatedAt","serverTimestamp","expiresAt","merge","recordUserAiRefusal","firestore","orgId","reservation","allowed","subject","ref","monthKey","Promise","resolve","refusals","catch","undefined","GET_ALL_CHUNK","readOrgAiUsageByUser","rollup","roster","all","get","orgEstCostUsd","denominator","members","docs","map","id","email","displayName","role","rows","start","length","chunk","slice","snapshots","getAll","member","forEach","snapshot","index","exists","data","push","name","share","sort","a","b","localeCompare","orgCredits","readUserAiUsageMonths","limit","now","Date","months","wanted","filter","Math","max","eraseUserAiUsage","orgIds","orgs","recursiveDelete","sweptMonths","stray","collectionGroup","where","delete","commit","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,6EAA6E;AAC7E,+EAA+E;AAC/E,0EAA0E;AAC1E,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SACEC,2BAA2B,EAC3BC,kCAAkC,EAClCC,iCAAiC,EACjCC,mBAAmB,EACnBC,sBAAsB,EACtBC,gBAAgB,EAChBC,YAAY,QAGP,+BAA2B;AAClC,SAASC,qBAAqB,QAAQ,wBAAoB;AAE1D,SAASC,oBAAoB,QAAQ,wCAAuC;AAgD5E;;;CAGC,GACD,OAAO,SAASC,kBAAkBC,KAAc;IAC9C,MAAMC,YAAYZ,WAAWY,SAAS;IACtC,OAAO;QACLC,OAAOD,UAAUD,MAAMG,WAAW;QAClCC,QAAQH,UAAUD,MAAMK,eAAe;QACvCC,YAAYL,UAAUD,MAAMO,gBAAgB;QAC5CC,QAAQP,UAAUD,MAAMS,YAAY;IACtC;AACF;AAEA,OAAO,SAASC,oBACdC,MAA2C,EAC3CC,GAAW,EACXC,KAAa;IAEb,OAAOF,OACJG,UAAU,CAACxB,6BACXyB,GAAG,CAACH,KACJE,UAAU,CAACvB,oCACXwB,GAAG,CAACF;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASG,kBACdC,KAAmC,EACnCN,MAA2C,EAC3CT,KAA6B;QAEVA,YAQGA;IARtB,MAAMU,MAAMM,QAAOhB,aAAAA,MAAMU,GAAG,YAATV,aAAa,IAAIiB,IAAI;IACxC,IAAI,CAACP,KAAK,OAAO;IACjB,MAAMQ,aACJC,OAAOC,QAAQ,CAACpB,MAAMkB,UAAU,KAAKlB,MAAMkB,UAAU,GAAG,IACpDlB,MAAMkB,UAAU,GAChB;IACN,MAAMG,UAAUzB,qBAAqBsB;IACrC,MAAMnB,YAAYZ,WAAWY,SAAS;IACtC,MAAMuB,SAASN,QAAOhB,gBAAAA,MAAMsB,MAAM,YAAZtB,gBAAgB,IAAIiB,IAAI;IAC9CF,MAAMQ,GAAG,CACPf,oBAAoBC,QAAQC,KAAKV,MAAMW,KAAK,GAC5C;QACED;QACAC,OAAOX,MAAMW,KAAK;QAClBU,SAAStB,UAAUsB;QACnBH,YAAYnB,UAAUmB;QACtBM,UAAUzB,UAAU;OAChBC,MAAMF,KAAK,GAAG;QAAE,CAACH,sBAAsB,EAAEE,kBAAkBG,MAAMF,KAAK;IAAE,IAAI,CAAC;QACjF2B,QAAQ;YAAE,CAACzB,MAAM0B,IAAI,CAAC,EAAE3B,UAAUsB;QAAS;OACvCC,SAAS;QAAEK,QAAQ;YAAE,CAACL,OAAO,EAAEvB,UAAUsB;QAAS;IAAE,IAAI,CAAC;QAC7DO,WAAWzC,WAAW0C,eAAe;QACrC,kEAAkE;QAClEC,WAAWvC,oBAAoBS,MAAMW,KAAK;QAE5C;QAAEoB,OAAO;IAAK;IAEhB,OAAO;AACT;AAYA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,oBACdC,SAAsC,EACtCC,KAAa,EACbxB,GAA8B,EAC9ByB,WAAgC;IAEhC,IAAIA,YAAYC,OAAO,EAAE;IACzB,MAAMC,UAAUrB,OAAON,cAAAA,MAAO,IAAIO,IAAI;IACtC,IAAI,CAACoB,SAAS;IACd,IAAI;QACF,MAAMC,MAAM9B,oBACVyB,UAAUrB,UAAU,CAAC,QAAQC,GAAG,CAACqB,QACjCG,SACAF,YAAYI,QAAQ;QAEtB,KAAKC,QAAQC,OAAO,CAClBH,IAAIf,GAAG,CACL;YACEb,KAAK2B;YACL1B,OAAOwB,YAAYI,QAAQ;YAC3BG,UAAUvD,WAAWY,SAAS,CAAC;YAC/B6B,WAAWzC,WAAW0C,eAAe;YACrCC,WAAWvC,oBAAoB4C,YAAYI,QAAQ;QACrD,GACA;YAAER,OAAO;QAAK,IAEhBY,KAAK,CAAC,IAAMC;IAChB,EAAE,eAAM;IACN,sEAAsE;IACtE,0DAA0D;IAC5D;AACF;AAqBA,8CAA8C,GAC9C,MAAMC,gBAAgB;AAEtB;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,qBACpBb,SAAsC,EACtCC,KAAa,EACbvB,KAAa;QAOgBoC;IAL7B,MAAMtC,SAASwB,UAAUrB,UAAU,CAAC,QAAQC,GAAG,CAACqB;IAChD,MAAM,CAACc,QAAQD,OAAO,GAAG,MAAMP,QAAQS,GAAG,CAAC;QACzCxC,OAAOG,UAAU,CAAC,WAAWsC,GAAG;QAChCzC,OAAOG,UAAU,CAAC,eAAeC,GAAG,CAACF,OAAOuC,GAAG;KAChD;IACD,MAAMC,gBAAgBhC,QAAO4B,cAAAA,OAAOG,GAAG,CAAC,yBAAXH,cAA4B;IACzD,MAAMK,cACJjC,OAAOC,QAAQ,CAAC+B,kBAAkBA,gBAAgB,IAAIA,gBAAgB;IAExE,MAAME,UAAUL,OAAOM,IAAI,CAACC,GAAG,CAAC,CAAC1C,MAAS,CAAA;YACxCH,KAAKG,IAAI2C,EAAE;YACXC,OAAO,OAAO5C,IAAIqC,GAAG,CAAC,aAAa,WAAWlC,OAAOH,IAAIqC,GAAG,CAAC,YAAY;YACzEQ,aACE,OAAO7C,IAAIqC,GAAG,CAAC,mBAAmB,YAAYlC,OAAOH,IAAIqC,GAAG,CAAC,gBAAgBjC,IAAI,KAC7ED,OAAOH,IAAIqC,GAAG,CAAC,gBAAgBjC,IAAI,KACnC;YACN0C,MAAM,OAAO9C,IAAIqC,GAAG,CAAC,YAAY,WAAWlC,OAAOH,IAAIqC,GAAG,CAAC,WAAW;QACxE,CAAA;IAEA,MAAMU,OAA8B,EAAE;IACtC,IAAK,IAAIC,QAAQ,GAAGA,QAAQR,QAAQS,MAAM,EAAED,SAAShB,cAAe;QAClE,MAAMkB,QAAQV,QAAQW,KAAK,CAACH,OAAOA,QAAQhB;QAC3C,MAAMoB,YAAY,MAAMhC,UAAUiC,MAAM,IACnCH,MAAMR,GAAG,CAAC,CAACY,SAAW3D,oBAAoBC,QAAQ0D,OAAOzD,GAAG,EAAEC;QAEnEsD,UAAUG,OAAO,CAAC,CAACC,UAAUC;gBAMnBH,MAAAA;YALR,IAAI,CAACE,SAASE,MAAM,EAAE;YACtB,MAAMJ,SAASJ,KAAK,CAACO,MAAM;YAC3B,MAAMxE,QAAQN,uBAAuB6E,SAASG,IAAI,IAAIL,OAAOzD,GAAG,EAAEC;YAClEiD,KAAKa,IAAI,CAAC,aACL3E;gBACH4E,IAAI,GAAEP,QAAAA,sBAAAA,OAAOT,WAAW,YAAlBS,sBAAsBA,OAAOV,KAAK,YAAlCU,OAAsCA,OAAOzD,GAAG;gBACtD+C,OAAOU,OAAOV,KAAK;gBACnBE,MAAMQ,OAAOR,IAAI;gBACjBgB,OAAOjF,aAAaI,MAAMoB,UAAU,EAAEkC;;QAE1C;IACF;IACAQ,KAAKgB,IAAI,CACP,CAACC,GAAGC,IACFA,EAAEzD,OAAO,GAAGwD,EAAExD,OAAO,IACrByD,EAAEtD,QAAQ,GAAGqD,EAAErD,QAAQ,IACvBqD,EAAEH,IAAI,CAACK,aAAa,CAACD,EAAEJ,IAAI;IAE/B,OAAO;QACL/D;QACAwC,eAAeC;QACf4B,YAAYpF,qBAAqBwD;QACjCQ;IACF;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,eAAeqB,sBACpBhD,SAAsC,EACtCC,KAAa,EACbxB,GAAW,EACXwE,QAAgB5F,iCAAiC,EACjD6F,MAAY,IAAIC,MAAM;IAEtB,MAAMC,SAASpD,UACZrB,UAAU,CAAC,QACXC,GAAG,CAACqB,OACJtB,UAAU,CAACxB,6BACXyB,GAAG,CAACH,KACJE,UAAU,CAACvB;IACd,MAAMiG,SAAS7F,iBAAiB0F;IAChC,MAAMlB,YAAY,MAAMhC,UAAUiC,MAAM,IAAIoB,OAAO/B,GAAG,CAAC,CAAC5C,QAAU0E,OAAOxE,GAAG,CAACF;IAC7E,OAAOsD,UACJsB,MAAM,CAAC,CAAC1E,MAAQA,IAAI0D,MAAM,EAC1BP,KAAK,CAAC,GAAGwB,KAAKC,GAAG,CAAC,GAAGP,QACrB3B,GAAG,CAAC,CAAC1C,MAAQrB,uBAAuBqB,IAAI2D,IAAI,IAAI9D,KAAKG,IAAI2C,EAAE;AAChE;AAaA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAekC,iBACpBzD,SAAsC,EACtCvB,GAAW,EACXiF,MAAyB;IAEzB,IAAIC,OAAO;IACX,KAAK,MAAM1D,SAASyD,OAAQ;QAC1B,MAAM1D,UAAU4D,eAAe,CAC7B5D,UACGrB,UAAU,CAAC,QACXC,GAAG,CAACqB,OACJtB,UAAU,CAACxB,6BACXyB,GAAG,CAACH;QAETkF,QAAQ;IACV;IACA,IAAIE,cAA6B;IACjC,IAAI;QACF,MAAMC,QAAQ,MAAM9D,UACjB+D,eAAe,CAAC3G,oCAChB4G,KAAK,CAAC,OAAO,MAAMvF,KACnBwC,GAAG;QACN4C,cAAc;QACd,IAAK,IAAIjC,QAAQ,GAAGA,QAAQkC,MAAMzC,IAAI,CAACQ,MAAM,EAAED,SAAShB,cAAe;YACrE,MAAM9B,QAAQkB,UAAUlB,KAAK;YAC7B,KAAK,MAAMF,OAAOkF,MAAMzC,IAAI,CAACU,KAAK,CAACH,OAAOA,QAAQhB,eAAgB;gBAChE9B,MAAMmF,MAAM,CAACrF,IAAIyB,GAAG;gBACpBwD,eAAe;YACjB;YACA,MAAM/E,MAAMoF,MAAM;QACpB;IACF,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,oDAAoD,EAAE1F,KAAK,EAAE0F;IAC9E;IACA,OAAO;QAAER;QAAME;IAAY;AAC7B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-by-user.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Straight from the SDK, not the admin lib, for the reason `assist-usage.ts`\n// gives at its head: the statics need no app, and the admin lib would drag the\n// default-app initialization into every unit test that touches a counter.\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n AI_USAGE_BY_USER_COLLECTION,\n AI_USAGE_BY_USER_MONTHS_COLLECTION,\n AI_USAGE_BY_USER_RETENTION_MONTHS,\n aiUsageByUserExpiry,\n aiUsageByUserMonthFrom,\n aiUsageMonthKeys,\n aiUsageShare,\n type AiUsageByUserMonth,\n type AiUsageKind,\n} from '../model/ai-usage-by-user'\nimport { AI_USAGE_TOKENS_FIELD } from '../model/ai-tokens'\nimport type { AiUsage } from '../providers/contract'\nimport { assistCreditsFromUsd } from './assist-credits'\n\n/**\n * PER-USER AI USAGE (AGL-2928): the writer and the readers.\n *\n * One document per person per month, under the org they spent in:\n *\n * orgs/{orgId}/aiUsageByUser/{uid}/months/{YYYY-MM}\n * { uid, month, credits, estCostUsd, requests, refusals,\n * tokens: { input, cached, cacheWrite, output },\n * byKind: { kind: credits }, byHost: { hostId: credits },\n * updatedAt, expiresAt }\n *\n * ## Written in the org rollup's own batch\n *\n * `recordUserAiUsage` takes the BATCH the org rollup is being written into\n * and adds this document to it, so the two cannot disagree: a batch commits\n * whole or not at all, and there is no path on which the workspace's month\n * moves and the person's does not. Both assist entrypoints hand it the batch\n * from inside `recordAssistCost` / `recordAssistExchange`, and the job\n * runner meters through the first of those, so a generation step lands here\n * with the job's kind and creator.\n *\n * ## No prose, no PII past the uid\n *\n * The exchange's verbatim half already expires at 180 days for the reason\n * `assist-usage.ts` gives; this rollup is integers keyed by an id, and it\n * is kept longer (`AI_USAGE_BY_USER_RETENTION_MONTHS`) because a workspace\n * admin's question is month-over-month. It is still a document ABOUT a\n * person, so it is readable only by the person and by members holding\n * `billing.view` or `org.auditLog` (the rules, and every route that serves\n * it), and an account erasure sweeps it (`eraseUserAiUsage`).\n */\n\n/** What one metered request attributes to a person. */\nexport interface RecordUserAiUsageInput {\n /** The person who asked. No uid, no attribution — the write is skipped. */\n uid: string | null | undefined\n /** The billing month the org rollup is being written into. */\n month: string\n /** The request's measured provider spend, as the org rollup counts it. */\n estCostUsd: number\n hostId: string | null | undefined\n kind: AiUsageKind\n /** The request's four token counts (AGL-2937), added to the month's `tokens`. */\n usage?: AiUsage\n}\n\n/**\n * A request's four token counts as increments, under the names every rollup\n * keeps them by (`model/ai-tokens.ts`).\n */\nexport function aiTokenIncrements(usage: AiUsage): Record<string, FirebaseFirestore.FieldValue> {\n const increment = FieldValue.increment\n return {\n input: increment(usage.inputTokens),\n cached: increment(usage.cacheReadTokens),\n cacheWrite: increment(usage.cacheWriteTokens),\n output: increment(usage.outputTokens),\n }\n}\n\nexport function userAiUsageMonthRef(\n orgRef: FirebaseFirestore.DocumentReference,\n uid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return orgRef\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .doc(month)\n}\n\n/**\n * Fold one request into a person's month, on the caller's batch.\n *\n * Returns whether anything was queued: a request with no uid (a meter that\n * could not say who asked) attributes to nobody rather than to a placeholder\n * id that would rank on every table. `set(…, { merge: true })`, never\n * `update()`: the document does not exist before a person's first request\n * of the month, and a merge into a map field adds the key rather than\n * replacing the map.\n */\nexport function recordUserAiUsage(\n batch: FirebaseFirestore.WriteBatch,\n orgRef: FirebaseFirestore.DocumentReference,\n input: RecordUserAiUsageInput,\n): boolean {\n const uid = String(input.uid ?? '').trim()\n if (!uid) return false\n const estCostUsd =\n Number.isFinite(input.estCostUsd) && input.estCostUsd > 0\n ? input.estCostUsd\n : 0\n const credits = assistCreditsFromUsd(estCostUsd)\n const increment = FieldValue.increment\n const hostId = String(input.hostId ?? '').trim()\n batch.set(\n userAiUsageMonthRef(orgRef, uid, input.month),\n {\n uid,\n month: input.month,\n credits: increment(credits),\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n ...(input.usage ? { [AI_USAGE_TOKENS_FIELD]: aiTokenIncrements(input.usage) } : {}),\n byKind: { [input.kind]: increment(credits) },\n ...(hostId ? { byHost: { [hostId]: increment(credits) } } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n // The retention window, as a Timestamp the TTL policy can act on.\n expiresAt: aiUsageByUserExpiry(input.month),\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The slice of a reservation a refusal is counted from: whether the gate\n * admitted the request, and the billing month it decided in. Structural, so\n * every door can hand over the reservation it already holds.\n */\nexport interface UserAiRefusalSource {\n allowed: boolean\n monthKey: string\n}\n\n/**\n * Count a refused reservation against the person who asked — the per-person\n * twin of `recordAssistRefusal`, and shaped the same way: fire-and-forget,\n * off the refusal's await path, swallowing every error, because a refusal\n * is the gate's cheap exit and a counter must not make it dearer than the\n * thing it counts.\n *\n * Takes the whole reservation rather than a month so every door calls it\n * the same way, once, right after reserving: an admitted reservation records\n * nothing, so the call needs no branch of its own and cannot drift from the\n * door's refusal branch. The month is the reservation's, not the clock's,\n * because that is the month the org's own refusal was counted in.\n */\nexport function recordUserAiRefusal(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string | null | undefined,\n reservation: UserAiRefusalSource,\n): void {\n if (reservation.allowed) return\n const subject = String(uid ?? '').trim()\n if (!subject) return\n try {\n const ref = userAiUsageMonthRef(\n firestore.collection('orgs').doc(orgId),\n subject,\n reservation.monthKey,\n )\n void Promise.resolve(\n ref.set(\n {\n uid: subject,\n month: reservation.monthKey,\n refusals: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n expiresAt: aiUsageByUserExpiry(reservation.monthKey),\n },\n { merge: true },\n ),\n ).catch(() => undefined)\n } catch {\n // A double without `set`, a ref that could not be built — the refusal\n // has already been decided and this must not change that.\n }\n}\n\n/** One roster row's month, with the person the roster names. */\nexport interface OrgAiUsageByUserRow extends AiUsageByUserMonth {\n /** The display name, else the email, else the uid — never blank. */\n name: string\n email: string | null\n role: string | null\n /** This person's share of the org's measured spend, in `[0, 1]`. */\n share: number\n}\n\nexport interface OrgAiUsageByUserMonth {\n month: string\n /** The org rollup's spend for the month — the denominator of every share. */\n orgEstCostUsd: number\n orgCredits: number\n /** Every current member with a document for the month, dearest first. */\n rows: OrgAiUsageByUserRow[]\n}\n\n/** How many references one `getAll` carries. */\nconst GET_ALL_CHUNK = 100\n\n/**\n * The month for every current member of the org, dearest first.\n *\n * Reads the ROSTER and then each member's month document by path, rather\n * than a collection-group query over `months`: the roster is the join the\n * table needs anyway (a uid is not a name), it is bounded by the seats the\n * plan sells, and a path read rides no index that has to be deployed before\n * a workspace can open its Usage page. A person who has left the org is not\n * listed — the roster is the set of people the reader may see — and their\n * documents expire on the retention window.\n */\nexport async function readOrgAiUsageByUser(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<OrgAiUsageByUserMonth> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [roster, rollup] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection('assistUsage').doc(month).get(),\n ])\n const orgEstCostUsd = Number(rollup.get('estCostUsd') ?? 0)\n const denominator =\n Number.isFinite(orgEstCostUsd) && orgEstCostUsd > 0 ? orgEstCostUsd : 0\n\n const members = roster.docs.map((doc) => ({\n uid: doc.id,\n email: typeof doc.get('email') === 'string' ? String(doc.get('email')) : null,\n displayName:\n typeof doc.get('displayName') === 'string' && String(doc.get('displayName')).trim()\n ? String(doc.get('displayName')).trim()\n : null,\n role: typeof doc.get('role') === 'string' ? String(doc.get('role')) : null,\n }))\n\n const rows: OrgAiUsageByUserRow[] = []\n for (let start = 0; start < members.length; start += GET_ALL_CHUNK) {\n const chunk = members.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(\n ...chunk.map((member) => userAiUsageMonthRef(orgRef, member.uid, month)),\n )\n snapshots.forEach((snapshot, index) => {\n if (!snapshot.exists) return\n const member = chunk[index]\n const usage = aiUsageByUserMonthFrom(snapshot.data(), member.uid, month)\n rows.push({\n ...usage,\n name: member.displayName ?? member.email ?? member.uid,\n email: member.email,\n role: member.role,\n share: aiUsageShare(usage.estCostUsd, denominator),\n })\n })\n }\n rows.sort(\n (a, b) =>\n b.credits - a.credits ||\n b.requests - a.requests ||\n a.name.localeCompare(b.name),\n )\n return {\n month,\n orgEstCostUsd: denominator,\n orgCredits: assistCreditsFromUsd(denominator),\n rows,\n }\n}\n\n/**\n * One person's months in one org, newest first — the member card and the\n * staff page. The document id IS the month key, so no field every writer must\n * remember carries the order.\n *\n * ## Asked for by id, never ordered (AGL-3143 §13)\n *\n * This read `.orderBy(documentId(), 'desc').limit(n)`, which looks\n * like the obvious way to say \"newest first\" and **500s in production**:\n * Firestore indexes `__name__` ASCENDING automatically and descending NOT at\n * all, so the query answers `FAILED_PRECONDITION: the query requires an\n * index`. `GET /api/ai/usage?uid=…` returned 500 on every call and the member\n * card read \"Could not read this member's AI usage — a failed read, not zero\n * usage\", while the org-wide table beside it, which orders on a real field,\n * was fine.\n *\n * ⛔ The fix is deliberately NOT the index the error offers. A promotion ships\n * Vercel and nothing else — it deploys no rules and no indexes — so a fix that\n * needs one would land green, ship, and stay broken until someone remembered a\n * separate deploy. There is nothing to remember here.\n *\n * The whole key space is knowable without a query: `aiUsageMonthKeys` is the\n * retention window and the TTL reaps everything older, so the thirteen ids are\n * asked for directly and the ones that exist come back in the order they were\n * asked. Bounded at thirteen point reads, and it cannot return a month outside\n * retention, which is what the door promises its callers.\n */\nexport async function readUserAiUsageMonths(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n limit: number = AI_USAGE_BY_USER_RETENTION_MONTHS,\n now: Date = new Date(),\n): Promise<AiUsageByUserMonth[]> {\n const months = firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n const wanted = aiUsageMonthKeys(now)\n const snapshots = await firestore.getAll(...wanted.map((month) => months.doc(month)))\n return snapshots\n .filter((doc) => doc.exists)\n .slice(0, Math.max(1, limit))\n .map((doc) => aiUsageByUserMonthFrom(doc.data(), uid, doc.id))\n}\n\nexport interface EraseUserAiUsageResult {\n /** Orgs whose `aiUsageByUser/{uid}` subtree was deleted by path. */\n orgs: number\n /**\n * Month documents found by the collection-group sweep and deleted — the\n * ones in orgs the person had already left. `null` when the sweep could\n * not run (its index is not deployed), which is not zero.\n */\n sweptMonths: number | null\n}\n\n/**\n * Remove a person's usage documents on account erasure.\n *\n * Two passes, because the documents live under the ORG and are keyed by the\n * uid: `recursiveDelete(users/{uid})` cannot see them, and neither can a\n * walk of current memberships find the months left behind in an org the\n * person was removed from. So every org the erasure knows of is deleted by\n * path first — no index, cannot fail for want of one — and then a\n * collection-group query on `uid` catches the rest. The second pass is\n * reported as `null` rather than thrown when it cannot run: the erasure has\n * a legal clock, and the by-path pass has already removed everything a\n * current membership could reach.\n */\nexport async function eraseUserAiUsage(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n orgIds: readonly string[],\n): Promise<EraseUserAiUsageResult> {\n let orgs = 0\n for (const orgId of orgIds) {\n await firestore.recursiveDelete(\n firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid),\n )\n orgs += 1\n }\n let sweptMonths: number | null = null\n try {\n const stray = await firestore\n .collectionGroup(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .where('uid', '==', uid)\n .get()\n sweptMonths = 0\n for (let start = 0; start < stray.docs.length; start += GET_ALL_CHUNK) {\n const batch = firestore.batch()\n for (const doc of stray.docs.slice(start, start + GET_ALL_CHUNK)) {\n batch.delete(doc.ref)\n sweptMonths += 1\n }\n await batch.commit()\n }\n } catch (error) {\n console.error(`eraseUserAiUsage: collection-group sweep failed for ${uid}`, error)\n }\n return { orgs, sweptMonths }\n}\n"],"names":["FieldValue","AI_USAGE_BY_USER_COLLECTION","AI_USAGE_BY_USER_MONTHS_COLLECTION","AI_USAGE_BY_USER_RETENTION_MONTHS","aiUsageByUserExpiry","aiUsageByUserMonthFrom","aiUsageMonthKeys","aiUsageShare","AI_USAGE_TOKENS_FIELD","assistCreditsFromUsd","aiTokenIncrements","usage","increment","input","inputTokens","cached","cacheReadTokens","cacheWrite","cacheWriteTokens","output","outputTokens","userAiUsageMonthRef","orgRef","uid","month","collection","doc","recordUserAiUsage","batch","String","trim","estCostUsd","Number","isFinite","credits","hostId","set","requests","byKind","kind","byHost","updatedAt","serverTimestamp","expiresAt","merge","recordUserAiRefusal","firestore","orgId","reservation","allowed","subject","ref","monthKey","Promise","resolve","refusals","catch","undefined","GET_ALL_CHUNK","readOrgAiUsageByUser","rollup","roster","all","get","orgEstCostUsd","denominator","members","docs","map","id","email","displayName","role","rows","start","length","chunk","slice","snapshots","getAll","member","forEach","snapshot","index","exists","data","push","name","share","sort","a","b","localeCompare","orgCredits","readUserAiUsageMonths","limit","now","Date","months","wanted","filter","Math","max","eraseUserAiUsage","orgIds","orgs","recursiveDelete","sweptMonths","stray","collectionGroup","where","delete","commit","error","console"],"mappings":"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"}
@@ -21,8 +21,8 @@ import type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-bi
21
21
  *
22
22
  * ## Why the repo default must not bind an org that has a band
23
23
  *
24
- * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `usage-budget.ts`, beside
25
- * the alert that announces the ceiling) is $40, which was sized as a
24
+ * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `assist-spend-guards.ts`,
25
+ * beside the alert that announces the ceiling) is $40, which was sized as a
26
26
  * runaway guard back when every org's assist spend was bounded by a message
27
27
  * cap. It is BELOW what Agency and Enterprise include, so applying it to a
28
28
  * plan band would refuse those workspaces partway through capacity they are