@headless-lms/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/dist/assets/events.d.ts +90 -9
  2. package/dist/assets/events.js +19 -3
  3. package/dist/assets/events.js.map +1 -1
  4. package/dist/assets/file-urls.d.ts +20 -0
  5. package/dist/assets/file-urls.js +25 -0
  6. package/dist/assets/file-urls.js.map +1 -0
  7. package/dist/assets/index.d.ts +5 -4
  8. package/dist/assets/index.js +3 -2
  9. package/dist/assets/model.d.ts +2 -2
  10. package/dist/assets/ports.d.ts +45 -15
  11. package/dist/assets/service.d.ts +39 -7
  12. package/dist/assets/service.js +292 -37
  13. package/dist/assets/service.js.map +1 -1
  14. package/dist/automations/actions.d.ts +12 -15
  15. package/dist/automations/actions.js +188 -30
  16. package/dist/automations/actions.js.map +1 -1
  17. package/dist/automations/catalog.d.ts +5 -1
  18. package/dist/automations/catalog.js +514 -197
  19. package/dist/automations/catalog.js.map +1 -1
  20. package/dist/automations/community-derivations.d.ts +14 -0
  21. package/dist/automations/community-derivations.js +439 -0
  22. package/dist/automations/community-derivations.js.map +1 -0
  23. package/dist/automations/community-mail.d.ts +112 -0
  24. package/dist/automations/community-mail.js +90 -0
  25. package/dist/automations/community-mail.js.map +1 -0
  26. package/dist/automations/community-notifications.d.ts +16 -0
  27. package/dist/automations/community-notifications.js +84 -0
  28. package/dist/automations/community-notifications.js.map +1 -0
  29. package/dist/automations/events.d.ts +74 -24
  30. package/dist/automations/events.js +5 -5
  31. package/dist/automations/events.js.map +1 -1
  32. package/dist/automations/index.d.ts +7 -5
  33. package/dist/automations/index.js +3 -2
  34. package/dist/automations/model.d.ts +20 -3
  35. package/dist/automations/model.js +36 -3
  36. package/dist/automations/model.js.map +1 -1
  37. package/dist/automations/ports.d.ts +27 -8
  38. package/dist/automations/schedule.d.ts +6 -0
  39. package/dist/automations/schedule.js +41 -0
  40. package/dist/automations/schedule.js.map +1 -0
  41. package/dist/automations/seeds.d.ts +17 -0
  42. package/dist/automations/seeds.js +235 -0
  43. package/dist/automations/seeds.js.map +1 -0
  44. package/dist/automations/service.d.ts +41 -6
  45. package/dist/automations/service.js +542 -59
  46. package/dist/automations/service.js.map +1 -1
  47. package/dist/automations/types.d.ts +2 -2
  48. package/dist/automations/webhook-signing.d.ts +7 -0
  49. package/dist/automations/webhook-signing.js +22 -0
  50. package/dist/automations/webhook-signing.js.map +1 -0
  51. package/dist/content/events.d.ts +1013 -25
  52. package/dist/content/events.js +168 -19
  53. package/dist/content/events.js.map +1 -1
  54. package/dist/content/index.d.ts +5 -5
  55. package/dist/content/index.js +2 -2
  56. package/dist/content/model.d.ts +2 -2
  57. package/dist/content/ports.d.ts +109 -25
  58. package/dist/content/service.d.ts +68 -23
  59. package/dist/content/service.js +647 -60
  60. package/dist/content/service.js.map +1 -1
  61. package/dist/content/types.d.ts +2 -2
  62. package/dist/discussion/authors.d.ts +10 -0
  63. package/dist/discussion/authors.js +23 -0
  64. package/dist/discussion/authors.js.map +1 -0
  65. package/dist/discussion/chats.d.ts +104 -0
  66. package/dist/discussion/chats.js +798 -0
  67. package/dist/discussion/chats.js.map +1 -0
  68. package/dist/discussion/events.d.ts +633 -14
  69. package/dist/discussion/events.js +134 -5
  70. package/dist/discussion/events.js.map +1 -1
  71. package/dist/discussion/index.d.ts +7 -3
  72. package/dist/discussion/index.js +4 -1
  73. package/dist/discussion/model.d.ts +1 -2
  74. package/dist/discussion/ports.d.ts +288 -27
  75. package/dist/discussion/ports.js +6 -1
  76. package/dist/discussion/ports.js.map +1 -0
  77. package/dist/discussion/posts.d.ts +103 -0
  78. package/dist/discussion/posts.js +723 -0
  79. package/dist/discussion/posts.js.map +1 -0
  80. package/dist/discussion/service.d.ts +33 -11
  81. package/dist/discussion/service.js +297 -81
  82. package/dist/discussion/service.js.map +1 -1
  83. package/dist/entitlements/events.d.ts +264 -13
  84. package/dist/entitlements/events.js +49 -5
  85. package/dist/entitlements/events.js.map +1 -1
  86. package/dist/entitlements/index.d.ts +5 -4
  87. package/dist/entitlements/index.js +2 -1
  88. package/dist/entitlements/model.d.ts +3 -2
  89. package/dist/entitlements/ports.d.ts +124 -11
  90. package/dist/entitlements/rules.d.ts +12 -0
  91. package/dist/entitlements/rules.js +17 -0
  92. package/dist/entitlements/rules.js.map +1 -0
  93. package/dist/entitlements/service.d.ts +54 -3
  94. package/dist/entitlements/service.js +473 -2
  95. package/dist/entitlements/service.js.map +1 -1
  96. package/dist/identity/access.d.ts +6 -0
  97. package/dist/identity/access.js +7 -0
  98. package/dist/identity/access.js.map +1 -0
  99. package/dist/identity/events.d.ts +17 -3
  100. package/dist/identity/events.js +12 -2
  101. package/dist/identity/events.js.map +1 -1
  102. package/dist/identity/index.d.ts +6 -4
  103. package/dist/identity/index.js +3 -1
  104. package/dist/identity/model.d.ts +2 -1
  105. package/dist/identity/notifications.d.ts +27 -0
  106. package/dist/identity/notifications.js +101 -0
  107. package/dist/identity/notifications.js.map +1 -0
  108. package/dist/identity/ports.d.ts +76 -3
  109. package/dist/identity/service.d.ts +17 -3
  110. package/dist/identity/service.js +73 -5
  111. package/dist/identity/service.js.map +1 -1
  112. package/dist/integrations/events.d.ts +27 -3
  113. package/dist/integrations/events.js +15 -3
  114. package/dist/integrations/events.js.map +1 -1
  115. package/dist/integrations/model.js +1 -1
  116. package/dist/integrations/model.js.map +1 -1
  117. package/dist/integrations/service.js +2 -2
  118. package/dist/integrations/service.js.map +1 -1
  119. package/dist/organizations/access-scopes.d.ts +19 -0
  120. package/dist/organizations/access-scopes.js +89 -0
  121. package/dist/organizations/access-scopes.js.map +1 -0
  122. package/dist/organizations/events.d.ts +234 -25
  123. package/dist/organizations/events.js +58 -9
  124. package/dist/organizations/events.js.map +1 -1
  125. package/dist/organizations/index.d.ts +8 -7
  126. package/dist/organizations/index.js +4 -3
  127. package/dist/organizations/members.d.ts +8 -5
  128. package/dist/organizations/members.js.map +1 -1
  129. package/dist/organizations/ports.d.ts +20 -21
  130. package/dist/organizations/roles.d.ts +6 -10
  131. package/dist/organizations/roles.js +19 -7
  132. package/dist/organizations/roles.js.map +1 -1
  133. package/dist/organizations/service.d.ts +28 -14
  134. package/dist/organizations/service.js +274 -105
  135. package/dist/organizations/service.js.map +1 -1
  136. package/dist/organizations/types.d.ts +2 -11
  137. package/dist/progress/events.d.ts +34 -2
  138. package/dist/progress/events.js +15 -2
  139. package/dist/progress/events.js.map +1 -1
  140. package/dist/progress/index.d.ts +2 -2
  141. package/dist/progress/ports.d.ts +6 -1
  142. package/dist/progress/service.d.ts +5 -3
  143. package/dist/progress/service.js +9 -3
  144. package/dist/progress/service.js.map +1 -1
  145. package/dist/reporting/admin/index.d.ts +4 -0
  146. package/dist/reporting/admin/index.js +2 -0
  147. package/dist/reporting/admin/model.d.ts +108 -0
  148. package/dist/reporting/admin/ports.d.ts +27 -0
  149. package/dist/reporting/admin/service.d.ts +22 -0
  150. package/dist/reporting/admin/service.js +38 -0
  151. package/dist/reporting/admin/service.js.map +1 -0
  152. package/dist/reporting/member/index.d.ts +5 -0
  153. package/dist/reporting/member/index.js +2 -0
  154. package/dist/reporting/member/model.d.ts +124 -0
  155. package/dist/reporting/member/ports.d.ts +45 -0
  156. package/dist/reporting/member/service.d.ts +73 -0
  157. package/dist/reporting/member/service.js +286 -0
  158. package/dist/reporting/member/service.js.map +1 -0
  159. package/dist/shared/actor.d.ts +9 -0
  160. package/dist/shared/errors.d.ts +7 -1
  161. package/dist/shared/errors.js +10 -1
  162. package/dist/shared/errors.js.map +1 -1
  163. package/dist/shared/events.d.ts +5 -3
  164. package/dist/shared/events.js +1 -0
  165. package/dist/shared/events.js.map +1 -1
  166. package/dist/shared/id.d.ts +12 -0
  167. package/dist/shared/id.js +13 -1
  168. package/dist/shared/id.js.map +1 -1
  169. package/dist/shared/mailer.d.ts +6 -1
  170. package/dist/shared/mailer.js.map +1 -1
  171. package/dist/shared/network.d.ts +10 -0
  172. package/dist/shared/network.js +211 -0
  173. package/dist/shared/network.js.map +1 -0
  174. package/dist/shared/settings.js.map +1 -1
  175. package/dist/types/assets.d.ts +2 -2
  176. package/dist/types/automations.d.ts +21 -2
  177. package/dist/types/content.d.ts +2 -2
  178. package/dist/types/discussion.d.ts +12 -3
  179. package/dist/types/email-templates.d.ts +124 -1
  180. package/dist/types/entitlements.d.ts +2 -2
  181. package/dist/types/events.d.ts +3 -3
  182. package/dist/types/identity.d.ts +48 -2
  183. package/dist/types/index.d.ts +10 -10
  184. package/dist/types/organizations.d.ts +2 -2
  185. package/dist/types/ports.d.ts +7 -1
  186. package/dist/types/schemas/assets.d.ts +67 -36
  187. package/dist/types/schemas/assets.js +19 -13
  188. package/dist/types/schemas/assets.js.map +1 -1
  189. package/dist/types/schemas/automations.d.ts +88 -4
  190. package/dist/types/schemas/automations.js +46 -6
  191. package/dist/types/schemas/automations.js.map +1 -1
  192. package/dist/types/schemas/content.d.ts +385 -20
  193. package/dist/types/schemas/content.js +178 -15
  194. package/dist/types/schemas/content.js.map +1 -1
  195. package/dist/types/schemas/credentials.js.map +1 -1
  196. package/dist/types/schemas/discussion.d.ts +379 -4
  197. package/dist/types/schemas/discussion.js +209 -7
  198. package/dist/types/schemas/discussion.js.map +1 -1
  199. package/dist/types/schemas/entitlements.d.ts +44 -4
  200. package/dist/types/schemas/entitlements.js +32 -4
  201. package/dist/types/schemas/entitlements.js.map +1 -1
  202. package/dist/types/schemas/identity.d.ts +115 -5
  203. package/dist/types/schemas/identity.js +59 -6
  204. package/dist/types/schemas/identity.js.map +1 -1
  205. package/dist/types/schemas/index.d.ts +9 -9
  206. package/dist/types/schemas/index.js +9 -9
  207. package/dist/types/schemas/integrations.js.map +1 -1
  208. package/dist/types/schemas/organizations.d.ts +27 -5
  209. package/dist/types/schemas/organizations.js +14 -7
  210. package/dist/types/schemas/organizations.js.map +1 -1
  211. package/dist/types/schemas/outbox.d.ts +1 -0
  212. package/dist/types/schemas/outbox.js +1 -0
  213. package/dist/types/schemas/outbox.js.map +1 -1
  214. package/dist/types/schemas/progress.d.ts +1 -0
  215. package/dist/types/schemas/progress.js +1 -0
  216. package/dist/types/schemas/progress.js.map +1 -1
  217. package/dist/types/schemas/settings.js.map +1 -1
  218. package/dist/types/schemas/shared.d.ts +4 -1
  219. package/dist/types/schemas/shared.js +7 -1
  220. package/dist/types/schemas/shared.js.map +1 -1
  221. package/dist/types/shared.d.ts +1 -1
  222. package/package.json +2 -2
  223. package/dist/reporting/dashboard/index.d.ts +0 -4
  224. package/dist/reporting/dashboard/index.js +0 -2
  225. package/dist/reporting/dashboard/model.d.ts +0 -14
  226. package/dist/reporting/dashboard/ports.d.ts +0 -13
  227. package/dist/reporting/dashboard/service.d.ts +0 -19
  228. package/dist/reporting/dashboard/service.js +0 -20
  229. package/dist/reporting/dashboard/service.js.map +0 -1
  230. package/dist/reporting/learn/index.d.ts +0 -5
  231. package/dist/reporting/learn/index.js +0 -2
  232. package/dist/reporting/learn/model.d.ts +0 -23
  233. package/dist/reporting/learn/ports.d.ts +0 -42
  234. package/dist/reporting/learn/service.d.ts +0 -48
  235. package/dist/reporting/learn/service.js +0 -111
  236. package/dist/reporting/learn/service.js.map +0 -1
  237. package/dist/reporting/students/index.d.ts +0 -4
  238. package/dist/reporting/students/index.js +0 -2
  239. package/dist/reporting/students/model.d.ts +0 -31
  240. package/dist/reporting/students/ports.d.ts +0 -13
  241. package/dist/reporting/students/ports.js +0 -1
  242. package/dist/reporting/students/service.d.ts +0 -19
  243. package/dist/reporting/students/service.js +0 -20
  244. package/dist/reporting/students/service.js.map +0 -1
  245. /package/dist/reporting/{dashboard → admin}/model.js +0 -0
  246. /package/dist/reporting/{dashboard → admin}/ports.js +0 -0
  247. /package/dist/reporting/{learn → member}/model.js +0 -0
  248. /package/dist/reporting/{learn → member}/ports.js +0 -0
  249. /package/dist/{reporting/students/model.js → shared/actor.js} +0 -0
@@ -1,8 +1,13 @@
1
+ import { deliverWebhookInputSchema } from "../types/schemas/automations.js";
2
+ import { ConflictError, NotFoundError } from "../shared/errors.js";
1
3
  import { noopLogger } from "../shared/logger.js";
2
- import { catalogActions, catalogTriggers } from "./catalog.js";
4
+ import { ActionFailedError, AutomationKindMismatchError, InvalidTriggerError, ReservedActionError } from "./model.js";
5
+ import { DELIVER_WEBHOOK_ACTION, catalogActions, catalogTriggers, publicDataSchema } from "./catalog.js";
6
+ import { generateWebhookSecret } from "./webhook-signing.js";
3
7
  import { executeAction } from "./actions.js";
4
8
  import { automationEvents } from "./events.js";
5
- import { InvalidTriggerError } from "./model.js";
9
+ import { communitySeeds, orgSeeds } from "./seeds.js";
10
+ import { cronSupported } from "./schedule.js";
6
11
  //#region src/automations/service.ts
7
12
  /** True only when `input` sets `enabled` and nothing else — emits automation.enabled|disabled instead of automation.updated. */
8
13
  function isEnabledOnlyUpdate(input) {
@@ -10,6 +15,107 @@ function isEnabledOnlyUpdate(input) {
10
15
  const { enabled: _enabled, ...rest } = input;
11
16
  return Object.values(rest).every((v) => v === void 0);
12
17
  }
18
+ function floorMinute(at) {
19
+ const copy = new Date(at);
20
+ copy.setUTCSeconds(0, 0);
21
+ return copy.getTime();
22
+ }
23
+ function unique(values) {
24
+ return [...new Set(values)];
25
+ }
26
+ function recordString(value, key) {
27
+ if (typeof value !== "object" || value === null || !(key in value)) return null;
28
+ const found = value[key];
29
+ return typeof found === "string" ? found : null;
30
+ }
31
+ function filterMatches(filter, data) {
32
+ if (filter == null) return true;
33
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return false;
34
+ const record = data;
35
+ return Object.entries(filter).every(([key, expected]) => {
36
+ if (!Object.prototype.hasOwnProperty.call(record, key)) return false;
37
+ return JSON.stringify(record[key]) === JSON.stringify(expected);
38
+ });
39
+ }
40
+ function assertAuthorableTriggers(triggers) {
41
+ for (const trigger of triggers) {
42
+ if (trigger.startsWith("automation.")) throw new InvalidTriggerError(trigger);
43
+ if (trigger.startsWith("schedule:") && !cronSupported(trigger.slice(9))) throw new InvalidTriggerError(trigger, "schedule");
44
+ }
45
+ }
46
+ function assertCatalogTriggers(triggers, schedules) {
47
+ assertAuthorableTriggers(triggers);
48
+ const known = new Set(catalogTriggers().map((t) => t.type));
49
+ for (const trigger of triggers) if (!(schedules && trigger.startsWith("schedule:")) && !known.has(trigger)) throw new InvalidTriggerError(trigger, "unknown");
50
+ }
51
+ function assertAuthorableActions(actions) {
52
+ for (const action of actions) if (action.type === "deliverWebhook") throw new ReservedActionError(action.type);
53
+ }
54
+ const SCHEDULE_FIRED = "automation.schedule.fired";
55
+ function publicEventData(event) {
56
+ const parsed = event.type === SCHEDULE_FIRED ? {
57
+ success: true,
58
+ data: event.data
59
+ } : publicDataSchema(event.type)?.safeParse(event.data);
60
+ return parsed?.success ? JSON.parse(JSON.stringify(parsed.data)) : null;
61
+ }
62
+ /** A run as callers see it: its event carries only what a webhook of that event would. */
63
+ function toRunView(run) {
64
+ const { type, version, id, orgId, occurredAt } = run.event;
65
+ return {
66
+ ...run,
67
+ event: {
68
+ type,
69
+ version,
70
+ id,
71
+ orgId,
72
+ occurredAt,
73
+ data: publicEventData(run.event)
74
+ }
75
+ };
76
+ }
77
+ /** An automation as callers see it: a webhook's credential reference stays internal. */
78
+ function toAutomationView(automation) {
79
+ return {
80
+ ...automation,
81
+ actions: automation.actions.map((action) => {
82
+ if (action.type !== "deliverWebhook") return action;
83
+ const { secretRef: _secretRef, ...input } = action.input;
84
+ return {
85
+ ...action,
86
+ input
87
+ };
88
+ })
89
+ };
90
+ }
91
+ function webhookTarget(automation) {
92
+ return deliverWebhookInputSchema.parse(automation.actions[0]?.input);
93
+ }
94
+ function toWebhook(automation) {
95
+ return {
96
+ orgId: automation.orgId,
97
+ id: automation.id,
98
+ url: webhookTarget(automation).url,
99
+ events: automation.triggers,
100
+ description: automation.description,
101
+ enabled: automation.enabled,
102
+ createdAt: automation.createdAt,
103
+ updatedAt: automation.updatedAt
104
+ };
105
+ }
106
+ function updateEvent(orgId, input, updated) {
107
+ if (isEnabledOnlyUpdate(input)) return input.enabled ? automationEvents.automationEnabled.make({
108
+ orgId,
109
+ data: updated
110
+ }) : automationEvents.automationDisabled.make({
111
+ orgId,
112
+ data: updated
113
+ });
114
+ return automationEvents.automationUpdated.make({
115
+ orgId,
116
+ data: updated
117
+ });
118
+ }
13
119
  var AutomationsServiceImpl = class {
14
120
  repo;
15
121
  runsRepo;
@@ -18,6 +124,12 @@ var AutomationsServiceImpl = class {
18
124
  mailer;
19
125
  lookups;
20
126
  integrations;
127
+ credentials;
128
+ webhooks;
129
+ mail;
130
+ grants;
131
+ desk;
132
+ scheduledWork;
21
133
  logger;
22
134
  constructor(params) {
23
135
  this.repo = params.repo;
@@ -27,12 +139,87 @@ var AutomationsServiceImpl = class {
27
139
  this.mailer = params.mailer;
28
140
  this.lookups = params.lookups;
29
141
  this.integrations = params.integrations;
142
+ this.credentials = params.credentials;
143
+ this.webhooks = params.webhooks;
144
+ this.mail = {
145
+ ...params.mail,
146
+ recentMessageRecipients: (orgId, automationId, chatId, since) => this.runsRepo.recentRecipients(orgId, automationId, chatId, since)
147
+ };
148
+ this.grants = params.grants;
149
+ this.desk = params.desk;
150
+ this.scheduledWork = params.scheduledWork;
30
151
  this.logger = params.logger ?? noopLogger;
31
152
  }
32
153
  async handle(event) {
33
154
  if (event.type.startsWith("automation.")) return;
155
+ await this.seedFor(event);
34
156
  const automations = await this.safeListByTrigger(event);
35
- for (const automation of automations.filter((a) => a.enabled)) await this.dispatchOne(automation, event);
157
+ for (const automation of automations) {
158
+ const matches = filterMatches(automation.filter, event.data);
159
+ if (!automation.enabled || !matches) {
160
+ if (automation.enabled && !matches) this.logger.debug("automation filter skipped", {
161
+ orgId: event.orgId,
162
+ automationId: automation.id,
163
+ trigger: event.type
164
+ });
165
+ continue;
166
+ }
167
+ await this.dispatchOne(automation, event);
168
+ }
169
+ }
170
+ seedsFor(event) {
171
+ if (event.type === "organization.created") return orgSeeds();
172
+ if (event.type !== "content.community.created") return null;
173
+ const communityId = recordString(event.data, "id");
174
+ if (!communityId) {
175
+ this.logger.error("community seed missing id", {
176
+ orgId: event.orgId,
177
+ eventId: event.id
178
+ });
179
+ return null;
180
+ }
181
+ return communitySeeds(communityId);
182
+ }
183
+ async seedFor(event) {
184
+ const rows = this.seedsFor(event);
185
+ if (!rows) return;
186
+ if (await this.uow.run(async ({ automations, outbox }) => {
187
+ const created = [];
188
+ for (const row of rows) {
189
+ const inserted = await automations.insertSeed(event.orgId, {
190
+ name: row.name,
191
+ description: row.description,
192
+ triggers: row.triggers,
193
+ actions: row.actions,
194
+ filter: row.filter,
195
+ kind: "workflow",
196
+ source: "system"
197
+ });
198
+ if (inserted) created.push(automationEvents.automationCreated.make({
199
+ orgId: event.orgId,
200
+ data: inserted
201
+ }));
202
+ }
203
+ if (created.length > 0) {
204
+ await outbox.append(created);
205
+ this.logger.info("system automations seeded", {
206
+ orgId: event.orgId,
207
+ trigger: event.type,
208
+ count: created.length
209
+ });
210
+ }
211
+ return created.length;
212
+ }) > 0) await this.reconcileSchedules(event.orgId);
213
+ }
214
+ async reconcileSchedules(orgId) {
215
+ try {
216
+ await this.engine.syncSchedules?.();
217
+ } catch (err) {
218
+ this.logger.error("automation schedules not reconciled", {
219
+ orgId,
220
+ err
221
+ });
222
+ }
36
223
  }
37
224
  async safeListByTrigger(event) {
38
225
  try {
@@ -41,55 +228,65 @@ var AutomationsServiceImpl = class {
41
228
  this.logger.error("automations: failed to load trigger matches", {
42
229
  orgId: event.orgId,
43
230
  trigger: event.type,
44
- error: err instanceof Error ? err.message : String(err)
231
+ err
45
232
  });
46
233
  return [];
47
234
  }
48
235
  }
49
236
  async dispatchOne(automation, event) {
50
- let run;
237
+ let run = null;
51
238
  try {
52
- run = await this.uow.run(async ({ runs, outbox }) => {
53
- const inserted = await runs.insert(event.orgId, {
54
- orgId: event.orgId,
55
- automationId: automation.id,
56
- trigger: automation.trigger,
57
- event,
58
- status: "running",
59
- actionResults: [],
60
- startedAt: /* @__PURE__ */ new Date(),
61
- finishedAt: null
62
- });
63
- if (!inserted) return null;
64
- await outbox.append([automationEvents.runStarted.make({
65
- orgId: event.orgId,
66
- data: inserted
67
- })]);
68
- return inserted;
69
- });
239
+ run = await this.openRun(automation, event);
70
240
  if (!run) return;
71
- await this.engine.dispatch({
72
- runId: run.id,
73
- orgId: event.orgId,
74
- automationId: automation.id,
75
- actions: automation.actions,
76
- event
77
- });
241
+ await this.engine.dispatch(this.dispatchFor(automation, run, event));
78
242
  } catch (err) {
79
- const message = err instanceof Error ? err.message : String(err);
80
243
  this.logger.error("automation run failed", {
81
244
  orgId: event.orgId,
82
245
  automationId: automation.id,
83
246
  runId: run?.id,
84
- error: message
247
+ trigger: event.type,
248
+ eventId: event.id,
249
+ err
85
250
  });
86
- if (run) await this.recordFailure(run, message);
251
+ if (run) await this.recordFailure(run, err instanceof Error ? err.message : String(err));
87
252
  }
88
253
  }
254
+ openRun(automation, event) {
255
+ const now = /* @__PURE__ */ new Date();
256
+ return this.uow.run(async ({ runs, outbox }) => {
257
+ const inserted = await runs.insert(event.orgId, {
258
+ orgId: event.orgId,
259
+ automationId: automation.id,
260
+ trigger: event.type,
261
+ event,
262
+ status: "running",
263
+ actionResults: [],
264
+ startedAt: now,
265
+ finishedAt: null,
266
+ createdAt: now,
267
+ updatedAt: now
268
+ });
269
+ if (!inserted) return null;
270
+ await outbox.append([automationEvents.runStarted.make({
271
+ orgId: event.orgId,
272
+ data: inserted
273
+ })]);
274
+ return inserted;
275
+ });
276
+ }
277
+ dispatchFor(automation, run, event) {
278
+ return {
279
+ runId: run.id,
280
+ orgId: event.orgId,
281
+ automationId: automation.id,
282
+ actions: automation.actions,
283
+ event
284
+ };
285
+ }
89
286
  /** Best-effort: logs and never rethrows — `handle` must never throw. */
90
287
  async recordFailure(run, error) {
91
288
  try {
92
- await this.uow.run(async ({ runs, outbox }) => {
289
+ return await this.uow.run(async ({ runs, outbox }) => {
93
290
  const failed = await runs.recordOutcome(run.orgId, run.id, {
94
291
  status: "failed",
95
292
  actionResults: run.actionResults,
@@ -99,25 +296,113 @@ var AutomationsServiceImpl = class {
99
296
  orgId: run.orgId,
100
297
  data: failed
101
298
  })]);
299
+ return failed;
102
300
  });
103
301
  } catch (err) {
104
302
  this.logger.error("failed to record automation run failure", {
105
303
  orgId: run.orgId,
304
+ automationId: run.automationId,
106
305
  runId: run.id,
107
- error: err instanceof Error ? err.message : String(err),
306
+ err,
108
307
  originalError: error
109
308
  });
309
+ return null;
110
310
  }
111
311
  }
112
312
  async runAction(d, index) {
113
313
  const action = d.actions[index];
114
314
  if (!action) throw new Error(`automation ${d.automationId} run ${d.runId}: no action at index ${index}`);
115
- await executeAction(action, d.event, this.mailer, this.lookups);
315
+ let outcome;
316
+ try {
317
+ outcome = await executeAction(action, d.event, {
318
+ mailer: this.mailer,
319
+ lookups: this.lookups,
320
+ credentials: this.credentials,
321
+ webhooks: this.webhooks,
322
+ mail: this.mail,
323
+ grants: this.grants,
324
+ desk: this.desk,
325
+ scheduledWork: this.scheduledWork,
326
+ automationId: d.automationId,
327
+ actionInput: action.input,
328
+ logger: this.logger
329
+ });
330
+ } catch (err) {
331
+ this.logger.error("automation action failed", {
332
+ orgId: d.orgId,
333
+ automationId: d.automationId,
334
+ runId: d.runId,
335
+ trigger: d.event.type,
336
+ index,
337
+ type: action.type,
338
+ err
339
+ });
340
+ throw err instanceof ActionFailedError ? err : new ActionFailedError();
341
+ }
342
+ const { recipients, failed } = outcome;
343
+ this.logger.info("automation action completed", {
344
+ orgId: d.orgId,
345
+ automationId: d.automationId,
346
+ runId: d.runId,
347
+ type: action.type,
348
+ recipients: recipients.length,
349
+ failed: failed.length
350
+ });
116
351
  return {
117
352
  index,
118
353
  type: action.type,
119
- status: "completed"
354
+ status: "completed",
355
+ ...recipients.length > 0 ? { recipients } : {},
356
+ ...failed.length > 0 ? { error: `delivery failed for ${failed.length} recipients: ${failed.join(", ")}` } : {}
357
+ };
358
+ }
359
+ async listSchedules() {
360
+ return this.repo.listSchedules();
361
+ }
362
+ async fireSchedule(orgId, automationId, at) {
363
+ const firedAt = new Date(floorMinute(at));
364
+ const automation = await this.repo.findById(orgId, automationId);
365
+ if (!automation?.enabled || firedAt.getTime() < floorMinute(automation.createdAt)) {
366
+ this.logger.debug("schedule skipped", {
367
+ orgId,
368
+ automationId
369
+ });
370
+ return;
371
+ }
372
+ if (!await this.scheduleHasWork(orgId, automation, firedAt)) {
373
+ this.logger.debug("schedule idle", {
374
+ orgId,
375
+ automationId
376
+ });
377
+ return;
378
+ }
379
+ const event = {
380
+ type: SCHEDULE_FIRED,
381
+ id: `schedule_${automationId}_${firedAt.toISOString()}`,
382
+ orgId,
383
+ version: 1,
384
+ occurredAt: firedAt.toISOString(),
385
+ data: {
386
+ automationId,
387
+ firedAt: firedAt.toISOString()
388
+ }
120
389
  };
390
+ await this.dispatchOne(automation, event);
391
+ }
392
+ async scheduleHasWork(orgId, automation, firedAt) {
393
+ const checks = automation.actions.map((action) => this.pendingWork(action.type));
394
+ const known = checks.filter((check) => check !== null);
395
+ if (checks.length === 0 || known.length < checks.length) return true;
396
+ for (const check of known) if (await check(orgId, firedAt)) return true;
397
+ return false;
398
+ }
399
+ pendingWork(type) {
400
+ switch (type) {
401
+ case "publishDuePosts": return (orgId, now) => this.scheduledWork.hasDuePosts(orgId, now);
402
+ case "expireDueGrants": return (orgId, now) => this.scheduledWork.hasDueGrants(orgId, now);
403
+ case "removeUnattachedUploads": return (orgId, now) => this.scheduledWork.hasUnattachedUploads(orgId, now);
404
+ default: return null;
405
+ }
121
406
  }
122
407
  async finalize(d, results) {
123
408
  const status = results.length === d.actions.length && results.every((r) => r.status === "completed") ? "completed" : "failed";
@@ -141,11 +426,46 @@ var AutomationsServiceImpl = class {
141
426
  }) }));
142
427
  await outbox.append([runEvent, ...actionFailedEvents]);
143
428
  });
429
+ this.logOutcome(d, status, results);
430
+ }
431
+ logOutcome(d, status, results) {
432
+ const run = {
433
+ orgId: d.orgId,
434
+ automationId: d.automationId,
435
+ runId: d.runId,
436
+ trigger: d.event.type,
437
+ eventId: d.event.id
438
+ };
439
+ if (status === "failed") {
440
+ const failed = results.find((r) => r.status === "failed");
441
+ const index = failed?.index ?? results.length;
442
+ this.logger.error("automation run failed", {
443
+ ...run,
444
+ index,
445
+ action: failed?.type ?? d.actions[index]?.type,
446
+ error: failed?.error ?? "not every action ran"
447
+ });
448
+ return;
449
+ }
450
+ const partial = results.filter((r) => r.error !== void 0);
451
+ if (partial.length > 0) this.logger.warn("automation run completed with failed deliveries", {
452
+ ...run,
453
+ actions: partial.map(({ index, type, error }) => ({
454
+ index,
455
+ type,
456
+ error
457
+ }))
458
+ });
144
459
  }
145
460
  async create(orgId, input) {
146
- if (input.trigger.startsWith("automation.")) throw new InvalidTriggerError(input.trigger);
461
+ assertCatalogTriggers(input.triggers, true);
462
+ assertAuthorableActions(input.actions);
147
463
  const automation = await this.uow.run(async ({ automations, outbox }) => {
148
- const created = await automations.insert(orgId, input);
464
+ const created = await automations.insert(orgId, {
465
+ ...input,
466
+ triggers: unique(input.triggers),
467
+ kind: "workflow"
468
+ });
149
469
  await outbox.append([automationEvents.automationCreated.make({
150
470
  orgId,
151
471
  data: created
@@ -156,56 +476,219 @@ var AutomationsServiceImpl = class {
156
476
  orgId,
157
477
  automationId: automation.id
158
478
  });
479
+ await this.reconcileSchedules(orgId);
159
480
  return automation;
160
481
  }
161
482
  async update(orgId, id, input) {
162
- if (input.trigger !== void 0 && input.trigger.startsWith("automation.")) throw new InvalidTriggerError(input.trigger);
483
+ if (input.triggers !== void 0) assertCatalogTriggers(input.triggers, true);
484
+ if (input.actions !== void 0) assertAuthorableActions(input.actions);
485
+ const existing = await this.repo.findById(orgId, id);
486
+ if (!existing) return null;
487
+ if (existing.kind !== "workflow") throw new AutomationKindMismatchError(id, existing.kind);
488
+ const patch = input.triggers === void 0 ? input : {
489
+ ...input,
490
+ triggers: unique(input.triggers)
491
+ };
163
492
  const automation = await this.uow.run(async ({ automations, outbox }) => {
164
- const updated = await automations.update(orgId, id, input);
493
+ const updated = await automations.update(orgId, id, patch);
165
494
  if (!updated) return null;
166
- if (isEnabledOnlyUpdate(input)) await outbox.append([input.enabled ? automationEvents.automationEnabled.make({
495
+ await outbox.append([updateEvent(orgId, input, updated)]);
496
+ return updated;
497
+ });
498
+ if (automation) {
499
+ this.logger.info("automation updated", {
167
500
  orgId,
168
- data: updated
169
- }) : automationEvents.automationDisabled.make({
501
+ automationId: id
502
+ });
503
+ await this.reconcileSchedules(orgId);
504
+ }
505
+ return automation;
506
+ }
507
+ async delete(orgId, id) {
508
+ const existing = await this.repo.findById(orgId, id);
509
+ if (!existing) return false;
510
+ if (existing.kind !== "workflow") throw new AutomationKindMismatchError(id, existing.kind);
511
+ if (existing.source === "system") throw new ConflictError("A system automation cannot be deleted");
512
+ const deleted = await this.uow.run(async ({ automations, outbox }) => {
513
+ const removed = await automations.delete(orgId, id);
514
+ if (!removed) return null;
515
+ await outbox.append([automationEvents.automationDeleted.make({
170
516
  orgId,
171
- data: updated
517
+ data: removed
172
518
  })]);
173
- else await outbox.append([automationEvents.automationUpdated.make({
519
+ return removed;
520
+ });
521
+ if (deleted) {
522
+ this.logger.info("automation deleted", {
174
523
  orgId,
175
- data: updated
524
+ automationId: id
525
+ });
526
+ await this.reconcileSchedules(orgId);
527
+ }
528
+ return deleted !== null;
529
+ }
530
+ async list(orgId, query) {
531
+ return (await this.repo.listByOrg(orgId, query?.kind)).map(toAutomationView);
532
+ }
533
+ async get(orgId, id) {
534
+ const automation = await this.repo.findById(orgId, id);
535
+ return automation ? toAutomationView(automation) : null;
536
+ }
537
+ async listRuns(orgId, automationId, query) {
538
+ const page = await this.runsRepo.list(orgId, automationId, query);
539
+ return {
540
+ ...page,
541
+ rows: page.rows.map(toRunView)
542
+ };
543
+ }
544
+ async rerun(orgId, automationId, runId) {
545
+ const existing = await this.runsRepo.findById(orgId, runId);
546
+ if (!existing || existing.automationId !== automationId) throw new NotFoundError("Automation run", runId);
547
+ const automation = await this.repo.findById(orgId, automationId);
548
+ if (!automation) throw new NotFoundError("Automation", automationId);
549
+ const run = await this.uow.run(async ({ runs, outbox }) => {
550
+ const restarted = await runs.restart(orgId, runId, /* @__PURE__ */ new Date());
551
+ if (!restarted) return null;
552
+ await outbox.append([automationEvents.runStarted.make({
553
+ orgId,
554
+ data: restarted
176
555
  })]);
177
- return updated;
556
+ return restarted;
557
+ });
558
+ if (!run) throw new ConflictError(`automation run ${runId} is still running`);
559
+ try {
560
+ await this.engine.dispatch(this.dispatchFor(automation, run, run.event));
561
+ } catch (err) {
562
+ this.logger.error("automation rerun failed", {
563
+ orgId,
564
+ automationId,
565
+ runId: run.id,
566
+ trigger: run.trigger,
567
+ err
568
+ });
569
+ return toRunView(await this.recordFailure(run, err instanceof Error ? err.message : String(err)) ?? run);
570
+ }
571
+ this.logger.info("automation rerun started", {
572
+ orgId,
573
+ automationId,
574
+ runId: run.id
575
+ });
576
+ return toRunView(run);
577
+ }
578
+ async listWebhooks(orgId) {
579
+ return (await this.repo.listByOrg(orgId, "webhook")).map(toWebhook);
580
+ }
581
+ async getWebhook(orgId, id) {
582
+ const automation = await this.findWebhook(orgId, id);
583
+ return automation ? toWebhook(automation) : null;
584
+ }
585
+ async createWebhook(orgId, input) {
586
+ const events = unique(input.events);
587
+ assertCatalogTriggers(events, false);
588
+ const secret = generateWebhookSecret();
589
+ const automation = await this.uow.run(async ({ automations, credentials, outbox }) => {
590
+ const secretRef = await credentials.store(orgId, { secret });
591
+ const created = await automations.insert(orgId, {
592
+ kind: "webhook",
593
+ name: input.url,
594
+ description: input.description,
595
+ triggers: events,
596
+ actions: [{
597
+ type: DELIVER_WEBHOOK_ACTION,
598
+ input: {
599
+ url: input.url,
600
+ secretRef
601
+ }
602
+ }]
603
+ });
604
+ await outbox.append([automationEvents.automationCreated.make({
605
+ orgId,
606
+ data: created
607
+ })]);
608
+ return created;
609
+ });
610
+ this.logger.info("webhook created", {
611
+ orgId,
612
+ automationId: automation.id
613
+ });
614
+ return {
615
+ ...toWebhook(automation),
616
+ secret
617
+ };
618
+ }
619
+ async updateWebhook(orgId, id, input) {
620
+ const existing = await this.findWebhook(orgId, id);
621
+ if (!existing) return null;
622
+ const events = input.events === void 0 ? void 0 : unique(input.events);
623
+ if (events) assertCatalogTriggers(events, false);
624
+ const { secretRef } = webhookTarget(existing);
625
+ const patch = {
626
+ name: input.url,
627
+ description: input.description,
628
+ triggers: events,
629
+ actions: input.url === void 0 ? void 0 : [{
630
+ type: DELIVER_WEBHOOK_ACTION,
631
+ input: {
632
+ url: input.url,
633
+ secretRef
634
+ }
635
+ }],
636
+ enabled: input.enabled
637
+ };
638
+ const updated = await this.uow.run(async ({ automations, outbox }) => {
639
+ const row = await automations.update(orgId, id, patch);
640
+ if (!row) return null;
641
+ await outbox.append([updateEvent(orgId, input, row)]);
642
+ return row;
178
643
  });
179
- if (automation) this.logger.info("automation updated", {
644
+ if (!updated) return null;
645
+ this.logger.info("webhook updated", {
180
646
  orgId,
181
647
  automationId: id
182
648
  });
183
- return automation;
649
+ return toWebhook(updated);
184
650
  }
185
- async delete(orgId, id) {
186
- const deleted = await this.uow.run(async ({ automations, outbox }) => {
651
+ async deleteWebhook(orgId, id) {
652
+ const existing = await this.findWebhook(orgId, id);
653
+ if (!existing) return false;
654
+ const { secretRef } = webhookTarget(existing);
655
+ const deleted = await this.uow.run(async ({ automations, credentials, outbox }) => {
187
656
  const removed = await automations.delete(orgId, id);
188
657
  if (!removed) return null;
658
+ await credentials.destroy(orgId, secretRef);
189
659
  await outbox.append([automationEvents.automationDeleted.make({
190
660
  orgId,
191
661
  data: removed
192
662
  })]);
193
663
  return removed;
194
664
  });
195
- if (deleted) this.logger.info("automation deleted", {
665
+ if (deleted) this.logger.info("webhook deleted", {
196
666
  orgId,
197
667
  automationId: id
198
668
  });
199
669
  return deleted !== null;
200
670
  }
201
- list(orgId) {
202
- return this.repo.listByOrg(orgId);
671
+ async revealWebhookSecret(orgId, id) {
672
+ const existing = await this.findWebhook(orgId, id);
673
+ if (!existing) return null;
674
+ const secret = (await this.credentials.reveal(orgId, webhookTarget(existing).secretRef))?.["secret"];
675
+ if (typeof secret !== "string") throw new Error(`webhook ${id}: signing secret is missing`);
676
+ return { secret };
203
677
  }
204
- get(orgId, id) {
205
- return this.repo.findById(orgId, id);
678
+ async rotateWebhookSecret(orgId, id) {
679
+ const existing = await this.findWebhook(orgId, id);
680
+ if (!existing) return null;
681
+ const secret = generateWebhookSecret();
682
+ await this.credentials.update(orgId, webhookTarget(existing).secretRef, { secret });
683
+ this.logger.info("webhook secret rotated", {
684
+ orgId,
685
+ automationId: id
686
+ });
687
+ return { secret };
206
688
  }
207
- listRuns(orgId, automationId, query) {
208
- return this.runsRepo.list(orgId, automationId, query);
689
+ async findWebhook(orgId, id) {
690
+ const automation = await this.repo.findById(orgId, id);
691
+ return automation?.kind === "webhook" ? automation : null;
209
692
  }
210
693
  availableActions() {
211
694
  return [...catalogActions(), ...this.integrations.available().flatMap((integration) => integration.actions.map((action) => ({