@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,43 +1,78 @@
1
1
  import { assetEvents } from "./events.js";
2
2
  import { genId } from "../shared/id.js";
3
+ import { ConflictError, ForbiddenError, InvalidInputError } from "../shared/errors.js";
3
4
  import { noopLogger } from "../shared/logger.js";
4
5
  //#region src/assets/service.ts
6
+ const UNATTACHED_UPLOAD_GRACE_MS = 3600 * 1e3;
7
+ const MEMBER_IMAGE_TYPE = /^image\/(png|jpeg|gif|webp|avif)$/i;
8
+ const MEMBER_IMAGE_REFUSAL = "A member upload must be a PNG, JPEG, GIF, WebP or AVIF image";
9
+ function isMemberImageType(contentType) {
10
+ return MEMBER_IMAGE_TYPE.test(contentType.trim());
11
+ }
12
+ function kindOf(contentType) {
13
+ const type = contentType.trim().toLowerCase();
14
+ if (type.startsWith("image/")) return "image";
15
+ if (type.startsWith("video/")) return "video";
16
+ if (type.startsWith("audio/")) return "audio";
17
+ return "document";
18
+ }
5
19
  function orgPrefix(orgId) {
6
20
  return `org/${orgId}/`;
7
21
  }
8
22
  function sanitizeFilename(filename) {
9
23
  return (filename.split(/[\\/]/).pop() ?? "file").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "file";
10
24
  }
25
+ function storedKey(orgId, folder, id, filename) {
26
+ return `${orgPrefix(orgId)}${folder}/${id}/${sanitizeFilename(filename)}`;
27
+ }
28
+ function finalMemberKey(orgId, asset) {
29
+ return `${orgPrefix(orgId)}image/${asset.id}/${sanitizeFilename(asset.filename)}`;
30
+ }
31
+ function graceCutoff(now) {
32
+ return /* @__PURE__ */ new Date(now.getTime() - UNATTACHED_UPLOAD_GRACE_MS);
33
+ }
11
34
  var AssetsServiceImpl = class {
12
35
  storage;
13
36
  repo;
14
37
  uow;
38
+ memberImageMaxBytes;
39
+ downloadLinkExpirySeconds;
40
+ fileUrls;
15
41
  logger;
16
42
  constructor(params) {
43
+ if (!Number.isSafeInteger(params.memberImageMaxBytes) || params.memberImageMaxBytes <= 0) throw new Error(`invalid memberImageMaxBytes "${params.memberImageMaxBytes}" (expected a positive integer)`);
44
+ if (!Number.isSafeInteger(params.downloadLinkExpirySeconds) || params.downloadLinkExpirySeconds <= 0) throw new Error(`invalid downloadLinkExpirySeconds "${params.downloadLinkExpirySeconds}" (expected a positive integer)`);
17
45
  this.storage = params.storage;
18
46
  this.repo = params.repo;
19
47
  this.uow = params.uow;
48
+ this.memberImageMaxBytes = params.memberImageMaxBytes;
49
+ this.downloadLinkExpirySeconds = params.downloadLinkExpirySeconds;
50
+ this.fileUrls = params.fileUrls;
20
51
  this.logger = params.logger ?? noopLogger;
21
52
  }
22
53
  async requestUpload(orgId, input) {
23
54
  const id = genId("asset");
24
- const key = `${orgPrefix(orgId)}${input.kind}/${id}/${sanitizeFilename(input.filename)}`;
55
+ const kind = kindOf(input.contentType);
56
+ const key = storedKey(orgId, input.source === "member" ? "uploads" : kind, id, input.filename);
25
57
  const presigned = await this.storage.presignUpload({
26
58
  key,
27
59
  contentType: input.contentType
28
60
  });
61
+ const now = /* @__PURE__ */ new Date();
29
62
  const asset = await this.uow.run(async ({ assets, outbox }) => {
30
63
  const asset = await assets.insert(orgId, {
31
64
  orgId,
32
65
  id,
33
66
  key,
34
- kind: input.kind,
67
+ kind,
68
+ source: input.source,
35
69
  filename: input.filename,
36
70
  contentType: input.contentType,
37
71
  size: 0,
38
72
  status: "pending",
39
73
  uploadedBy: input.uploadedBy,
40
- createdAt: /* @__PURE__ */ new Date()
74
+ createdAt: now,
75
+ updatedAt: now
41
76
  });
42
77
  await outbox.append([assetEvents.assetCreated.make({
43
78
  orgId,
@@ -48,83 +83,303 @@ var AssetsServiceImpl = class {
48
83
  this.logger.info("asset upload requested", {
49
84
  orgId,
50
85
  assetId: id,
51
- kind: input.kind
86
+ kind,
87
+ source: input.source
52
88
  });
53
89
  return {
54
- asset,
90
+ asset: {
91
+ ...asset,
92
+ url: null,
93
+ downloadUrl: null
94
+ },
55
95
  uploadUrl: presigned.url,
56
96
  method: "PUT",
57
97
  expiresInSeconds: presigned.expiresInSeconds,
58
98
  headers: presigned.headers
59
99
  };
60
100
  }
101
+ async requestMemberUpload(orgId, orgUserId, input) {
102
+ if (!isMemberImageType(input.contentType)) throw new InvalidInputError(MEMBER_IMAGE_REFUSAL);
103
+ return this.requestUpload(orgId, {
104
+ filename: input.filename,
105
+ contentType: input.contentType,
106
+ source: "member",
107
+ uploadedBy: orgUserId
108
+ });
109
+ }
61
110
  async confirm(orgId, id) {
62
111
  const asset = await this.repo.findById(orgId, id);
63
112
  if (!asset) return null;
113
+ if (asset.source === "member") throw new ForbiddenError("Only the uploader can finalize a member upload");
114
+ const confirmed = await this.finalize(orgId, asset);
115
+ return confirmed ? this.view(confirmed) : null;
116
+ }
117
+ async confirmOwn(orgId, id, orgUserId) {
118
+ const asset = await this.repo.findById(orgId, id);
119
+ if (!asset || asset.source !== "member" || asset.uploadedBy !== orgUserId) {
120
+ this.logger.debug("upload confirm refused", {
121
+ orgId,
122
+ assetId: id,
123
+ orgUserId
124
+ });
125
+ return null;
126
+ }
127
+ const confirmed = await this.finalize(orgId, asset);
128
+ if (!confirmed) return null;
129
+ return confirmed.status === "ready" ? {
130
+ ...confirmed,
131
+ url: this.fileUrls.memberUpload(confirmed.id),
132
+ downloadUrl: null
133
+ } : {
134
+ ...confirmed,
135
+ url: null,
136
+ downloadUrl: null
137
+ };
138
+ }
139
+ async ownUploadFile(orgId, assetId, orgUserId) {
140
+ if (!await this.ownsImage(orgId, assetId, orgUserId)) {
141
+ this.logger.warn("upload file not served — not the uploader", {
142
+ orgId,
143
+ assetId,
144
+ orgUserId
145
+ });
146
+ return null;
147
+ }
148
+ return this.signFile(orgId, assetId);
149
+ }
150
+ async ownsImage(orgId, assetId, orgUserId) {
151
+ const asset = await this.repo.findById(orgId, assetId);
152
+ return asset !== null && asset.source === "member" && asset.kind === "image" && asset.status === "ready" && asset.uploadedBy === orgUserId;
153
+ }
154
+ async libraryAssetsAmong(orgId, assetIds) {
155
+ if (assetIds.length === 0) return [];
156
+ const rows = await this.repo.findByIds(orgId, [...new Set(assetIds)]);
157
+ const library = new Set(rows.filter((a) => a.source === "library" && a.status === "ready").map((a) => a.id));
158
+ return assetIds.filter((id) => library.has(id));
159
+ }
160
+ async list(orgId, query) {
161
+ const page = await this.repo.list(orgId, {
162
+ ...query,
163
+ source: query.source ?? "library"
164
+ });
165
+ return {
166
+ ...page,
167
+ rows: await Promise.all(page.rows.map((asset) => this.view(asset)))
168
+ };
169
+ }
170
+ async get(orgId, id) {
171
+ const asset = await this.repo.findById(orgId, id);
172
+ return asset ? this.view(asset) : null;
173
+ }
174
+ async readyFiles(orgId, assetIds) {
175
+ const ready = await this.readyAmong(orgId, assetIds);
176
+ return Object.fromEntries(ready.map((asset) => [asset.id, {
177
+ filename: asset.filename,
178
+ contentType: asset.contentType,
179
+ size: asset.size
180
+ }]));
181
+ }
182
+ async signFile(orgId, assetId, opts = {}) {
183
+ const [asset] = await this.readyAmong(orgId, [assetId]);
184
+ if (!asset) {
185
+ this.logger.warn("file not served — no ready asset", {
186
+ orgId,
187
+ assetId
188
+ });
189
+ return null;
190
+ }
191
+ return opts.saveAs === void 0 ? this.storage.presignDownload({ key: asset.key }) : this.storage.presignDownload({
192
+ key: asset.key,
193
+ downloadFilename: opts.saveAs,
194
+ expiresInSeconds: this.downloadLinkExpirySeconds
195
+ });
196
+ }
197
+ async libraryFile(orgId, assetId, opts) {
198
+ const asset = await this.repo.findById(orgId, assetId);
199
+ if (!asset || asset.source !== "library") {
200
+ this.logger.warn("library file not served — not a library asset", {
201
+ orgId,
202
+ assetId
203
+ });
204
+ return null;
205
+ }
206
+ return this.signFile(orgId, assetId, opts.download ? { saveAs: asset.filename } : {});
207
+ }
208
+ async remove(orgId, id) {
209
+ const asset = await this.repo.findById(orgId, id);
210
+ if (!asset) return false;
211
+ const removed = await this.discard(orgId, asset);
212
+ if (removed) this.logger.info("asset removed", {
213
+ orgId,
214
+ assetId: id
215
+ });
216
+ return removed;
217
+ }
218
+ async removeOwn(orgId, id, orgUserId) {
219
+ const asset = await this.repo.findById(orgId, id);
220
+ if (!asset || asset.source !== "member" || asset.uploadedBy !== orgUserId) return false;
221
+ return this.remove(orgId, id);
222
+ }
223
+ async hasUnattached(orgId, now) {
224
+ if (await this.repo.hasUnreferencedMemberUploads(orgId, graceCutoff(now))) return true;
225
+ return (await this.strayUploads(orgId, now)).length > 0;
226
+ }
227
+ async removeUnattached(orgId, now) {
228
+ const stale = await this.repo.listUnreferencedMemberUploads(orgId, graceCutoff(now));
229
+ let removed = 0;
230
+ let failed = 0;
231
+ for (const asset of stale) try {
232
+ if (await this.discard(orgId, asset)) removed += 1;
233
+ } catch (err) {
234
+ if (!(err instanceof ConflictError)) {
235
+ failed += 1;
236
+ this.logger.error("unattached upload not removed", {
237
+ orgId,
238
+ assetId: asset.id,
239
+ err
240
+ });
241
+ }
242
+ }
243
+ let stray = 0;
244
+ for (const key of await this.strayUploads(orgId, now)) try {
245
+ await this.storage.remove(key);
246
+ stray += 1;
247
+ } catch (err) {
248
+ failed += 1;
249
+ this.logger.warn("stray upload not removed", {
250
+ orgId,
251
+ err
252
+ });
253
+ }
254
+ if (removed > 0 || stray > 0 || failed > 0) this.logger.info("unattached uploads removed", {
255
+ orgId,
256
+ count: removed,
257
+ stray,
258
+ failed
259
+ });
260
+ return removed + stray;
261
+ }
262
+ async strayUploads(orgId, now) {
263
+ const cutoff = now.getTime() - this.storage.uploadExpirySeconds * 1e3;
264
+ const old = (await this.storage.list(`${orgPrefix(orgId)}uploads/`)).filter((o) => o.lastModified !== void 0 && Date.parse(o.lastModified) < cutoff).map((o) => o.key);
265
+ if (old.length === 0) return [];
266
+ const held = new Set(await this.repo.heldKeys(orgId, old));
267
+ return old.filter((key) => !held.has(key));
268
+ }
269
+ async finalize(orgId, asset) {
270
+ if (asset.status !== "pending") return asset;
271
+ if (asset.source === "member") return this.finalizeMember(orgId, asset);
64
272
  const stat = await this.storage.stat(asset.key);
65
273
  if (!stat) {
66
274
  this.logger.debug("asset not yet uploaded", {
67
275
  orgId,
68
- assetId: id
276
+ assetId: asset.id
69
277
  });
70
278
  return asset;
71
279
  }
72
- const updated = await this.uow.run(async ({ assets, outbox }) => {
73
- const updated = await assets.update(id, {
74
- size: stat.size,
75
- contentType: stat.contentType ?? asset.contentType,
76
- status: "ready"
280
+ const contentType = stat.contentType ?? asset.contentType;
281
+ return await this.markReady(orgId, asset, {
282
+ key: asset.key,
283
+ kind: kindOf(contentType),
284
+ size: stat.size,
285
+ contentType
286
+ }) ?? this.repo.findById(orgId, asset.id);
287
+ }
288
+ async finalizeMember(orgId, asset) {
289
+ if (!await this.storage.stat(asset.key)) {
290
+ this.logger.debug("asset not yet uploaded", {
291
+ orgId,
292
+ assetId: asset.id
293
+ });
294
+ return asset;
295
+ }
296
+ const key = finalMemberKey(orgId, asset);
297
+ await this.storage.copy({
298
+ from: asset.key,
299
+ to: key
300
+ });
301
+ const stat = await this.storage.stat(key);
302
+ if (!stat) throw new Error("copied upload not found");
303
+ const contentType = asset.contentType;
304
+ const kind = kindOf(contentType);
305
+ const reason = !isMemberImageType(contentType) ? MEMBER_IMAGE_REFUSAL : stat.contentType !== void 0 && stat.contentType !== contentType ? "The uploaded file is not the declared type" : stat.size > this.memberImageMaxBytes ? "This image is too large" : null;
306
+ if (reason) {
307
+ await this.removeObject(orgId, asset.id, key);
308
+ await this.discard(orgId, asset);
309
+ this.logger.info("member upload refused", {
310
+ orgId,
311
+ assetId: asset.id,
312
+ reason
77
313
  });
314
+ throw new InvalidInputError(reason);
315
+ }
316
+ const ready = await this.markReady(orgId, asset, {
317
+ key,
318
+ kind,
319
+ size: stat.size,
320
+ contentType
321
+ });
322
+ if (!ready) return this.repo.findById(orgId, asset.id);
323
+ await this.removeObject(orgId, asset.id, asset.key);
324
+ return ready;
325
+ }
326
+ async markReady(orgId, asset, patch) {
327
+ const updated = await this.uow.run(async ({ assets, outbox }) => {
328
+ const updated = await assets.markReady(orgId, asset.id, patch);
78
329
  if (updated) await outbox.append([assetEvents.assetReady.make({
79
330
  orgId,
80
331
  data: updated
81
332
  })]);
82
333
  return updated;
83
334
  });
84
- this.logger.info("asset confirmed", {
335
+ if (updated) this.logger.info("asset confirmed", {
85
336
  orgId,
86
- assetId: id
337
+ assetId: asset.id
87
338
  });
88
339
  return updated;
89
340
  }
90
- list(orgId, query) {
91
- return this.repo.list(orgId, query);
92
- }
93
- get(orgId, id) {
94
- return this.repo.findById(orgId, id);
341
+ async readyAmong(orgId, assetIds) {
342
+ const ids = [...new Set(assetIds)];
343
+ if (ids.length === 0) return [];
344
+ return (await this.repo.findByIds(orgId, ids)).filter((asset) => asset.orgId === orgId && asset.status === "ready");
95
345
  }
96
- async requestDownload(orgId, id, filename, expiresInSeconds) {
97
- const asset = await this.repo.findById(orgId, id);
98
- if (!asset) return null;
346
+ view(asset) {
347
+ if (asset.source !== "library" || asset.status !== "ready") return {
348
+ ...asset,
349
+ url: null,
350
+ downloadUrl: null
351
+ };
99
352
  return {
100
- url: await this.storage.presignDownload({
101
- key: asset.key,
102
- downloadFilename: filename ?? asset.filename,
103
- ...expiresInSeconds === void 0 ? {} : { expiresInSeconds }
104
- }),
105
- asset
353
+ ...asset,
354
+ url: this.fileUrls.libraryFile(asset.id),
355
+ downloadUrl: this.fileUrls.libraryDownload(asset.id)
106
356
  };
107
357
  }
108
- async remove(orgId, id) {
109
- const asset = await this.repo.findById(orgId, id);
110
- if (!asset) return false;
111
- await this.storage.remove(asset.key);
358
+ async discard(orgId, asset) {
112
359
  const deleted = await this.uow.run(async ({ assets, outbox }) => {
113
- const deleted = await assets.delete(id);
360
+ const deleted = await assets.delete(orgId, asset.id);
114
361
  if (deleted) await outbox.append([assetEvents.assetDeleted.make({
115
362
  orgId,
116
363
  data: asset
117
364
  })]);
118
365
  return deleted;
119
366
  });
120
- this.logger.info("asset removed", {
121
- orgId,
122
- assetId: id
123
- });
367
+ if (deleted) await this.removeObject(orgId, asset.id, asset.key);
124
368
  return deleted;
125
369
  }
370
+ async removeObject(orgId, assetId, key) {
371
+ try {
372
+ await this.storage.remove(key);
373
+ } catch (error) {
374
+ this.logger.warn("stored object not removed", {
375
+ orgId,
376
+ assetId,
377
+ err: error
378
+ });
379
+ }
380
+ }
126
381
  };
127
382
  //#endregion
128
- export { AssetsServiceImpl };
383
+ export { AssetsServiceImpl, MEMBER_IMAGE_TYPE, isMemberImageType, kindOf };
129
384
 
130
385
  //# sourceMappingURL=service.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","names":[],"sources":["../../src/assets/service.ts"],"sourcesContent":["// assets context — service. Owns org-scoped key construction and the asset\n// lifecycle (register → upload → confirm → serve → remove); delegates the\n// actual object operations to the ObjectStorage port. Org isolation is enforced\n// by scoping every lookup to the caller's org (cross-org reads return null).\nimport { genId } from '../shared/id.js';\nimport type { Logger, ObjectStorage } from '../shared/ports.js';\nimport type {\n Asset,\n AssetsQuery,\n DownloadTicket,\n Page,\n RequestUploadInput,\n UploadTicket,\n} from './model.js';\nimport type { AssetsRepository, AssetsService, AssetsUnitOfWork } from './ports.js';\nimport { noopLogger } from '../shared/logger.js';\nimport { assetEvents } from './events.js';\n\nfunction orgPrefix(orgId: string): string {\n return `org/${orgId}/`;\n}\n\nfunction sanitizeFilename(filename: string): string {\n const base = filename.split(/[\\\\/]/).pop() ?? 'file';\n return base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';\n}\n\nexport type AssetsServiceParams = {\n storage: ObjectStorage;\n repo: AssetsRepository;\n uow: AssetsUnitOfWork;\n logger?: Logger;\n};\n\nexport class AssetsServiceImpl implements AssetsService {\n private readonly storage: ObjectStorage;\n private readonly repo: AssetsRepository;\n private readonly uow: AssetsUnitOfWork;\n private readonly logger: Logger;\n\n constructor(params: AssetsServiceParams) {\n this.storage = params.storage;\n this.repo = params.repo;\n this.uow = params.uow;\n this.logger = params.logger ?? noopLogger;\n }\n\n async requestUpload(orgId: string, input: RequestUploadInput): Promise<UploadTicket> {\n const id = genId('asset');\n const key = `${orgPrefix(orgId)}${input.kind}/${id}/${sanitizeFilename(input.filename)}`;\n const presigned = await this.storage.presignUpload({ key, contentType: input.contentType });\n const asset = await this.uow.run(async ({ assets, outbox }) => {\n const asset = await assets.insert(orgId, {\n orgId,\n id,\n key,\n kind: input.kind,\n filename: input.filename,\n contentType: input.contentType,\n size: 0,\n status: 'pending',\n uploadedBy: input.uploadedBy,\n createdAt: new Date(),\n });\n await outbox.append([assetEvents.assetCreated.make({ orgId, data: asset })]);\n return asset;\n });\n this.logger.info('asset upload requested', { orgId, assetId: id, kind: input.kind });\n return {\n asset,\n uploadUrl: presigned.url,\n method: 'PUT',\n expiresInSeconds: presigned.expiresInSeconds,\n headers: presigned.headers,\n };\n }\n\n async confirm(orgId: string, id: string): Promise<Asset | null> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset) {\n return null;\n }\n const stat = await this.storage.stat(asset.key);\n if (!stat) {\n this.logger.debug('asset not yet uploaded', { orgId, assetId: id });\n return asset; // not uploaded yet — stays pending\n }\n const updated = await this.uow.run(async ({ assets, outbox }) => {\n const updated = await assets.update(id, {\n size: stat.size,\n contentType: stat.contentType ?? asset.contentType,\n status: 'ready',\n });\n if (updated) {\n await outbox.append([assetEvents.assetReady.make({ orgId, data: updated })]);\n }\n return updated;\n });\n this.logger.info('asset confirmed', { orgId, assetId: id });\n return updated;\n }\n\n list(orgId: string, query: AssetsQuery): Promise<Page<Asset>> {\n return this.repo.list(orgId, query);\n }\n\n get(orgId: string, id: string): Promise<Asset | null> {\n return this.repo.findById(orgId, id);\n }\n\n async requestDownload(\n orgId: string,\n id: string,\n filename?: string,\n expiresInSeconds?: number,\n ): Promise<DownloadTicket | null> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset) {\n return null;\n }\n const url = await this.storage.presignDownload({\n key: asset.key,\n downloadFilename: filename ?? asset.filename,\n ...(expiresInSeconds === undefined ? {} : { expiresInSeconds }),\n });\n return { url, asset };\n }\n\n async remove(orgId: string, id: string): Promise<boolean> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset) {\n return false;\n }\n await this.storage.remove(asset.key);\n const deleted = await this.uow.run(async ({ assets, outbox }) => {\n const deleted = await assets.delete(id);\n if (deleted) {\n await outbox.append([assetEvents.assetDeleted.make({ orgId, data: asset })]);\n }\n return deleted;\n });\n this.logger.info('asset removed', { orgId, assetId: id });\n return deleted;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"service.js","names":[],"sources":["../../src/assets/service.ts"],"sourcesContent":["import { genId } from '../shared/id.js';\nimport type { Logger, ObjectStorage } from '../shared/ports.js';\nimport { ConflictError, ForbiddenError, InvalidInputError } from '../shared/errors.js';\nimport type {\n Asset,\n AssetKind,\n AssetView,\n AssetsQuery,\n Page,\n RequestUploadInput,\n UploadTicket,\n} from './model.js';\nimport type {\n AssetsRepository,\n AssetsService,\n AssetsUnitOfWork,\n MemberUploadInput,\n ReadyFile,\n} from './ports.js';\nimport type { FileUrls } from './file-urls.js';\nimport { noopLogger } from '../shared/logger.js';\nimport { assetEvents } from './events.js';\n\nconst UNATTACHED_UPLOAD_GRACE_MS = 60 * 60 * 1000;\n\nexport const MEMBER_IMAGE_TYPE = /^image\\/(png|jpeg|gif|webp|avif)$/i;\nconst MEMBER_IMAGE_REFUSAL = 'A member upload must be a PNG, JPEG, GIF, WebP or AVIF image';\n\nexport function isMemberImageType(contentType: string): boolean {\n return MEMBER_IMAGE_TYPE.test(contentType.trim());\n}\n\nexport function kindOf(contentType: string): AssetKind {\n const type = contentType.trim().toLowerCase();\n if (type.startsWith('image/')) {\n return 'image';\n }\n if (type.startsWith('video/')) {\n return 'video';\n }\n if (type.startsWith('audio/')) {\n return 'audio';\n }\n return 'document';\n}\n\nfunction orgPrefix(orgId: string): string {\n return `org/${orgId}/`;\n}\n\nfunction sanitizeFilename(filename: string): string {\n const base = filename.split(/[\\\\/]/).pop() ?? 'file';\n return base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';\n}\n\nfunction storedKey(orgId: string, folder: string, id: string, filename: string): string {\n return `${orgPrefix(orgId)}${folder}/${id}/${sanitizeFilename(filename)}`;\n}\n\nfunction finalMemberKey(orgId: string, asset: Asset): string {\n return `${orgPrefix(orgId)}image/${asset.id}/${sanitizeFilename(asset.filename)}`;\n}\n\nfunction graceCutoff(now: Date): Date {\n return new Date(now.getTime() - UNATTACHED_UPLOAD_GRACE_MS);\n}\n\nexport type AssetsServiceParams = {\n storage: ObjectStorage;\n repo: AssetsRepository;\n uow: AssetsUnitOfWork;\n memberImageMaxBytes: number;\n downloadLinkExpirySeconds: number;\n fileUrls: FileUrls;\n logger?: Logger;\n};\n\nexport class AssetsServiceImpl implements AssetsService {\n private readonly storage: ObjectStorage;\n private readonly repo: AssetsRepository;\n private readonly uow: AssetsUnitOfWork;\n private readonly memberImageMaxBytes: number;\n private readonly downloadLinkExpirySeconds: number;\n private readonly fileUrls: FileUrls;\n private readonly logger: Logger;\n\n constructor(params: AssetsServiceParams) {\n if (!Number.isSafeInteger(params.memberImageMaxBytes) || params.memberImageMaxBytes <= 0) {\n throw new Error(\n `invalid memberImageMaxBytes \"${params.memberImageMaxBytes}\" (expected a positive integer)`,\n );\n }\n if (\n !Number.isSafeInteger(params.downloadLinkExpirySeconds) ||\n params.downloadLinkExpirySeconds <= 0\n ) {\n throw new Error(\n `invalid downloadLinkExpirySeconds \"${params.downloadLinkExpirySeconds}\" (expected a positive integer)`,\n );\n }\n this.storage = params.storage;\n this.repo = params.repo;\n this.uow = params.uow;\n this.memberImageMaxBytes = params.memberImageMaxBytes;\n this.downloadLinkExpirySeconds = params.downloadLinkExpirySeconds;\n this.fileUrls = params.fileUrls;\n this.logger = params.logger ?? noopLogger;\n }\n\n async requestUpload(orgId: string, input: RequestUploadInput): Promise<UploadTicket> {\n const id = genId('asset');\n const kind = kindOf(input.contentType);\n const folder = input.source === 'member' ? 'uploads' : kind;\n const key = storedKey(orgId, folder, id, input.filename);\n const presigned = await this.storage.presignUpload({ key, contentType: input.contentType });\n const now = new Date();\n const asset = await this.uow.run(async ({ assets, outbox }) => {\n const asset = await assets.insert(orgId, {\n orgId,\n id,\n key,\n kind,\n source: input.source,\n filename: input.filename,\n contentType: input.contentType,\n size: 0,\n status: 'pending',\n uploadedBy: input.uploadedBy,\n createdAt: now,\n updatedAt: now,\n });\n await outbox.append([assetEvents.assetCreated.make({ orgId, data: asset })]);\n return asset;\n });\n this.logger.info('asset upload requested', {\n orgId,\n assetId: id,\n kind,\n source: input.source,\n });\n return {\n asset: { ...asset, url: null, downloadUrl: null },\n uploadUrl: presigned.url,\n method: 'PUT',\n expiresInSeconds: presigned.expiresInSeconds,\n headers: presigned.headers,\n };\n }\n\n async requestMemberUpload(\n orgId: string,\n orgUserId: string,\n input: MemberUploadInput,\n ): Promise<UploadTicket> {\n if (!isMemberImageType(input.contentType)) {\n throw new InvalidInputError(MEMBER_IMAGE_REFUSAL);\n }\n return this.requestUpload(orgId, {\n filename: input.filename,\n contentType: input.contentType,\n source: 'member',\n uploadedBy: orgUserId,\n });\n }\n\n async confirm(orgId: string, id: string): Promise<AssetView | null> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset) {\n return null;\n }\n if (asset.source === 'member') {\n throw new ForbiddenError('Only the uploader can finalize a member upload');\n }\n const confirmed = await this.finalize(orgId, asset);\n return confirmed ? this.view(confirmed) : null;\n }\n\n async confirmOwn(orgId: string, id: string, orgUserId: string): Promise<AssetView | null> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset || asset.source !== 'member' || asset.uploadedBy !== orgUserId) {\n this.logger.debug('upload confirm refused', { orgId, assetId: id, orgUserId });\n return null;\n }\n const confirmed = await this.finalize(orgId, asset);\n if (!confirmed) {\n return null;\n }\n return confirmed.status === 'ready'\n ? { ...confirmed, url: this.fileUrls.memberUpload(confirmed.id), downloadUrl: null }\n : { ...confirmed, url: null, downloadUrl: null };\n }\n\n async ownUploadFile(orgId: string, assetId: string, orgUserId: string): Promise<string | null> {\n if (!(await this.ownsImage(orgId, assetId, orgUserId))) {\n this.logger.warn('upload file not served — not the uploader', { orgId, assetId, orgUserId });\n return null;\n }\n return this.signFile(orgId, assetId);\n }\n\n async ownsImage(orgId: string, assetId: string, orgUserId: string): Promise<boolean> {\n const asset = await this.repo.findById(orgId, assetId);\n return (\n asset !== null &&\n asset.source === 'member' &&\n asset.kind === 'image' &&\n asset.status === 'ready' &&\n asset.uploadedBy === orgUserId\n );\n }\n\n async libraryAssetsAmong(orgId: string, assetIds: string[]): Promise<string[]> {\n if (assetIds.length === 0) {\n return [];\n }\n const rows = await this.repo.findByIds(orgId, [...new Set(assetIds)]);\n const library = new Set(\n rows.filter((a) => a.source === 'library' && a.status === 'ready').map((a) => a.id),\n );\n return assetIds.filter((id) => library.has(id));\n }\n\n async list(orgId: string, query: AssetsQuery): Promise<Page<AssetView>> {\n const page = await this.repo.list(orgId, { ...query, source: query.source ?? 'library' });\n return { ...page, rows: await Promise.all(page.rows.map((asset) => this.view(asset))) };\n }\n\n async get(orgId: string, id: string): Promise<AssetView | null> {\n const asset = await this.repo.findById(orgId, id);\n return asset ? this.view(asset) : null;\n }\n\n async readyFiles(orgId: string, assetIds: readonly string[]): Promise<Record<string, ReadyFile>> {\n const ready = await this.readyAmong(orgId, assetIds);\n return Object.fromEntries(\n ready.map((asset) => [\n asset.id,\n { filename: asset.filename, contentType: asset.contentType, size: asset.size },\n ]),\n );\n }\n\n async signFile(\n orgId: string,\n assetId: string,\n opts: { saveAs?: string } = {},\n ): Promise<string | null> {\n const [asset] = await this.readyAmong(orgId, [assetId]);\n if (!asset) {\n this.logger.warn('file not served — no ready asset', { orgId, assetId });\n return null;\n }\n return opts.saveAs === undefined\n ? this.storage.presignDownload({ key: asset.key })\n : this.storage.presignDownload({\n key: asset.key,\n downloadFilename: opts.saveAs,\n expiresInSeconds: this.downloadLinkExpirySeconds,\n });\n }\n\n async libraryFile(\n orgId: string,\n assetId: string,\n opts: { download: boolean },\n ): Promise<string | null> {\n const asset = await this.repo.findById(orgId, assetId);\n if (!asset || asset.source !== 'library') {\n this.logger.warn('library file not served — not a library asset', { orgId, assetId });\n return null;\n }\n return this.signFile(orgId, assetId, opts.download ? { saveAs: asset.filename } : {});\n }\n\n async remove(orgId: string, id: string): Promise<boolean> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset) {\n return false;\n }\n const removed = await this.discard(orgId, asset);\n if (removed) {\n this.logger.info('asset removed', { orgId, assetId: id });\n }\n return removed;\n }\n\n async removeOwn(orgId: string, id: string, orgUserId: string): Promise<boolean> {\n const asset = await this.repo.findById(orgId, id);\n if (!asset || asset.source !== 'member' || asset.uploadedBy !== orgUserId) {\n return false;\n }\n return this.remove(orgId, id);\n }\n\n async hasUnattached(orgId: string, now: Date): Promise<boolean> {\n if (await this.repo.hasUnreferencedMemberUploads(orgId, graceCutoff(now))) {\n return true;\n }\n return (await this.strayUploads(orgId, now)).length > 0;\n }\n\n async removeUnattached(orgId: string, now: Date): Promise<number> {\n const stale = await this.repo.listUnreferencedMemberUploads(orgId, graceCutoff(now));\n let removed = 0;\n let failed = 0;\n for (const asset of stale) {\n try {\n if (await this.discard(orgId, asset)) {\n removed += 1;\n }\n } catch (err) {\n if (!(err instanceof ConflictError)) {\n failed += 1;\n this.logger.error('unattached upload not removed', { orgId, assetId: asset.id, err });\n }\n }\n }\n let stray = 0;\n for (const key of await this.strayUploads(orgId, now)) {\n try {\n await this.storage.remove(key);\n stray += 1;\n } catch (err) {\n failed += 1;\n this.logger.warn('stray upload not removed', { orgId, err });\n }\n }\n if (removed > 0 || stray > 0 || failed > 0) {\n this.logger.info('unattached uploads removed', { orgId, count: removed, stray, failed });\n }\n return removed + stray;\n }\n\n private async strayUploads(orgId: string, now: Date): Promise<string[]> {\n const cutoff = now.getTime() - this.storage.uploadExpirySeconds * 1000;\n const objects = await this.storage.list(`${orgPrefix(orgId)}uploads/`);\n const old = objects\n .filter((o) => o.lastModified !== undefined && Date.parse(o.lastModified) < cutoff)\n .map((o) => o.key);\n if (old.length === 0) {\n return [];\n }\n const held = new Set(await this.repo.heldKeys(orgId, old));\n return old.filter((key) => !held.has(key));\n }\n\n private async finalize(orgId: string, asset: Asset): Promise<Asset | null> {\n if (asset.status !== 'pending') {\n return asset;\n }\n if (asset.source === 'member') {\n return this.finalizeMember(orgId, asset);\n }\n const stat = await this.storage.stat(asset.key);\n if (!stat) {\n this.logger.debug('asset not yet uploaded', { orgId, assetId: asset.id });\n return asset;\n }\n const contentType = stat.contentType ?? asset.contentType;\n const ready = await this.markReady(orgId, asset, {\n key: asset.key,\n kind: kindOf(contentType),\n size: stat.size,\n contentType,\n });\n return ready ?? this.repo.findById(orgId, asset.id);\n }\n\n private async finalizeMember(orgId: string, asset: Asset): Promise<Asset | null> {\n if (!(await this.storage.stat(asset.key))) {\n this.logger.debug('asset not yet uploaded', { orgId, assetId: asset.id });\n return asset;\n }\n const key = finalMemberKey(orgId, asset);\n await this.storage.copy({ from: asset.key, to: key });\n const stat = await this.storage.stat(key);\n if (!stat) {\n throw new Error('copied upload not found');\n }\n const contentType = asset.contentType;\n const kind = kindOf(contentType);\n const reason = !isMemberImageType(contentType)\n ? MEMBER_IMAGE_REFUSAL\n : stat.contentType !== undefined && stat.contentType !== contentType\n ? 'The uploaded file is not the declared type'\n : stat.size > this.memberImageMaxBytes\n ? 'This image is too large'\n : null;\n if (reason) {\n await this.removeObject(orgId, asset.id, key);\n await this.discard(orgId, asset);\n this.logger.info('member upload refused', { orgId, assetId: asset.id, reason });\n throw new InvalidInputError(reason);\n }\n const ready = await this.markReady(orgId, asset, { key, kind, size: stat.size, contentType });\n if (!ready) {\n return this.repo.findById(orgId, asset.id);\n }\n await this.removeObject(orgId, asset.id, asset.key);\n return ready;\n }\n\n private async markReady(\n orgId: string,\n asset: Asset,\n patch: Pick<Asset, 'key' | 'kind' | 'size' | 'contentType'>,\n ): Promise<Asset | null> {\n const updated = await this.uow.run(async ({ assets, outbox }) => {\n const updated = await assets.markReady(orgId, asset.id, patch);\n if (updated) {\n await outbox.append([assetEvents.assetReady.make({ orgId, data: updated })]);\n }\n return updated;\n });\n if (updated) {\n this.logger.info('asset confirmed', { orgId, assetId: asset.id });\n }\n return updated;\n }\n\n private async readyAmong(orgId: string, assetIds: readonly string[]): Promise<Asset[]> {\n const ids = [...new Set(assetIds)];\n if (ids.length === 0) {\n return [];\n }\n const rows = await this.repo.findByIds(orgId, ids);\n return rows.filter((asset) => asset.orgId === orgId && asset.status === 'ready');\n }\n\n private view(asset: Asset): AssetView {\n if (asset.source !== 'library' || asset.status !== 'ready') {\n return { ...asset, url: null, downloadUrl: null };\n }\n return {\n ...asset,\n url: this.fileUrls.libraryFile(asset.id),\n downloadUrl: this.fileUrls.libraryDownload(asset.id),\n };\n }\n\n private async discard(orgId: string, asset: Asset): Promise<boolean> {\n const deleted = await this.uow.run(async ({ assets, outbox }) => {\n const deleted = await assets.delete(orgId, asset.id);\n if (deleted) {\n await outbox.append([assetEvents.assetDeleted.make({ orgId, data: asset })]);\n }\n return deleted;\n });\n if (deleted) {\n await this.removeObject(orgId, asset.id, asset.key);\n }\n return deleted;\n }\n\n private async removeObject(orgId: string, assetId: string, key: string): Promise<void> {\n try {\n await this.storage.remove(key);\n } catch (error) {\n this.logger.warn('stored object not removed', { orgId, assetId, err: error });\n }\n }\n}\n"],"mappings":"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"}
@@ -1,24 +1,21 @@
1
1
  import { DomainEvent } from "../types/shared.js";
2
- import { EmailTemplateId, EmailTemplateParams } from "../types/email-templates.js";
2
+ import { EmailTemplateId } from "../types/email-templates.js";
3
3
  import { AutomationAction } from "../types/schemas/automations.js";
4
4
  import "../types/index.js";
5
5
  import "../shared/ports.js";
6
- import { Mailer, MailerLookups } from "../shared/mailer.js";
7
6
  import "./model.js";
7
+ import { ActionDeps, EmailRecipient } from "./community-mail.js";
8
+ import { EmailDerivation } from "./community-derivations.js";
8
9
  //#region src/automations/actions.d.ts
9
- interface SendEmailDerivation<K extends EmailTemplateId> {
10
- /** The only event type this template's params can be derived from. */
11
- trigger: string;
12
- /** Undefined = the recipient or content behind the event's ids cannot be resolved. */
13
- derive(event: DomainEvent, lookups: MailerLookups): Promise<{
14
- to: string;
15
- params: EmailTemplateParams[K];
16
- } | undefined>;
17
- }
18
- type SendEmailDerivations = { [K in EmailTemplateId]?: SendEmailDerivation<K>; };
10
+ type SendEmailDerivations = { [K in EmailTemplateId]?: EmailDerivation<K>; };
19
11
  declare const SEND_EMAIL_DERIVATIONS: SendEmailDerivations;
20
- /** Throws on any failure — the engine owns retry policy and failure bookkeeping. */
21
- declare function executeAction(action: AutomationAction, event: DomainEvent, mailer: Pick<Mailer, 'send'>, lookups: MailerLookups): Promise<void>;
12
+ /** Who an action reached, and who a delivery to failed for (a partial failure does not throw). */
13
+ interface ActionOutcome {
14
+ recipients: string[];
15
+ failed: string[];
16
+ }
17
+ /** Throws on any failure but a partial fan-out — the engine owns retry policy and failure bookkeeping. */
18
+ declare function executeAction(action: AutomationAction, event: DomainEvent, deps: ActionDeps): Promise<ActionOutcome>;
22
19
  //#endregion
23
- export { SEND_EMAIL_DERIVATIONS, executeAction };
20
+ export { type ActionDeps, ActionOutcome, type EmailRecipient, SEND_EMAIL_DERIVATIONS, executeAction };
24
21
  //# sourceMappingURL=actions.d.ts.map