@headless-lms/core 0.2.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 (225) hide show
  1. package/dist/assets/events.d.ts +39 -21
  2. package/dist/assets/events.js +1 -1
  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 +291 -38
  13. package/dist/assets/service.js.map +1 -1
  14. package/dist/automations/actions.d.ts +13 -24
  15. package/dist/automations/actions.js +168 -43
  16. package/dist/automations/actions.js.map +1 -1
  17. package/dist/automations/catalog.d.ts +2 -1
  18. package/dist/automations/catalog.js +229 -13
  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 +28 -0
  30. package/dist/automations/index.d.ts +4 -2
  31. package/dist/automations/index.js +3 -2
  32. package/dist/automations/model.d.ts +10 -3
  33. package/dist/automations/model.js +15 -2
  34. package/dist/automations/model.js.map +1 -1
  35. package/dist/automations/ports.d.ts +5 -1
  36. package/dist/automations/schedule.d.ts +6 -0
  37. package/dist/automations/schedule.js +41 -0
  38. package/dist/automations/schedule.js.map +1 -0
  39. package/dist/automations/seeds.d.ts +17 -0
  40. package/dist/automations/seeds.js +235 -0
  41. package/dist/automations/seeds.js.map +1 -0
  42. package/dist/automations/service.d.ts +22 -3
  43. package/dist/automations/service.js +306 -43
  44. package/dist/automations/service.js.map +1 -1
  45. package/dist/content/events.d.ts +631 -19
  46. package/dist/content/events.js +44 -1
  47. package/dist/content/events.js.map +1 -1
  48. package/dist/content/index.d.ts +4 -4
  49. package/dist/content/model.d.ts +2 -2
  50. package/dist/content/ports.d.ts +71 -18
  51. package/dist/content/service.d.ts +57 -18
  52. package/dist/content/service.js +502 -23
  53. package/dist/content/service.js.map +1 -1
  54. package/dist/content/types.d.ts +2 -2
  55. package/dist/discussion/authors.d.ts +10 -0
  56. package/dist/discussion/authors.js +23 -0
  57. package/dist/discussion/authors.js.map +1 -0
  58. package/dist/discussion/chats.d.ts +104 -0
  59. package/dist/discussion/chats.js +798 -0
  60. package/dist/discussion/chats.js.map +1 -0
  61. package/dist/discussion/events.d.ts +619 -56
  62. package/dist/discussion/events.js +106 -3
  63. package/dist/discussion/events.js.map +1 -1
  64. package/dist/discussion/index.d.ts +7 -3
  65. package/dist/discussion/index.js +4 -1
  66. package/dist/discussion/ports.d.ts +288 -27
  67. package/dist/discussion/ports.js +6 -1
  68. package/dist/discussion/ports.js.map +1 -0
  69. package/dist/discussion/posts.d.ts +103 -0
  70. package/dist/discussion/posts.js +723 -0
  71. package/dist/discussion/posts.js.map +1 -0
  72. package/dist/discussion/service.d.ts +32 -10
  73. package/dist/discussion/service.js +293 -78
  74. package/dist/discussion/service.js.map +1 -1
  75. package/dist/entitlements/events.d.ts +178 -11
  76. package/dist/entitlements/events.js +28 -1
  77. package/dist/entitlements/events.js.map +1 -1
  78. package/dist/entitlements/index.d.ts +5 -4
  79. package/dist/entitlements/index.js +2 -1
  80. package/dist/entitlements/model.d.ts +3 -2
  81. package/dist/entitlements/ports.d.ts +124 -11
  82. package/dist/entitlements/rules.d.ts +12 -0
  83. package/dist/entitlements/rules.js +17 -0
  84. package/dist/entitlements/rules.js.map +1 -0
  85. package/dist/entitlements/service.d.ts +54 -3
  86. package/dist/entitlements/service.js +472 -2
  87. package/dist/entitlements/service.js.map +1 -1
  88. package/dist/identity/access.d.ts +6 -0
  89. package/dist/identity/access.js +7 -0
  90. package/dist/identity/access.js.map +1 -0
  91. package/dist/identity/index.d.ts +6 -4
  92. package/dist/identity/index.js +3 -1
  93. package/dist/identity/model.d.ts +2 -1
  94. package/dist/identity/notifications.d.ts +27 -0
  95. package/dist/identity/notifications.js +101 -0
  96. package/dist/identity/notifications.js.map +1 -0
  97. package/dist/identity/ports.d.ts +73 -3
  98. package/dist/identity/service.d.ts +14 -3
  99. package/dist/identity/service.js +62 -5
  100. package/dist/identity/service.js.map +1 -1
  101. package/dist/integrations/model.js +1 -1
  102. package/dist/integrations/model.js.map +1 -1
  103. package/dist/integrations/service.js +2 -2
  104. package/dist/integrations/service.js.map +1 -1
  105. package/dist/organizations/access-scopes.d.ts +19 -0
  106. package/dist/organizations/access-scopes.js +89 -0
  107. package/dist/organizations/access-scopes.js.map +1 -0
  108. package/dist/organizations/events.d.ts +74 -32
  109. package/dist/organizations/events.js +6 -0
  110. package/dist/organizations/events.js.map +1 -1
  111. package/dist/organizations/index.d.ts +7 -6
  112. package/dist/organizations/index.js +4 -3
  113. package/dist/organizations/members.d.ts +8 -5
  114. package/dist/organizations/members.js.map +1 -1
  115. package/dist/organizations/ports.d.ts +14 -11
  116. package/dist/organizations/roles.d.ts +6 -10
  117. package/dist/organizations/roles.js +19 -7
  118. package/dist/organizations/roles.js.map +1 -1
  119. package/dist/organizations/service.d.ts +20 -11
  120. package/dist/organizations/service.js +157 -47
  121. package/dist/organizations/service.js.map +1 -1
  122. package/dist/organizations/types.d.ts +1 -10
  123. package/dist/progress/index.d.ts +2 -2
  124. package/dist/progress/ports.d.ts +6 -1
  125. package/dist/progress/service.d.ts +3 -1
  126. package/dist/progress/service.js +6 -2
  127. package/dist/progress/service.js.map +1 -1
  128. package/dist/reporting/admin/index.d.ts +4 -0
  129. package/dist/reporting/admin/index.js +2 -0
  130. package/dist/reporting/admin/model.d.ts +108 -0
  131. package/dist/reporting/admin/ports.d.ts +27 -0
  132. package/dist/reporting/admin/service.d.ts +22 -0
  133. package/dist/reporting/admin/service.js +38 -0
  134. package/dist/reporting/admin/service.js.map +1 -0
  135. package/dist/reporting/member/index.d.ts +5 -0
  136. package/dist/reporting/member/index.js +2 -0
  137. package/dist/reporting/member/model.d.ts +124 -0
  138. package/dist/reporting/member/ports.d.ts +45 -0
  139. package/dist/reporting/member/service.d.ts +73 -0
  140. package/dist/reporting/member/service.js +286 -0
  141. package/dist/reporting/member/service.js.map +1 -0
  142. package/dist/shared/actor.d.ts +9 -0
  143. package/dist/shared/errors.d.ts +7 -1
  144. package/dist/shared/errors.js +10 -1
  145. package/dist/shared/errors.js.map +1 -1
  146. package/dist/shared/id.d.ts +11 -0
  147. package/dist/shared/id.js +12 -1
  148. package/dist/shared/id.js.map +1 -1
  149. package/dist/shared/mailer.d.ts +4 -1
  150. package/dist/shared/mailer.js.map +1 -1
  151. package/dist/shared/network.d.ts +10 -0
  152. package/dist/shared/network.js +211 -0
  153. package/dist/shared/network.js.map +1 -0
  154. package/dist/types/assets.d.ts +2 -2
  155. package/dist/types/automations.d.ts +10 -2
  156. package/dist/types/content.d.ts +2 -2
  157. package/dist/types/discussion.d.ts +11 -3
  158. package/dist/types/email-templates.d.ts +121 -0
  159. package/dist/types/entitlements.d.ts +2 -2
  160. package/dist/types/identity.d.ts +48 -2
  161. package/dist/types/index.d.ts +9 -9
  162. package/dist/types/ports.d.ts +6 -0
  163. package/dist/types/schemas/assets.d.ts +65 -37
  164. package/dist/types/schemas/assets.js +17 -12
  165. package/dist/types/schemas/assets.js.map +1 -1
  166. package/dist/types/schemas/automations.d.ts +18 -1
  167. package/dist/types/schemas/automations.js +15 -6
  168. package/dist/types/schemas/automations.js.map +1 -1
  169. package/dist/types/schemas/content.d.ts +335 -3
  170. package/dist/types/schemas/content.js +139 -3
  171. package/dist/types/schemas/content.js.map +1 -1
  172. package/dist/types/schemas/discussion.d.ts +370 -3
  173. package/dist/types/schemas/discussion.js +199 -4
  174. package/dist/types/schemas/discussion.js.map +1 -1
  175. package/dist/types/schemas/entitlements.d.ts +36 -2
  176. package/dist/types/schemas/entitlements.js +23 -1
  177. package/dist/types/schemas/entitlements.js.map +1 -1
  178. package/dist/types/schemas/identity.d.ts +111 -1
  179. package/dist/types/schemas/identity.js +55 -2
  180. package/dist/types/schemas/identity.js.map +1 -1
  181. package/dist/types/schemas/index.d.ts +8 -8
  182. package/dist/types/schemas/index.js +8 -8
  183. package/dist/types/schemas/organizations.d.ts +8 -0
  184. package/dist/types/schemas/organizations.js +2 -0
  185. package/dist/types/schemas/organizations.js.map +1 -1
  186. package/dist/types/schemas/shared.d.ts +3 -1
  187. package/dist/types/schemas/shared.js +5 -1
  188. package/dist/types/schemas/shared.js.map +1 -1
  189. package/package.json +2 -2
  190. package/dist/reporting/courses/index.d.ts +0 -4
  191. package/dist/reporting/courses/index.js +0 -2
  192. package/dist/reporting/courses/model.d.ts +0 -24
  193. package/dist/reporting/courses/ports.d.ts +0 -13
  194. package/dist/reporting/courses/service.d.ts +0 -19
  195. package/dist/reporting/courses/service.js +0 -20
  196. package/dist/reporting/courses/service.js.map +0 -1
  197. package/dist/reporting/dashboard/index.d.ts +0 -4
  198. package/dist/reporting/dashboard/index.js +0 -2
  199. package/dist/reporting/dashboard/model.d.ts +0 -14
  200. package/dist/reporting/dashboard/ports.d.ts +0 -13
  201. package/dist/reporting/dashboard/service.d.ts +0 -19
  202. package/dist/reporting/dashboard/service.js +0 -20
  203. package/dist/reporting/dashboard/service.js.map +0 -1
  204. package/dist/reporting/learn/index.d.ts +0 -5
  205. package/dist/reporting/learn/index.js +0 -2
  206. package/dist/reporting/learn/model.d.ts +0 -23
  207. package/dist/reporting/learn/ports.d.ts +0 -43
  208. package/dist/reporting/learn/ports.js +0 -1
  209. package/dist/reporting/learn/service.d.ts +0 -49
  210. package/dist/reporting/learn/service.js +0 -116
  211. package/dist/reporting/learn/service.js.map +0 -1
  212. package/dist/reporting/students/index.d.ts +0 -4
  213. package/dist/reporting/students/index.js +0 -2
  214. package/dist/reporting/students/model.d.ts +0 -51
  215. package/dist/reporting/students/model.js +0 -1
  216. package/dist/reporting/students/ports.d.ts +0 -15
  217. package/dist/reporting/students/ports.js +0 -1
  218. package/dist/reporting/students/service.d.ts +0 -20
  219. package/dist/reporting/students/service.js +0 -23
  220. package/dist/reporting/students/service.js.map +0 -1
  221. /package/dist/reporting/{courses → admin}/model.js +0 -0
  222. /package/dist/reporting/{courses → admin}/ports.js +0 -0
  223. /package/dist/reporting/{dashboard → member}/model.js +0 -0
  224. /package/dist/reporting/{dashboard → member}/ports.js +0 -0
  225. /package/dist/{reporting/learn/model.js → shared/actor.js} +0 -0
@@ -1,8 +1,10 @@
1
- import { noopLogger } from "../shared/logger.js";
2
1
  import { genId } from "../shared/id.js";
3
- import { ForbiddenError, NotFoundError } from "../shared/errors.js";
2
+ import { ConflictError, ForbiddenError, NotFoundError } from "../shared/errors.js";
3
+ import { noopLogger } from "../shared/logger.js";
4
+ import { canModerate, isStaffRole } from "../organizations/roles.js";
4
5
  import "../shared/settings.js";
5
6
  import { discussionEvents } from "./events.js";
7
+ import { authorView } from "./authors.js";
6
8
  //#region src/discussion/service.ts
7
9
  /** A course with no stored settings. Discussion is opt-in, so the common case
8
10
  * persists no row at all and every existing course stays silent. */
@@ -17,15 +19,21 @@ var DiscussionServiceImpl = class {
17
19
  repo;
18
20
  access;
19
21
  content;
22
+ posts;
20
23
  uow;
21
24
  settings;
25
+ communities;
26
+ fileUrls;
22
27
  logger;
23
28
  constructor(params) {
24
29
  this.repo = params.repo;
25
30
  this.access = params.access;
26
31
  this.content = params.content;
32
+ this.posts = params.posts;
27
33
  this.uow = params.uow;
28
34
  this.settings = params.settings;
35
+ this.communities = params.communities;
36
+ this.fileUrls = params.fileUrls;
29
37
  this.logger = params.logger ?? noopLogger;
30
38
  }
31
39
  async resolveConfig(orgId, activityId) {
@@ -44,33 +52,24 @@ var DiscussionServiceImpl = class {
44
52
  reactions: settings.reactions
45
53
  };
46
54
  }
47
- /** Any org user that is not a learner moderates. The role arrives from
48
- * the edge already resolved; core never looks one up to authorise. */
49
55
  isStaff(actor) {
50
- return actor.role !== "student";
56
+ return isStaffRole(actor.role);
51
57
  }
52
- toAuthor(record) {
53
- return {
54
- id: record.id,
55
- firstName: record.firstName,
56
- lastName: record.lastName,
57
- image: record.image,
58
- role: record.role
59
- };
60
- }
61
- toView(comment, authors, reactions) {
58
+ toView(orgId, comment, authors, urls, reactions) {
62
59
  const author = authors[comment.orgUserId];
63
- if (!author) throw new NotFoundError("OrgUser", comment.orgUserId);
64
- if (comment.removedBy && !authors[comment.removedBy]) throw new NotFoundError("OrgUser", comment.removedBy);
60
+ if (!author) throw new NotFoundError("Member", comment.orgUserId);
61
+ if (comment.removedBy && !authors[comment.removedBy]) throw new NotFoundError("Member", comment.removedBy);
65
62
  const remover = comment.removedBy ? authors[comment.removedBy] : void 0;
66
63
  return {
67
64
  id: comment.id,
68
65
  activityId: comment.activityId,
66
+ postId: comment.postId,
67
+ communityId: comment.communityId,
69
68
  parentId: comment.parentId,
70
- author: this.toAuthor(author),
69
+ author: authorView(orgId, author, urls),
71
70
  body: comment.status === "removed" ? null : comment.body,
72
71
  status: comment.status,
73
- removedBy: remover ? this.toAuthor(remover) : null,
72
+ removedBy: remover ? authorView(orgId, remover, urls) : null,
74
73
  reactions: reactions.reactions,
75
74
  viewerReaction: reactions.viewerReaction,
76
75
  createdAt: comment.createdAt,
@@ -88,29 +87,46 @@ var DiscussionServiceImpl = class {
88
87
  async renderOne(orgId, comment, actor) {
89
88
  const authors = await this.repo.authorsOf(orgId, this.peopleIn([comment]));
90
89
  const stored = comment.status === "removed" ? EMPTY_REACTIONS : (await this.repo.reactionsOf(orgId, [comment.id], actor.id))[comment.id] ?? EMPTY_REACTIONS;
91
- return this.toView(comment, authors, stored);
90
+ return this.toView(orgId, comment, authors, this.fileUrls, stored);
91
+ }
92
+ activityIdOf(comment) {
93
+ if (comment.activityId === null) throw new NotFoundError("Activity", comment.id);
94
+ return comment.activityId;
92
95
  }
93
96
  async hasAccess(orgId, activityId, actor) {
94
97
  if (this.isStaff(actor)) return true;
95
98
  const activity = await this.content.getActivity(orgId, activityId);
96
- return activity ? this.access.hasCourseAccess(orgId, actor.id, activity.courseId) : false;
99
+ return activity ? this.access.hasAccess(orgId, actor.id, activity.courseId) : false;
97
100
  }
98
- async postComment(orgId, { activityId, actor, parentId, body }) {
99
- if (!await this.hasAccess(orgId, activityId, actor)) throw new ForbiddenError("no access to this activity");
101
+ async requireActivityOpen(orgId, activityId, actor) {
102
+ const activity = await this.content.getActivity(orgId, activityId);
103
+ if (!activity) throw new NotFoundError("Activity", activityId);
104
+ if (!this.isStaff(actor) && !await this.access.hasAccess(orgId, actor.id, activity.courseId)) throw new ForbiddenError("You cannot open this activity");
105
+ }
106
+ async postComment(orgId, input) {
107
+ if (input.postId) return this.postOnPost(orgId, input.postId, input);
108
+ const activityId = input.activityId;
109
+ if (!activityId) throw new NotFoundError("Activity", activityId ?? "");
110
+ return this.postOnActivity(orgId, activityId, input);
111
+ }
112
+ async postOnActivity(orgId, activityId, { actor, parentId, body }) {
113
+ await this.requireActivityOpen(orgId, activityId, actor);
100
114
  const config = await this.resolveConfig(orgId, activityId);
101
- if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
115
+ if (!config.enabled) throw new ForbiddenError("Discussion is not open on this activity");
102
116
  if (parentId !== null) {
103
- if (!config.threaded) throw new ForbiddenError("replies are disabled on this course");
117
+ if (!config.threaded) throw new ForbiddenError("Replies are off on this course");
104
118
  const parent = await this.repo.findComment(orgId, parentId);
105
119
  if (!parent || parent.activityId !== activityId) throw new NotFoundError("Comment", parentId);
106
- if (parent.parentId !== null) throw new ForbiddenError("replies nest one level");
107
- if (parent.status !== "published") throw new ForbiddenError("cannot reply to a comment that is not published");
120
+ if (parent.parentId !== null) throw new ForbiddenError("A reply cannot have replies of its own");
121
+ if (parent.status !== "published") throw new ForbiddenError("You can reply only to a published comment");
108
122
  }
109
123
  const status = config.requireReview && !this.isStaff(actor) ? "pending" : "published";
110
124
  const comment = {
111
125
  id: genId("comment"),
112
126
  orgId,
113
127
  activityId,
128
+ postId: null,
129
+ communityId: null,
114
130
  parentId,
115
131
  orgUserId: actor.id,
116
132
  body,
@@ -128,47 +144,151 @@ var DiscussionServiceImpl = class {
128
144
  this.logger.info("comment created", {
129
145
  orgId,
130
146
  commentId: row.id,
131
- status
147
+ status,
148
+ actorId: actor.id
149
+ });
150
+ return row;
151
+ });
152
+ return this.renderOne(orgId, saved, actor);
153
+ }
154
+ async postOnPost(orgId, postId, { actor, parentId, body }) {
155
+ const { post, space } = await this.visiblePost(orgId, actor, postId);
156
+ if (post.status !== "published") throw new ConflictError("This post is not published");
157
+ if (!this.commentsOpen(post, space)) throw new ForbiddenError("Comments are closed on this post");
158
+ if (parentId !== null) {
159
+ const parent = await this.repo.findComment(orgId, parentId);
160
+ if (!parent || parent.postId !== postId) throw new NotFoundError("Comment", parentId);
161
+ if (parent.parentId !== null) throw new ForbiddenError("A reply cannot have replies of its own");
162
+ if (parent.status !== "published") throw new ForbiddenError("You can reply only to a published comment");
163
+ }
164
+ const now = /* @__PURE__ */ new Date();
165
+ const comment = {
166
+ id: genId("comment"),
167
+ orgId,
168
+ activityId: null,
169
+ postId,
170
+ communityId: post.communityId,
171
+ parentId,
172
+ orgUserId: actor.id,
173
+ body,
174
+ status: "published",
175
+ removedBy: null,
176
+ createdAt: now,
177
+ updatedAt: now
178
+ };
179
+ const saved = await this.uow.run(async (scope) => {
180
+ const row = await scope.discussion.insertComment(orgId, comment);
181
+ await scope.outbox.append([discussionEvents.commentCreated.make({
182
+ orgId,
183
+ data: row,
184
+ metadata: { actorOrgUserId: actor.id }
185
+ })]);
186
+ this.logger.info("comment created", {
187
+ orgId,
188
+ commentId: row.id,
189
+ postId,
190
+ status: "published",
191
+ actorId: actor.id
132
192
  });
133
193
  return row;
134
194
  });
135
195
  return this.renderOne(orgId, saved, actor);
136
196
  }
197
+ async postComments(orgId, postId, actor, query) {
198
+ const { post, space } = await this.visiblePost(orgId, actor, postId);
199
+ const seesHeld = (person) => this.canModerate(person);
200
+ const page = await this.repo.listThreadsByPost(orgId, postId, {
201
+ ...query,
202
+ viewerId: actor.id,
203
+ seesHeld: seesHeld(actor)
204
+ });
205
+ const comments = await this.thread(orgId, page.rows, actor, post.reactionsEnabled ?? space.settings.reactions, seesHeld);
206
+ this.logger.debug("post comments read", {
207
+ orgId,
208
+ postId,
209
+ count: comments.length
210
+ });
211
+ return {
212
+ ...page,
213
+ rows: comments
214
+ };
215
+ }
216
+ async thread(orgId, rows, actor, reactionsOn, seesHeld) {
217
+ const readable = rows.filter((comment) => {
218
+ if (comment.status === "published" || comment.status === "removed") return true;
219
+ return seesHeld(actor) || comment.orgUserId === actor.id;
220
+ });
221
+ const replies = readable.filter((comment) => comment.parentId !== null && comment.status !== "removed");
222
+ const heldOpen = new Set(replies.map((reply) => reply.parentId));
223
+ const roots = readable.filter((comment) => comment.parentId === null && (comment.status !== "removed" || heldOpen.has(comment.id)));
224
+ const rootIds = new Set(roots.map((root) => root.id));
225
+ const served = [...roots, ...replies.filter((reply) => rootIds.has(reply.parentId))].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
226
+ const [authors, reactions] = await Promise.all([this.repo.authorsOf(orgId, this.peopleIn(served)), reactionsOn ? this.repo.reactionsOf(orgId, served.map((comment) => comment.id), actor.id) : Promise.resolve({})]);
227
+ const urls = this.fileUrls;
228
+ return served.map((comment) => this.toView(orgId, comment, authors, urls, reactions[comment.id] ?? EMPTY_REACTIONS));
229
+ }
230
+ async visiblePost(orgId, actor, postId) {
231
+ return this.posts.visible(orgId, actor, postId);
232
+ }
233
+ async postOfComment(orgId, actor, commentId, postId) {
234
+ try {
235
+ return await this.visiblePost(orgId, actor, postId);
236
+ } catch (error) {
237
+ if (error instanceof NotFoundError) throw new NotFoundError("Comment", commentId);
238
+ throw error;
239
+ }
240
+ }
241
+ canModerate(actor) {
242
+ return canModerate(actor.role);
243
+ }
244
+ inCommunity(comment, communityId) {
245
+ return communityId === void 0 || comment.communityId === communityId;
246
+ }
247
+ commentsOpen(post, space) {
248
+ return post.commentsClosed === null ? space.settings.comments : !post.commentsClosed;
249
+ }
137
250
  async activityComments(orgId, activityId, actor) {
251
+ await this.requireActivityOpen(orgId, activityId, actor);
138
252
  const config = await this.resolveConfig(orgId, activityId);
139
253
  if (!config.enabled) return {
140
254
  config,
141
255
  comments: []
142
256
  };
143
- const readable = (await this.repo.listByActivity(orgId, activityId)).filter((c) => {
144
- if (c.status === "published" || c.status === "removed") return true;
145
- return this.isStaff(actor) || c.orgUserId === actor.id;
146
- });
147
- const replies = readable.filter((c) => c.parentId !== null && c.status !== "removed");
148
- const heldOpen = new Set(replies.map((r) => r.parentId));
149
- const roots = readable.filter((c) => c.parentId === null && (c.status !== "removed" || heldOpen.has(c.id)));
150
- const rootIds = new Set(roots.map((r) => r.id));
151
- const served = [...roots, ...replies.filter((r) => rootIds.has(r.parentId))].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
152
- const [authors, reactions] = await Promise.all([this.repo.authorsOf(orgId, this.peopleIn(served)), config.reactions ? this.repo.reactionsOf(orgId, served.map((c) => c.id), actor.id) : Promise.resolve({})]);
257
+ const rows = await this.repo.listByActivity(orgId, activityId);
153
258
  return {
154
259
  config,
155
- comments: served.map((c) => this.toView(c, authors, reactions[c.id] ?? EMPTY_REACTIONS))
260
+ comments: await this.thread(orgId, rows, actor, config.reactions, (person) => this.isStaff(person))
156
261
  };
157
262
  }
158
263
  async edit(orgId, commentId, actor, body) {
159
264
  const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
160
- if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
161
- if (comment.orgUserId !== actor.id) throw new ForbiddenError("only the author may edit a comment");
162
- if (comment.status === "removed") throw new ForbiddenError("a removed comment cannot be edited");
265
+ if (!config.enabled) throw new ForbiddenError(comment.postId ? "Comments are closed on this post" : "Discussion is not open on this activity");
266
+ if (comment.orgUserId !== actor.id) throw new ForbiddenError("You can edit only your own comment");
267
+ if (comment.status === "removed") throw new ForbiddenError("A removed comment cannot be edited");
163
268
  const updated = await this.repo.updateComment(orgId, commentId, { body });
164
269
  if (!updated) throw new NotFoundError("Comment", commentId);
165
270
  return this.renderOne(orgId, updated, actor);
166
271
  }
167
- async remove(orgId, commentId, actor) {
272
+ async remove(orgId, commentId, actor, communityId) {
273
+ return this.renderOne(orgId, await this.removeComment(orgId, commentId, actor, communityId), actor);
274
+ }
275
+ async removeComment(orgId, commentId, actor, communityId) {
168
276
  const comment = await this.getComment(orgId, commentId);
169
- if (!comment) throw new NotFoundError("Comment", commentId);
170
- if (!await this.hasAccess(orgId, comment.activityId, actor)) throw new ForbiddenError("no access to this activity");
171
- if (comment.orgUserId !== actor.id && !this.isStaff(actor)) throw new ForbiddenError("only the author or a moderator may remove a comment");
277
+ if (!comment || !this.inCommunity(comment, communityId)) {
278
+ this.logger.warn("comment remove rejected — not found", {
279
+ orgId,
280
+ commentId,
281
+ actorId: actor.id
282
+ });
283
+ throw new NotFoundError("Comment", commentId);
284
+ }
285
+ if (comment.postId) {
286
+ await this.postOfComment(orgId, actor, commentId, comment.postId);
287
+ if (comment.orgUserId !== actor.id && !this.canModerate(actor)) throw new ForbiddenError("You cannot remove this comment");
288
+ } else {
289
+ if (!await this.hasAccess(orgId, this.activityIdOf(comment), actor)) throw new ForbiddenError("You cannot open this activity");
290
+ if (comment.orgUserId !== actor.id && !this.isStaff(actor)) throw new ForbiddenError("You cannot remove this comment");
291
+ }
172
292
  if (comment.status === "removed") return comment;
173
293
  return this.uow.run(async (scope) => {
174
294
  const updated = await scope.discussion.updateComment(orgId, commentId, {
@@ -176,28 +296,49 @@ var DiscussionServiceImpl = class {
176
296
  removedBy: actor.id
177
297
  });
178
298
  if (!updated) throw new NotFoundError("Comment", commentId);
299
+ if (comment.orgUserId !== actor.id) await scope.discussion.resolveReportsFor(orgId, commentId, {
300
+ resolvedAt: /* @__PURE__ */ new Date(),
301
+ resolvedBy: actor.id,
302
+ resolution: "removed"
303
+ });
179
304
  await scope.outbox.append([discussionEvents.commentRemoved.make({
180
305
  orgId,
181
- data: updated
306
+ data: updated,
307
+ metadata: updated.postId ? { actorOrgUserId: actor.id } : void 0
182
308
  })]);
183
309
  this.logger.info("comment removed", {
184
310
  orgId,
185
311
  commentId,
186
- by: actor.id
312
+ actorId: actor.id
187
313
  });
188
314
  return updated;
189
315
  });
190
316
  }
191
- async restore(orgId, commentId, actor) {
192
- if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may restore a comment");
317
+ async restore(orgId, commentId, actor, communityId) {
318
+ return this.renderOne(orgId, await this.restoreComment(orgId, commentId, actor, communityId), actor);
319
+ }
320
+ async restoreComment(orgId, commentId, actor, communityId) {
321
+ if (!this.isStaff(actor)) throw new ForbiddenError("You cannot restore this comment");
193
322
  const comment = await this.getComment(orgId, commentId);
194
- if (!comment) throw new NotFoundError("Comment", commentId);
323
+ if (!comment || !this.inCommunity(comment, communityId)) {
324
+ this.logger.warn("comment restore rejected — not found", {
325
+ orgId,
326
+ commentId,
327
+ actorId: actor.id
328
+ });
329
+ throw new NotFoundError("Comment", commentId);
330
+ }
195
331
  if (comment.status !== "removed") return comment;
196
332
  const updated = await this.repo.updateComment(orgId, commentId, {
197
333
  status: "published",
198
334
  removedBy: null
199
335
  });
200
336
  if (!updated) throw new NotFoundError("Comment", commentId);
337
+ this.logger.info("comment restored", {
338
+ orgId,
339
+ commentId,
340
+ actorId: actor.id
341
+ });
201
342
  return updated;
202
343
  }
203
344
  /** Load a comment together with its activity's resolved comments config,
@@ -206,25 +347,45 @@ var DiscussionServiceImpl = class {
206
347
  async loadWithConfig(orgId, commentId, actor) {
207
348
  const comment = await this.getComment(orgId, commentId);
208
349
  if (!comment) throw new NotFoundError("Comment", commentId);
209
- if (!await this.hasAccess(orgId, comment.activityId, actor)) throw new ForbiddenError("no access to this activity");
350
+ if (comment.postId) {
351
+ const { post, space } = await this.postOfComment(orgId, actor, commentId, comment.postId);
352
+ return {
353
+ comment,
354
+ config: {
355
+ enabled: this.commentsOpen(post, space),
356
+ threaded: true,
357
+ requireReview: false,
358
+ reactions: post.reactionsEnabled ?? space.settings.reactions
359
+ }
360
+ };
361
+ }
362
+ const activityId = this.activityIdOf(comment);
363
+ if (!await this.hasAccess(orgId, activityId, actor)) throw new ForbiddenError("You cannot open this activity");
210
364
  return {
211
365
  comment,
212
- config: await this.resolveConfig(orgId, comment.activityId)
366
+ config: await this.resolveConfig(orgId, activityId)
213
367
  };
214
368
  }
215
369
  async setReaction(orgId, commentId, actor, emoji) {
216
370
  const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
217
- if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
218
- if (!config.reactions && emoji !== null) throw new ForbiddenError("reactions are disabled on this course");
219
- if (comment.status === "removed") throw new ForbiddenError("a removed comment cannot be reacted to");
371
+ if (!config.enabled && !comment.postId) throw new ForbiddenError("Discussion is not open on this activity");
372
+ if (!config.reactions && emoji !== null) throw new ForbiddenError(comment.postId ? "Reactions are off on this post" : "Reactions are off on this course");
373
+ if (comment.status === "removed") throw new ForbiddenError("A removed comment cannot be reacted to");
220
374
  await this.repo.setReaction(orgId, commentId, actor.id, emoji);
221
375
  return (await this.repo.reactionsOf(orgId, [commentId], actor.id))[commentId] ?? EMPTY_REACTIONS;
222
376
  }
223
377
  async approve(orgId, commentId, actor) {
224
- if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may approve a comment");
378
+ if (!this.isStaff(actor)) throw new ForbiddenError("You cannot approve this comment");
225
379
  const comment = await this.getComment(orgId, commentId);
226
- if (!comment) throw new NotFoundError("Comment", commentId);
227
- if (comment.status !== "pending") throw new ForbiddenError("only a pending comment can be approved");
380
+ if (!comment) {
381
+ this.logger.warn("comment approve rejected — not found", {
382
+ orgId,
383
+ commentId,
384
+ actorId: actor.id
385
+ });
386
+ throw new NotFoundError("Comment", commentId);
387
+ }
388
+ if (comment.status !== "pending") throw new ForbiddenError("Only a comment awaiting review can be approved");
228
389
  return this.uow.run(async (scope) => {
229
390
  const updated = await scope.discussion.updateComment(orgId, commentId, { status: "published" });
230
391
  if (!updated) throw new NotFoundError("Comment", commentId);
@@ -234,7 +395,8 @@ var DiscussionServiceImpl = class {
234
395
  })]);
235
396
  this.logger.info("comment published", {
236
397
  orgId,
237
- commentId
398
+ commentId,
399
+ actorId: actor.id
238
400
  });
239
401
  return updated;
240
402
  });
@@ -243,26 +405,49 @@ var DiscussionServiceImpl = class {
243
405
  * caller must not decide that itself. `approve` and `restore` still enforce
244
406
  * their own staff check; the check here covers the third path, where a
245
407
  * comment is already published and neither of them would run. */
246
- async publish(orgId, commentId, actor) {
247
- if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may publish a comment");
408
+ async publish(orgId, commentId, actor, communityId) {
409
+ if (!this.isStaff(actor)) throw new ForbiddenError("You cannot publish this comment");
248
410
  const comment = await this.getComment(orgId, commentId);
249
- if (!comment) throw new NotFoundError("Comment", commentId);
411
+ if (!comment || !this.inCommunity(comment, communityId)) {
412
+ this.logger.warn("comment publish rejected — not found", {
413
+ orgId,
414
+ commentId,
415
+ actorId: actor.id
416
+ });
417
+ throw new NotFoundError("Comment", commentId);
418
+ }
250
419
  if (comment.status === "pending") return this.approve(orgId, commentId, actor);
251
- if (comment.status === "removed") return this.restore(orgId, commentId, actor);
420
+ if (comment.status === "removed") return this.restoreComment(orgId, commentId, actor);
252
421
  return comment;
253
422
  }
254
- async resolveReports(orgId, commentId, actor) {
255
- if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may resolve a report");
256
- if (!await this.getComment(orgId, commentId)) throw new NotFoundError("Comment", commentId);
257
- await this.repo.resolveReportsFor(orgId, commentId, /* @__PURE__ */ new Date());
423
+ async resolveReports(orgId, commentId, actor, communityId) {
424
+ const comment = await this.getComment(orgId, commentId);
425
+ if (!comment || !this.inCommunity(comment, communityId)) {
426
+ this.logger.warn("comment reports resolve rejected — not found", {
427
+ orgId,
428
+ commentId,
429
+ actorId: actor.id
430
+ });
431
+ throw new NotFoundError("Comment", commentId);
432
+ }
433
+ if (comment.postId) {
434
+ await this.postOfComment(orgId, actor, commentId, comment.postId);
435
+ if (!this.canModerate(actor)) throw new ForbiddenError("You cannot work this queue");
436
+ } else if (!this.isStaff(actor)) throw new ForbiddenError("You cannot resolve this report");
437
+ await this.repo.resolveReportsFor(orgId, commentId, {
438
+ resolvedAt: /* @__PURE__ */ new Date(),
439
+ resolvedBy: actor.id,
440
+ resolution: "dismissed"
441
+ });
258
442
  this.logger.info("comment reports resolved", {
259
443
  orgId,
260
- commentId
444
+ commentId,
445
+ actorId: actor.id
261
446
  });
262
447
  }
263
448
  async reportComment(orgId, commentId, actor, reason) {
264
- const { config } = await this.loadWithConfig(orgId, commentId, actor);
265
- if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
449
+ const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
450
+ if (!config.enabled && !comment.postId) throw new ForbiddenError("Discussion is not open on this activity");
266
451
  const now = /* @__PURE__ */ new Date();
267
452
  const report = {
268
453
  id: genId("commentReport"),
@@ -271,6 +456,8 @@ var DiscussionServiceImpl = class {
271
456
  orgUserId: actor.id,
272
457
  reason,
273
458
  resolvedAt: null,
459
+ resolution: null,
460
+ resolvedBy: null,
274
461
  createdAt: now,
275
462
  updatedAt: now
276
463
  };
@@ -283,11 +470,16 @@ var DiscussionServiceImpl = class {
283
470
  }
284
471
  await scope.outbox.append([discussionEvents.commentReported.make({
285
472
  orgId,
286
- data: saved
473
+ data: {
474
+ ...saved,
475
+ communityId: comment.communityId
476
+ },
477
+ metadata: comment.postId ? { actorOrgUserId: actor.id } : void 0
287
478
  })]);
288
479
  this.logger.info("comment reported", {
289
480
  orgId,
290
- commentId
481
+ commentId,
482
+ actorId: actor.id
291
483
  });
292
484
  return saved;
293
485
  });
@@ -301,29 +493,30 @@ var DiscussionServiceImpl = class {
301
493
  };
302
494
  const reports = await this.repo.listOpenReports(orgId, rows.map((c) => c.id));
303
495
  const authors = await this.repo.authorsOf(orgId, [.../* @__PURE__ */ new Set([...this.peopleIn(rows), ...reports.map((r) => r.orgUserId)])]);
496
+ const urls = this.fileUrls;
304
497
  const byComment = /* @__PURE__ */ new Map();
305
498
  for (const report of reports) byComment.set(report.commentId, [...byComment.get(report.commentId) ?? [], report]);
306
499
  return {
307
500
  ...page,
308
501
  rows: page.rows.map(({ comment, courseId, activityTitle }) => {
309
502
  const author = authors[comment.orgUserId];
310
- if (!author) throw new NotFoundError("OrgUser", comment.orgUserId);
503
+ if (!author) throw new NotFoundError("Member", comment.orgUserId);
311
504
  return {
312
505
  id: comment.id,
313
506
  parentId: comment.parentId,
314
- activityId: comment.activityId,
507
+ activityId: this.activityIdOf(comment),
315
508
  activityTitle,
316
509
  courseId,
317
510
  body: comment.body,
318
511
  status: comment.status,
319
- author: this.toAuthor(author),
512
+ author: authorView(orgId, author, urls),
320
513
  authorEmail: author.email,
321
514
  removedBy: comment.removedBy,
322
515
  reports: (byComment.get(comment.id) ?? []).map((r) => {
323
516
  const reporter = authors[r.orgUserId];
324
- if (!reporter) throw new NotFoundError("OrgUser", r.orgUserId);
517
+ if (!reporter) throw new NotFoundError("Member", r.orgUserId);
325
518
  return {
326
- reporter: this.toAuthor(reporter),
519
+ reporter: authorView(orgId, reporter, urls),
327
520
  reason: r.reason,
328
521
  createdAt: r.createdAt
329
522
  };
@@ -337,6 +530,28 @@ var DiscussionServiceImpl = class {
337
530
  getComment(orgId, commentId) {
338
531
  return this.repo.findComment(orgId, commentId);
339
532
  }
533
+ async sendDigest(orgId, now) {
534
+ const communityIds = await this.communities.listIds(orgId);
535
+ if (communityIds.length === 0) {
536
+ this.logger.debug("no communities for digest", { orgId });
537
+ return;
538
+ }
539
+ const period = now.toISOString().slice(0, 10);
540
+ await this.uow.run(async ({ outbox }) => {
541
+ await outbox.append(communityIds.map((communityId) => discussionEvents.digestDue.make({
542
+ orgId,
543
+ data: {
544
+ communityId,
545
+ period
546
+ }
547
+ })));
548
+ });
549
+ this.logger.info("digest due", {
550
+ orgId,
551
+ communities: communityIds.length,
552
+ period
553
+ });
554
+ }
340
555
  };
341
556
  //#endregion
342
557
  export { DEFAULT_SETTINGS, DiscussionServiceImpl };