@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,10 @@
1
1
  import { genId } from "../shared/id.js";
2
+ import { ConflictError, ForbiddenError, NotFoundError } from "../shared/errors.js";
2
3
  import { noopLogger } from "../shared/logger.js";
4
+ import { canModerate, isStaffRole } from "../organizations/roles.js";
3
5
  import "../shared/settings.js";
4
- import { ForbiddenError, NotFoundError } from "../shared/errors.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,30 +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
- return this.isStaff(actor) || await (async () => {
95
- const activity = await this.content.getActivity(orgId, activityId);
96
- return activity ? this.access.hasCourseAccess(orgId, actor.id, activity.courseId) : false;
97
- })();
97
+ if (this.isStaff(actor)) return true;
98
+ const activity = await this.content.getActivity(orgId, activityId);
99
+ return activity ? this.access.hasAccess(orgId, actor.id, activity.courseId) : false;
100
+ }
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);
98
111
  }
99
- async postComment(orgId, { activityId, actor, parentId, body }) {
100
- await this.hasAccess(orgId, activityId, actor);
112
+ async postOnActivity(orgId, activityId, { actor, parentId, body }) {
113
+ await this.requireActivityOpen(orgId, activityId, actor);
101
114
  const config = await this.resolveConfig(orgId, activityId);
102
- 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");
103
116
  if (parentId !== null) {
104
- 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");
105
118
  const parent = await this.repo.findComment(orgId, parentId);
106
119
  if (!parent || parent.activityId !== activityId) throw new NotFoundError("Comment", parentId);
107
- if (parent.parentId !== null) throw new ForbiddenError("replies nest one level");
108
- 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");
109
122
  }
110
123
  const status = config.requireReview && !this.isStaff(actor) ? "pending" : "published";
111
124
  const comment = {
112
125
  id: genId("comment"),
113
126
  orgId,
114
127
  activityId,
128
+ postId: null,
129
+ communityId: null,
115
130
  parentId,
116
131
  orgUserId: actor.id,
117
132
  body,
@@ -129,47 +144,151 @@ var DiscussionServiceImpl = class {
129
144
  this.logger.info("comment created", {
130
145
  orgId,
131
146
  commentId: row.id,
132
- 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
133
192
  });
134
193
  return row;
135
194
  });
136
195
  return this.renderOne(orgId, saved, actor);
137
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
+ }
138
250
  async activityComments(orgId, activityId, actor) {
251
+ await this.requireActivityOpen(orgId, activityId, actor);
139
252
  const config = await this.resolveConfig(orgId, activityId);
140
253
  if (!config.enabled) return {
141
254
  config,
142
255
  comments: []
143
256
  };
144
- const readable = (await this.repo.listByActivity(orgId, activityId)).filter((c) => {
145
- if (c.status === "published" || c.status === "removed") return true;
146
- return this.isStaff(actor) || c.orgUserId === actor.id;
147
- });
148
- const replies = readable.filter((c) => c.parentId !== null && c.status !== "removed");
149
- const heldOpen = new Set(replies.map((r) => r.parentId));
150
- const roots = readable.filter((c) => c.parentId === null && (c.status !== "removed" || heldOpen.has(c.id)));
151
- const rootIds = new Set(roots.map((r) => r.id));
152
- const served = [...roots, ...replies.filter((r) => rootIds.has(r.parentId))].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
153
- 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);
154
258
  return {
155
259
  config,
156
- 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))
157
261
  };
158
262
  }
159
263
  async edit(orgId, commentId, actor, body) {
160
264
  const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
161
- if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
162
- if (comment.orgUserId !== actor.id) throw new ForbiddenError("only the author may edit a comment");
163
- 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");
164
268
  const updated = await this.repo.updateComment(orgId, commentId, { body });
165
269
  if (!updated) throw new NotFoundError("Comment", commentId);
166
270
  return this.renderOne(orgId, updated, actor);
167
271
  }
168
- 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) {
169
276
  const comment = await this.getComment(orgId, commentId);
170
- if (!comment) throw new NotFoundError("Comment", commentId);
171
- await this.hasAccess(orgId, comment.activityId, actor);
172
- 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
+ }
173
292
  if (comment.status === "removed") return comment;
174
293
  return this.uow.run(async (scope) => {
175
294
  const updated = await scope.discussion.updateComment(orgId, commentId, {
@@ -177,28 +296,49 @@ var DiscussionServiceImpl = class {
177
296
  removedBy: actor.id
178
297
  });
179
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
+ });
180
304
  await scope.outbox.append([discussionEvents.commentRemoved.make({
181
305
  orgId,
182
- data: updated
306
+ data: updated,
307
+ metadata: updated.postId ? { actorOrgUserId: actor.id } : void 0
183
308
  })]);
184
309
  this.logger.info("comment removed", {
185
310
  orgId,
186
311
  commentId,
187
- by: actor.id
312
+ actorId: actor.id
188
313
  });
189
314
  return updated;
190
315
  });
191
316
  }
192
- async restore(orgId, commentId, actor) {
193
- 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");
194
322
  const comment = await this.getComment(orgId, commentId);
195
- 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
+ }
196
331
  if (comment.status !== "removed") return comment;
197
332
  const updated = await this.repo.updateComment(orgId, commentId, {
198
333
  status: "published",
199
334
  removedBy: null
200
335
  });
201
336
  if (!updated) throw new NotFoundError("Comment", commentId);
337
+ this.logger.info("comment restored", {
338
+ orgId,
339
+ commentId,
340
+ actorId: actor.id
341
+ });
202
342
  return updated;
203
343
  }
204
344
  /** Load a comment together with its activity's resolved comments config,
@@ -207,25 +347,45 @@ var DiscussionServiceImpl = class {
207
347
  async loadWithConfig(orgId, commentId, actor) {
208
348
  const comment = await this.getComment(orgId, commentId);
209
349
  if (!comment) throw new NotFoundError("Comment", commentId);
210
- await this.hasAccess(orgId, comment.activityId, actor);
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");
211
364
  return {
212
365
  comment,
213
- config: await this.resolveConfig(orgId, comment.activityId)
366
+ config: await this.resolveConfig(orgId, activityId)
214
367
  };
215
368
  }
216
369
  async setReaction(orgId, commentId, actor, emoji) {
217
370
  const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
218
- if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
219
- if (!config.reactions && emoji !== null) throw new ForbiddenError("reactions are disabled on this course");
220
- 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");
221
374
  await this.repo.setReaction(orgId, commentId, actor.id, emoji);
222
375
  return (await this.repo.reactionsOf(orgId, [commentId], actor.id))[commentId] ?? EMPTY_REACTIONS;
223
376
  }
224
377
  async approve(orgId, commentId, actor) {
225
- 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");
226
379
  const comment = await this.getComment(orgId, commentId);
227
- if (!comment) throw new NotFoundError("Comment", commentId);
228
- 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");
229
389
  return this.uow.run(async (scope) => {
230
390
  const updated = await scope.discussion.updateComment(orgId, commentId, { status: "published" });
231
391
  if (!updated) throw new NotFoundError("Comment", commentId);
@@ -235,7 +395,8 @@ var DiscussionServiceImpl = class {
235
395
  })]);
236
396
  this.logger.info("comment published", {
237
397
  orgId,
238
- commentId
398
+ commentId,
399
+ actorId: actor.id
239
400
  });
240
401
  return updated;
241
402
  });
@@ -244,26 +405,50 @@ var DiscussionServiceImpl = class {
244
405
  * caller must not decide that itself. `approve` and `restore` still enforce
245
406
  * their own staff check; the check here covers the third path, where a
246
407
  * comment is already published and neither of them would run. */
247
- async publish(orgId, commentId, actor) {
248
- 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");
249
410
  const comment = await this.getComment(orgId, commentId);
250
- 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
+ }
251
419
  if (comment.status === "pending") return this.approve(orgId, commentId, actor);
252
- if (comment.status === "removed") return this.restore(orgId, commentId, actor);
420
+ if (comment.status === "removed") return this.restoreComment(orgId, commentId, actor);
253
421
  return comment;
254
422
  }
255
- async resolveReports(orgId, commentId, actor) {
256
- if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may resolve a report");
257
- if (!await this.getComment(orgId, commentId)) throw new NotFoundError("Comment", commentId);
258
- 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
+ });
259
442
  this.logger.info("comment reports resolved", {
260
443
  orgId,
261
- commentId
444
+ commentId,
445
+ actorId: actor.id
262
446
  });
263
447
  }
264
448
  async reportComment(orgId, commentId, actor, reason) {
265
- const { config } = await this.loadWithConfig(orgId, commentId, actor);
266
- 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");
451
+ const now = /* @__PURE__ */ new Date();
267
452
  const report = {
268
453
  id: genId("commentReport"),
269
454
  orgId,
@@ -271,7 +456,10 @@ var DiscussionServiceImpl = class {
271
456
  orgUserId: actor.id,
272
457
  reason,
273
458
  resolvedAt: null,
274
- createdAt: /* @__PURE__ */ new Date()
459
+ resolution: null,
460
+ resolvedBy: null,
461
+ createdAt: now,
462
+ updatedAt: now
275
463
  };
276
464
  return this.uow.run(async (scope) => {
277
465
  const saved = await scope.discussion.insertReport(orgId, report);
@@ -282,11 +470,16 @@ var DiscussionServiceImpl = class {
282
470
  }
283
471
  await scope.outbox.append([discussionEvents.commentReported.make({
284
472
  orgId,
285
- data: saved
473
+ data: {
474
+ ...saved,
475
+ communityId: comment.communityId
476
+ },
477
+ metadata: comment.postId ? { actorOrgUserId: actor.id } : void 0
286
478
  })]);
287
479
  this.logger.info("comment reported", {
288
480
  orgId,
289
- commentId
481
+ commentId,
482
+ actorId: actor.id
290
483
  });
291
484
  return saved;
292
485
  });
@@ -300,29 +493,30 @@ var DiscussionServiceImpl = class {
300
493
  };
301
494
  const reports = await this.repo.listOpenReports(orgId, rows.map((c) => c.id));
302
495
  const authors = await this.repo.authorsOf(orgId, [.../* @__PURE__ */ new Set([...this.peopleIn(rows), ...reports.map((r) => r.orgUserId)])]);
496
+ const urls = this.fileUrls;
303
497
  const byComment = /* @__PURE__ */ new Map();
304
498
  for (const report of reports) byComment.set(report.commentId, [...byComment.get(report.commentId) ?? [], report]);
305
499
  return {
306
500
  ...page,
307
501
  rows: page.rows.map(({ comment, courseId, activityTitle }) => {
308
502
  const author = authors[comment.orgUserId];
309
- if (!author) throw new NotFoundError("OrgUser", comment.orgUserId);
503
+ if (!author) throw new NotFoundError("Member", comment.orgUserId);
310
504
  return {
311
505
  id: comment.id,
312
506
  parentId: comment.parentId,
313
- activityId: comment.activityId,
507
+ activityId: this.activityIdOf(comment),
314
508
  activityTitle,
315
509
  courseId,
316
510
  body: comment.body,
317
511
  status: comment.status,
318
- author: this.toAuthor(author),
512
+ author: authorView(orgId, author, urls),
319
513
  authorEmail: author.email,
320
514
  removedBy: comment.removedBy,
321
515
  reports: (byComment.get(comment.id) ?? []).map((r) => {
322
516
  const reporter = authors[r.orgUserId];
323
- if (!reporter) throw new NotFoundError("OrgUser", r.orgUserId);
517
+ if (!reporter) throw new NotFoundError("Member", r.orgUserId);
324
518
  return {
325
- reporter: this.toAuthor(reporter),
519
+ reporter: authorView(orgId, reporter, urls),
326
520
  reason: r.reason,
327
521
  createdAt: r.createdAt
328
522
  };
@@ -336,6 +530,28 @@ var DiscussionServiceImpl = class {
336
530
  getComment(orgId, commentId) {
337
531
  return this.repo.findComment(orgId, commentId);
338
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
+ }
339
555
  };
340
556
  //#endregion
341
557
  export { DEFAULT_SETTINGS, DiscussionServiceImpl };