@headless-lms/core 0.3.0 → 0.5.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 (307) hide show
  1. package/dist/assets/events.d.ts +24 -18
  2. package/dist/assets/file-urls.d.ts +11 -7
  3. package/dist/assets/file-urls.js +14 -4
  4. package/dist/assets/file-urls.js.map +1 -1
  5. package/dist/assets/index.d.ts +5 -4
  6. package/dist/assets/index.js +4 -3
  7. package/dist/assets/jobs.d.ts +15 -0
  8. package/dist/assets/jobs.js +30 -0
  9. package/dist/assets/jobs.js.map +1 -0
  10. package/dist/assets/ports.d.ts +37 -17
  11. package/dist/assets/service.d.ts +30 -15
  12. package/dist/assets/service.js +229 -59
  13. package/dist/assets/service.js.map +1 -1
  14. package/dist/automations/actions.js +2 -63
  15. package/dist/automations/actions.js.map +1 -1
  16. package/dist/automations/catalog.d.ts +1 -2
  17. package/dist/automations/catalog.js +137 -65
  18. package/dist/automations/catalog.js.map +1 -1
  19. package/dist/automations/community-derivations.js +20 -20
  20. package/dist/automations/community-derivations.js.map +1 -1
  21. package/dist/automations/community-mail.d.ts +36 -20
  22. package/dist/automations/community-mail.js +32 -24
  23. package/dist/automations/community-mail.js.map +1 -1
  24. package/dist/automations/event-permissions.d.ts +12 -0
  25. package/dist/automations/event-permissions.js +38 -0
  26. package/dist/automations/event-permissions.js.map +1 -0
  27. package/dist/automations/events.d.ts +0 -10
  28. package/dist/automations/index.d.ts +12 -4
  29. package/dist/automations/index.js +9 -1
  30. package/dist/automations/notifications/applicable.d.ts +8 -0
  31. package/dist/automations/notifications/applicable.js +12 -0
  32. package/dist/automations/notifications/applicable.js.map +1 -0
  33. package/dist/automations/notifications/debounce.d.ts +25 -0
  34. package/dist/automations/notifications/debounce.js +93 -0
  35. package/dist/automations/notifications/debounce.js.map +1 -0
  36. package/dist/automations/notifications/defaults.d.ts +8 -0
  37. package/dist/automations/notifications/defaults.js +346 -0
  38. package/dist/automations/notifications/defaults.js.map +1 -0
  39. package/dist/automations/notifications/delivery.d.ts +28 -0
  40. package/dist/automations/notifications/delivery.js +88 -0
  41. package/dist/automations/notifications/delivery.js.map +1 -0
  42. package/dist/automations/notifications/processor.d.ts +64 -0
  43. package/dist/automations/notifications/processor.js +270 -0
  44. package/dist/automations/notifications/processor.js.map +1 -0
  45. package/dist/automations/notifications/rules/content.d.ts +6 -0
  46. package/dist/automations/notifications/rules/content.js +96 -0
  47. package/dist/automations/notifications/rules/content.js.map +1 -0
  48. package/dist/automations/notifications/rules/discussion.d.ts +7 -0
  49. package/dist/automations/notifications/rules/discussion.js +678 -0
  50. package/dist/automations/notifications/rules/discussion.js.map +1 -0
  51. package/dist/automations/notifications/rules/entitlements.d.ts +6 -0
  52. package/dist/automations/notifications/rules/entitlements.js +276 -0
  53. package/dist/automations/notifications/rules/entitlements.js.map +1 -0
  54. package/dist/automations/notifications/rules/events.d.ts +9 -0
  55. package/dist/automations/notifications/rules/events.js +27 -0
  56. package/dist/automations/notifications/rules/events.js.map +1 -0
  57. package/dist/automations/notifications/rules/index.d.ts +6 -0
  58. package/dist/automations/notifications/rules/index.js +15 -0
  59. package/dist/automations/notifications/rules/index.js.map +1 -0
  60. package/dist/automations/notifications/rules/organizations.d.ts +6 -0
  61. package/dist/automations/notifications/rules/organizations.js +35 -0
  62. package/dist/automations/notifications/rules/organizations.js.map +1 -0
  63. package/dist/automations/notifications/rules/recipients.d.ts +32 -0
  64. package/dist/automations/notifications/rules/recipients.js +98 -0
  65. package/dist/automations/notifications/rules/recipients.js.map +1 -0
  66. package/dist/automations/notifications/service.d.ts +29 -0
  67. package/dist/automations/notifications/service.js +45 -0
  68. package/dist/automations/notifications/service.js.map +1 -0
  69. package/dist/automations/notifications/types.d.ts +7 -0
  70. package/dist/automations/notifications/types.js +560 -0
  71. package/dist/automations/notifications/types.js.map +1 -0
  72. package/dist/automations/ports.d.ts +134 -21
  73. package/dist/automations/service.d.ts +30 -30
  74. package/dist/automations/service.js +111 -144
  75. package/dist/automations/service.js.map +1 -1
  76. package/dist/content/event-sight.d.ts +11 -0
  77. package/dist/content/event-sight.js +11 -0
  78. package/dist/content/event-sight.js.map +1 -0
  79. package/dist/content/events.d.ts +437 -46
  80. package/dist/content/events.js +45 -1
  81. package/dist/content/events.js.map +1 -1
  82. package/dist/content/index.d.ts +8 -4
  83. package/dist/content/index.js +5 -1
  84. package/dist/content/jobs.d.ts +16 -0
  85. package/dist/content/jobs.js +37 -0
  86. package/dist/content/jobs.js.map +1 -0
  87. package/dist/content/model.d.ts +2 -2
  88. package/dist/content/notification-categories.d.ts +37 -0
  89. package/dist/content/notification-categories.js +73 -0
  90. package/dist/content/notification-categories.js.map +1 -0
  91. package/dist/content/ports.d.ts +132 -68
  92. package/dist/content/service.d.ts +100 -68
  93. package/dist/content/service.js +703 -146
  94. package/dist/content/service.js.map +1 -1
  95. package/dist/content/titles.d.ts +25 -0
  96. package/dist/content/titles.js +82 -0
  97. package/dist/content/titles.js.map +1 -0
  98. package/dist/content/types.d.ts +2 -2
  99. package/dist/discussion/authors.d.ts +4 -2
  100. package/dist/discussion/authors.js +9 -5
  101. package/dist/discussion/authors.js.map +1 -1
  102. package/dist/discussion/chats.d.ts +63 -39
  103. package/dist/discussion/chats.js +455 -190
  104. package/dist/discussion/chats.js.map +1 -1
  105. package/dist/discussion/digests.d.ts +24 -0
  106. package/dist/discussion/digests.js +104 -0
  107. package/dist/discussion/digests.js.map +1 -0
  108. package/dist/discussion/events.d.ts +227 -23
  109. package/dist/discussion/events.js +114 -19
  110. package/dist/discussion/events.js.map +1 -1
  111. package/dist/discussion/index.d.ts +12 -4
  112. package/dist/discussion/index.js +9 -2
  113. package/dist/discussion/jobs.d.ts +16 -0
  114. package/dist/discussion/jobs.js +40 -0
  115. package/dist/discussion/jobs.js.map +1 -0
  116. package/dist/discussion/live.d.ts +106 -0
  117. package/dist/discussion/live.js +642 -0
  118. package/dist/discussion/live.js.map +1 -0
  119. package/dist/discussion/mentions.d.ts +11 -0
  120. package/dist/discussion/mentions.js +35 -0
  121. package/dist/discussion/mentions.js.map +1 -0
  122. package/dist/discussion/ports.d.ts +279 -114
  123. package/dist/discussion/ports.js.map +1 -1
  124. package/dist/discussion/posts.d.ts +60 -44
  125. package/dist/discussion/posts.js +453 -287
  126. package/dist/discussion/posts.js.map +1 -1
  127. package/dist/discussion/rules.d.ts +18 -0
  128. package/dist/discussion/rules.js +22 -0
  129. package/dist/discussion/rules.js.map +1 -0
  130. package/dist/discussion/service.d.ts +50 -35
  131. package/dist/discussion/service.js +333 -205
  132. package/dist/discussion/service.js.map +1 -1
  133. package/dist/discussion/subscriptions.d.ts +37 -0
  134. package/dist/discussion/subscriptions.js +115 -0
  135. package/dist/discussion/subscriptions.js.map +1 -0
  136. package/dist/discussion/titles.d.ts +28 -0
  137. package/dist/discussion/titles.js +98 -0
  138. package/dist/discussion/titles.js.map +1 -0
  139. package/dist/entitlements/events.d.ts +20 -20
  140. package/dist/entitlements/index.d.ts +5 -4
  141. package/dist/entitlements/index.js +3 -2
  142. package/dist/entitlements/jobs.d.ts +12 -0
  143. package/dist/entitlements/jobs.js +18 -0
  144. package/dist/entitlements/jobs.js.map +1 -0
  145. package/dist/entitlements/model.d.ts +2 -2
  146. package/dist/entitlements/ports.d.ts +68 -38
  147. package/dist/entitlements/rules.d.ts +3 -6
  148. package/dist/entitlements/rules.js +5 -10
  149. package/dist/entitlements/rules.js.map +1 -1
  150. package/dist/entitlements/service.d.ts +43 -28
  151. package/dist/entitlements/service.js +387 -160
  152. package/dist/entitlements/service.js.map +1 -1
  153. package/dist/identity/devices.d.ts +30 -0
  154. package/dist/identity/devices.js +109 -0
  155. package/dist/identity/devices.js.map +1 -0
  156. package/dist/identity/index.d.ts +6 -5
  157. package/dist/identity/index.js +4 -3
  158. package/dist/identity/model.d.ts +2 -2
  159. package/dist/identity/notification-settings.d.ts +28 -0
  160. package/dist/identity/notification-settings.js +114 -0
  161. package/dist/identity/notification-settings.js.map +1 -0
  162. package/dist/identity/ports.d.ts +80 -56
  163. package/dist/identity/service.d.ts +8 -7
  164. package/dist/identity/service.js +17 -13
  165. package/dist/identity/service.js.map +1 -1
  166. package/dist/integrations/ports.d.ts +9 -8
  167. package/dist/integrations/service.d.ts +9 -8
  168. package/dist/integrations/service.js +33 -16
  169. package/dist/integrations/service.js.map +1 -1
  170. package/dist/organizations/default-roles.d.ts +22 -0
  171. package/dist/organizations/default-roles.js +89 -0
  172. package/dist/organizations/default-roles.js.map +1 -0
  173. package/dist/organizations/effective-permissions.d.ts +45 -0
  174. package/dist/organizations/effective-permissions.js +113 -0
  175. package/dist/organizations/effective-permissions.js.map +1 -0
  176. package/dist/organizations/events.d.ts +320 -181
  177. package/dist/organizations/events.js +90 -18
  178. package/dist/organizations/events.js.map +1 -1
  179. package/dist/organizations/index.d.ts +15 -10
  180. package/dist/organizations/index.js +8 -3
  181. package/dist/organizations/members.d.ts +66 -8
  182. package/dist/organizations/members.js.map +1 -1
  183. package/dist/organizations/notification-catalog.d.ts +31 -0
  184. package/dist/organizations/notification-catalog.js +32 -0
  185. package/dist/organizations/notification-catalog.js.map +1 -0
  186. package/dist/organizations/notification-categories.d.ts +34 -0
  187. package/dist/organizations/notification-categories.js +66 -0
  188. package/dist/organizations/notification-categories.js.map +1 -0
  189. package/dist/organizations/org-roles.d.ts +33 -0
  190. package/dist/organizations/org-roles.js +49 -0
  191. package/dist/organizations/org-roles.js.map +1 -0
  192. package/dist/organizations/permissions.d.ts +311 -0
  193. package/dist/organizations/permissions.js +327 -0
  194. package/dist/organizations/permissions.js.map +1 -0
  195. package/dist/organizations/ports.d.ts +187 -65
  196. package/dist/organizations/service.d.ts +104 -35
  197. package/dist/organizations/service.js +1247 -323
  198. package/dist/organizations/service.js.map +1 -1
  199. package/dist/organizations/testing.d.ts +18 -0
  200. package/dist/organizations/testing.js +37 -0
  201. package/dist/organizations/testing.js.map +1 -0
  202. package/dist/organizations/types.d.ts +5 -16
  203. package/dist/progress/index.d.ts +2 -2
  204. package/dist/progress/ports.d.ts +11 -3
  205. package/dist/progress/service.d.ts +6 -5
  206. package/dist/progress/service.js +23 -8
  207. package/dist/progress/service.js.map +1 -1
  208. package/dist/reporting/admin/index.d.ts +6 -4
  209. package/dist/reporting/admin/model.d.ts +36 -3
  210. package/dist/reporting/admin/ports.d.ts +39 -14
  211. package/dist/reporting/admin/service.d.ts +32 -11
  212. package/dist/reporting/admin/service.js +102 -18
  213. package/dist/reporting/admin/service.js.map +1 -1
  214. package/dist/reporting/events/index.d.ts +27 -0
  215. package/dist/reporting/events/index.js +20 -0
  216. package/dist/reporting/events/index.js.map +1 -0
  217. package/dist/reporting/member/calendar.d.ts +11 -0
  218. package/dist/reporting/member/calendar.js +75 -0
  219. package/dist/reporting/member/calendar.js.map +1 -0
  220. package/dist/reporting/member/index.d.ts +3 -2
  221. package/dist/reporting/member/model.d.ts +80 -26
  222. package/dist/reporting/member/ports.d.ts +50 -28
  223. package/dist/reporting/member/service.d.ts +81 -35
  224. package/dist/reporting/member/service.js +438 -145
  225. package/dist/reporting/member/service.js.map +1 -1
  226. package/dist/shared/actor.d.ts +22 -3
  227. package/dist/shared/errors.d.ts +15 -2
  228. package/dist/shared/errors.js +21 -2
  229. package/dist/shared/errors.js.map +1 -1
  230. package/dist/shared/id.d.ts +7 -0
  231. package/dist/shared/id.js +8 -1
  232. package/dist/shared/id.js.map +1 -1
  233. package/dist/shared/logger.d.ts +2 -1
  234. package/dist/shared/logger.js +10 -1
  235. package/dist/shared/logger.js.map +1 -1
  236. package/dist/shared/mailer.d.ts +11 -1
  237. package/dist/shared/mailer.js +32 -5
  238. package/dist/shared/mailer.js.map +1 -1
  239. package/dist/shared/notification-categories.d.ts +16 -0
  240. package/dist/shared/notification-categories.js +94 -0
  241. package/dist/shared/notification-categories.js.map +1 -0
  242. package/dist/shared/notification-settings.d.ts +14 -0
  243. package/dist/shared/notification-settings.js +61 -0
  244. package/dist/shared/notification-settings.js.map +1 -0
  245. package/dist/shared/ports.d.ts +68 -2
  246. package/dist/shared/settings.d.ts +2 -1
  247. package/dist/shared/settings.js +1 -0
  248. package/dist/shared/settings.js.map +1 -1
  249. package/dist/types/automations.d.ts +8 -2
  250. package/dist/types/content.d.ts +2 -2
  251. package/dist/types/discussion.d.ts +4 -5
  252. package/dist/types/email-templates.d.ts +119 -6
  253. package/dist/types/entitlements.d.ts +2 -2
  254. package/dist/types/identity.d.ts +14 -17
  255. package/dist/types/index.d.ts +13 -12
  256. package/dist/types/index.js +2 -1
  257. package/dist/types/notifications.d.ts +157 -0
  258. package/dist/types/notifications.js +1 -0
  259. package/dist/types/organizations.d.ts +3 -2
  260. package/dist/types/ports.d.ts +8 -1
  261. package/dist/types/ports.js +7 -1
  262. package/dist/types/ports.js.map +1 -0
  263. package/dist/types/schemas/assets.d.ts +5 -5
  264. package/dist/types/schemas/assets.js +4 -3
  265. package/dist/types/schemas/assets.js.map +1 -1
  266. package/dist/types/schemas/automations.d.ts +1 -10
  267. package/dist/types/schemas/automations.js +1 -3
  268. package/dist/types/schemas/automations.js.map +1 -1
  269. package/dist/types/schemas/content.d.ts +596 -11
  270. package/dist/types/schemas/content.js +225 -9
  271. package/dist/types/schemas/content.js.map +1 -1
  272. package/dist/types/schemas/discussion.d.ts +362 -66
  273. package/dist/types/schemas/discussion.js +123 -15
  274. package/dist/types/schemas/discussion.js.map +1 -1
  275. package/dist/types/schemas/entitlements.d.ts +14 -1
  276. package/dist/types/schemas/entitlements.js +11 -4
  277. package/dist/types/schemas/entitlements.js.map +1 -1
  278. package/dist/types/schemas/identity.d.ts +1 -85
  279. package/dist/types/schemas/identity.js +1 -42
  280. package/dist/types/schemas/identity.js.map +1 -1
  281. package/dist/types/schemas/index.d.ts +8 -7
  282. package/dist/types/schemas/index.js +8 -7
  283. package/dist/types/schemas/notifications.d.ts +96 -0
  284. package/dist/types/schemas/notifications.js +57 -0
  285. package/dist/types/schemas/notifications.js.map +1 -0
  286. package/dist/types/schemas/organizations.d.ts +96 -60
  287. package/dist/types/schemas/organizations.js +78 -27
  288. package/dist/types/schemas/organizations.js.map +1 -1
  289. package/dist/types/schemas/progress.d.ts +0 -1
  290. package/dist/types/schemas/progress.js +0 -1
  291. package/dist/types/schemas/progress.js.map +1 -1
  292. package/package.json +1 -1
  293. package/dist/automations/community-notifications.d.ts +0 -16
  294. package/dist/automations/community-notifications.js +0 -84
  295. package/dist/automations/community-notifications.js.map +0 -1
  296. package/dist/automations/seeds.d.ts +0 -17
  297. package/dist/automations/seeds.js +0 -235
  298. package/dist/automations/seeds.js.map +0 -1
  299. package/dist/identity/notifications.d.ts +0 -27
  300. package/dist/identity/notifications.js +0 -101
  301. package/dist/identity/notifications.js.map +0 -1
  302. package/dist/organizations/access-scopes.d.ts +0 -19
  303. package/dist/organizations/access-scopes.js +0 -89
  304. package/dist/organizations/access-scopes.js.map +0 -1
  305. package/dist/organizations/roles.d.ts +0 -19
  306. package/dist/organizations/roles.js +0 -82
  307. package/dist/organizations/roles.js.map +0 -1
@@ -1,10 +1,11 @@
1
- import { genId } from "../shared/id.js";
2
- import { ConflictError, ForbiddenError, NotFoundError } from "../shared/errors.js";
3
1
  import { noopLogger } from "../shared/logger.js";
4
- import { canModerate, isStaffRole } from "../organizations/roles.js";
2
+ import { ConflictError, ForbiddenError, NotFoundError } from "../shared/errors.js";
3
+ import { genId } from "../shared/id.js";
5
4
  import "../shared/settings.js";
6
5
  import { discussionEvents } from "./events.js";
7
6
  import { authorView } from "./authors.js";
7
+ import { keepAllowed, mentionTokens, mentionViews } from "./mentions.js";
8
+ import { skipsReview, teamBadge } from "./rules.js";
8
9
  //#region src/discussion/service.ts
9
10
  /** A course with no stored settings. Discussion is opt-in, so the common case
10
11
  * persists no row at all and every existing course stays silent. */
@@ -20,9 +21,11 @@ var DiscussionServiceImpl = class {
20
21
  access;
21
22
  content;
22
23
  posts;
24
+ spaces;
25
+ authors;
26
+ mentions;
23
27
  uow;
24
28
  settings;
25
- communities;
26
29
  fileUrls;
27
30
  logger;
28
31
  constructor(params) {
@@ -30,21 +33,21 @@ var DiscussionServiceImpl = class {
30
33
  this.access = params.access;
31
34
  this.content = params.content;
32
35
  this.posts = params.posts;
36
+ this.spaces = params.spaces;
37
+ this.authors = params.authors;
38
+ this.mentions = params.mentions;
33
39
  this.uow = params.uow;
34
40
  this.settings = params.settings;
35
- this.communities = params.communities;
36
41
  this.fileUrls = params.fileUrls;
37
42
  this.logger = params.logger ?? noopLogger;
38
43
  }
39
- async resolveConfig(orgId, activityId) {
40
- const activity = await this.content.getActivity(orgId, activityId);
41
- if (!activity) throw new NotFoundError("Activity", activityId);
44
+ async configOf(orgId, activity) {
42
45
  const stored = await this.settings.get(orgId, "content", activity.courseId);
43
46
  const settings = {
44
47
  ...DEFAULT_SETTINGS,
45
48
  ...stored?.comments
46
49
  };
47
- const rule = (activity?.settings ?? {}).comments ?? "inherit";
50
+ const rule = (activity.settings ?? {}).comments ?? "inherit";
48
51
  return {
49
52
  enabled: settings.enabled && rule !== "never",
50
53
  threaded: settings.threaded,
@@ -52,10 +55,10 @@ var DiscussionServiceImpl = class {
52
55
  reactions: settings.reactions
53
56
  };
54
57
  }
55
- isStaff(actor) {
56
- return isStaffRole(actor.role);
58
+ async badgesOf(orgId, orgUserIds) {
59
+ return this.authors.permissionsOfMany(orgId, [...new Set(orgUserIds)]);
57
60
  }
58
- toView(orgId, comment, authors, urls, reactions) {
61
+ toView(orgId, comment, itemId, authors, permissions, reactions, mentions) {
59
62
  const author = authors[comment.orgUserId];
60
63
  if (!author) throw new NotFoundError("Member", comment.orgUserId);
61
64
  if (comment.removedBy && !authors[comment.removedBy]) throw new NotFoundError("Member", comment.removedBy);
@@ -66,12 +69,13 @@ var DiscussionServiceImpl = class {
66
69
  postId: comment.postId,
67
70
  communityId: comment.communityId,
68
71
  parentId: comment.parentId,
69
- author: authorView(orgId, author, urls),
72
+ author: authorView(orgId, author, this.fileUrls, teamBadge(permissions.get(author.id), itemId)),
70
73
  body: comment.status === "removed" ? null : comment.body,
71
74
  status: comment.status,
72
- removedBy: remover ? authorView(orgId, remover, urls) : null,
75
+ removedBy: remover ? authorView(orgId, remover, this.fileUrls, teamBadge(permissions.get(remover.id), itemId)) : null,
73
76
  reactions: reactions.reactions,
74
77
  viewerReaction: reactions.viewerReaction,
78
+ mentions: comment.status === "removed" ? [] : mentions,
75
79
  createdAt: comment.createdAt,
76
80
  updatedAt: comment.updatedAt
77
81
  };
@@ -84,34 +88,97 @@ var DiscussionServiceImpl = class {
84
88
  }
85
89
  return [...ids];
86
90
  }
87
- async renderOne(orgId, comment, actor) {
88
- const authors = await this.repo.authorsOf(orgId, this.peopleIn([comment]));
91
+ itemOf(where) {
92
+ return where.kind === "course" ? where.courseId : where.communityId;
93
+ }
94
+ async renderOne(comment, where, actor) {
95
+ const orgId = actor.orgId;
96
+ const people = this.peopleIn([comment]);
97
+ const [authors, permissions, mentioned] = await Promise.all([
98
+ this.repo.authorsOf(orgId, people),
99
+ this.badgesOf(orgId, people),
100
+ mentionViews(this.mentions, this.repo, orgId, "comment", [comment.id])
101
+ ]);
89
102
  const stored = comment.status === "removed" ? EMPTY_REACTIONS : (await this.repo.reactionsOf(orgId, [comment.id], actor.id))[comment.id] ?? EMPTY_REACTIONS;
90
- return this.toView(orgId, comment, authors, this.fileUrls, stored);
103
+ return this.toView(orgId, comment, this.itemOf(where), authors, permissions, stored, mentioned[comment.id] ?? []);
91
104
  }
92
105
  activityIdOf(comment) {
93
106
  if (comment.activityId === null) throw new NotFoundError("Activity", comment.id);
94
107
  return comment.activityId;
95
108
  }
96
- async hasAccess(orgId, activityId, actor) {
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;
109
+ system(orgId) {
110
+ return this.authors.systemActor(orgId);
111
+ }
112
+ async activityOf(orgId, activityId) {
113
+ return this.content.getActivity(activityId, this.system(orgId));
100
114
  }
101
- async requireActivityOpen(orgId, activityId, actor) {
102
- const activity = await this.content.getActivity(orgId, activityId);
115
+ async opensCourse(actor, courseId) {
116
+ return actor.permissions.has("courses:read", courseId) || this.access.hasAccess(actor.orgId, actor.id, courseId);
117
+ }
118
+ async requireActivityOpen(activityId, actor) {
119
+ const activity = await this.activityOf(actor.orgId, activityId);
103
120
  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");
121
+ if (!await this.opensCourse(actor, activity.courseId)) {
122
+ this.logger.warn("activity refused — no access", {
123
+ orgId: actor.orgId,
124
+ activityId,
125
+ actorId: actor.id
126
+ });
127
+ throw new ForbiddenError("You cannot open this activity");
128
+ }
129
+ return activity;
130
+ }
131
+ async courseOpeners(orgId, courseId, orgUserIds) {
132
+ if (orgUserIds.length === 0) return [];
133
+ const [people, permissions] = await Promise.all([this.repo.authorsOf(orgId, orgUserIds), this.badgesOf(orgId, orgUserIds)]);
134
+ const opens = await Promise.all(orgUserIds.map(async (orgUserId) => {
135
+ if (!people[orgUserId]) return false;
136
+ return (permissions.get(orgUserId)?.has("courses:read", courseId) ?? false) || this.access.hasAccess(orgId, orgUserId, courseId);
137
+ }));
138
+ return orgUserIds.filter((_, index) => opens[index]);
139
+ }
140
+ async spaceOpeners(orgId, spaceId, orgUserIds) {
141
+ if (orgUserIds.length === 0) return [];
142
+ return keepAllowed(orgUserIds, await this.spaces.spaceOpenersAmong(orgId, spaceId, orgUserIds));
143
+ }
144
+ async openersOf(orgId, comment, orgUserIds) {
145
+ if (orgUserIds.length === 0) return [];
146
+ if (comment.postId) {
147
+ const post = await this.posts.find(orgId, comment.postId);
148
+ return post ? this.spaceOpeners(orgId, post.spaceId, orgUserIds) : [];
149
+ }
150
+ const activity = await this.activityOf(orgId, this.activityIdOf(comment));
151
+ return activity ? this.courseOpeners(orgId, activity.courseId, orgUserIds) : [];
105
152
  }
106
- async postComment(orgId, input) {
107
- if (input.postId) return this.postOnPost(orgId, input.postId, input);
153
+ commenterIds(orgId, target) {
154
+ return this.repo.commenterIds(orgId, target);
155
+ }
156
+ async postComment(input, actor) {
157
+ if (input.postId) return this.postOnPost(input.postId, input, actor);
108
158
  const activityId = input.activityId;
109
159
  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);
114
- const config = await this.resolveConfig(orgId, activityId);
160
+ return this.postOnActivity(activityId, input, actor);
161
+ }
162
+ async replyToComment(commentId, body, actor) {
163
+ const parent = await this.getComment(actor.orgId, commentId);
164
+ if (!parent || parent.activityId === null) throw new NotFoundError("Comment", commentId);
165
+ const activity = await this.activityOf(actor.orgId, parent.activityId);
166
+ if (!activity) throw new NotFoundError("Comment", commentId);
167
+ this.requireModerates(actor, {
168
+ kind: "course",
169
+ courseId: activity.courseId,
170
+ activity
171
+ });
172
+ return this.postOnActivity(parent.activityId, {
173
+ activityId: parent.activityId,
174
+ parentId: parent.parentId ?? parent.id,
175
+ body
176
+ }, actor);
177
+ }
178
+ async postOnActivity(activityId, { parentId, body }, actor) {
179
+ const orgId = actor.orgId;
180
+ const activity = await this.requireActivityOpen(activityId, actor);
181
+ const config = await this.configOf(orgId, activity);
115
182
  if (!config.enabled) throw new ForbiddenError("Discussion is not open on this activity");
116
183
  if (parentId !== null) {
117
184
  if (!config.threaded) throw new ForbiddenError("Replies are off on this course");
@@ -120,7 +187,7 @@ var DiscussionServiceImpl = class {
120
187
  if (parent.parentId !== null) throw new ForbiddenError("A reply cannot have replies of its own");
121
188
  if (parent.status !== "published") throw new ForbiddenError("You can reply only to a published comment");
122
189
  }
123
- const status = config.requireReview && !this.isStaff(actor) ? "pending" : "published";
190
+ const status = config.requireReview && !skipsReview(actor.permissions, { courseId: activity.courseId }) ? "pending" : "published";
124
191
  const comment = {
125
192
  id: genId("comment"),
126
193
  orgId,
@@ -135,11 +202,17 @@ var DiscussionServiceImpl = class {
135
202
  createdAt: /* @__PURE__ */ new Date(),
136
203
  updatedAt: /* @__PURE__ */ new Date()
137
204
  };
205
+ const mentioned = await this.courseOpeners(orgId, activity.courseId, mentionTokens(body));
138
206
  const saved = await this.uow.run(async (scope) => {
139
207
  const row = await scope.discussion.insertComment(orgId, comment);
208
+ await scope.mentions.sync(orgId, "comment", row.id, mentioned, comment.createdAt);
140
209
  await scope.outbox.append([discussionEvents.commentCreated.make({
141
210
  orgId,
142
- data: row
211
+ data: {
212
+ ...row,
213
+ mentionedOrgUserIds: status === "published" ? mentioned : []
214
+ },
215
+ metadata: { actorId: actor.id }
143
216
  })]);
144
217
  this.logger.info("comment created", {
145
218
  orgId,
@@ -149,10 +222,15 @@ var DiscussionServiceImpl = class {
149
222
  });
150
223
  return row;
151
224
  });
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);
225
+ return this.renderOne(saved, {
226
+ kind: "course",
227
+ courseId: activity.courseId,
228
+ activity
229
+ }, actor);
230
+ }
231
+ async postOnPost(postId, { parentId, body }, actor) {
232
+ const orgId = actor.orgId;
233
+ const { post, space } = await this.posts.visible(postId, actor);
156
234
  if (post.status !== "published") throw new ConflictError("This post is not published");
157
235
  if (!this.commentsOpen(post, space)) throw new ForbiddenError("Comments are closed on this post");
158
236
  if (parentId !== null) {
@@ -176,12 +254,17 @@ var DiscussionServiceImpl = class {
176
254
  createdAt: now,
177
255
  updatedAt: now
178
256
  };
257
+ const mentioned = await this.spaceOpeners(orgId, post.spaceId, mentionTokens(body));
179
258
  const saved = await this.uow.run(async (scope) => {
180
259
  const row = await scope.discussion.insertComment(orgId, comment);
260
+ await scope.mentions.sync(orgId, "comment", row.id, mentioned, now);
181
261
  await scope.outbox.append([discussionEvents.commentCreated.make({
182
262
  orgId,
183
- data: row,
184
- metadata: { actorOrgUserId: actor.id }
263
+ data: {
264
+ ...row,
265
+ mentionedOrgUserIds: mentioned
266
+ },
267
+ metadata: { actorId: actor.id }
185
268
  })]);
186
269
  this.logger.info("comment created", {
187
270
  orgId,
@@ -192,17 +275,23 @@ var DiscussionServiceImpl = class {
192
275
  });
193
276
  return row;
194
277
  });
195
- return this.renderOne(orgId, saved, actor);
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);
278
+ return this.renderOne(saved, {
279
+ kind: "post",
280
+ communityId: post.communityId,
281
+ post,
282
+ space
283
+ }, actor);
284
+ }
285
+ async postComments(postId, query, actor) {
286
+ const orgId = actor.orgId;
287
+ const { post, space } = await this.posts.visible(postId, actor);
288
+ const seesHeld = actor.permissions.has("communities:moderate", post.communityId);
200
289
  const page = await this.repo.listThreadsByPost(orgId, postId, {
201
290
  ...query,
202
291
  viewerId: actor.id,
203
- seesHeld: seesHeld(actor)
292
+ seesHeld
204
293
  });
205
- const comments = await this.thread(orgId, page.rows, actor, post.reactionsEnabled ?? space.settings.reactions, seesHeld);
294
+ const comments = await this.thread(orgId, page.rows, actor, post.communityId, post.reactionsEnabled ?? space.settings.reactions, seesHeld);
206
295
  this.logger.debug("post comments read", {
207
296
  orgId,
208
297
  postId,
@@ -213,84 +302,142 @@ var DiscussionServiceImpl = class {
213
302
  rows: comments
214
303
  };
215
304
  }
216
- async thread(orgId, rows, actor, reactionsOn, seesHeld) {
305
+ async thread(orgId, rows, actor, itemId, reactionsOn, seesHeld) {
217
306
  const readable = rows.filter((comment) => {
218
307
  if (comment.status === "published" || comment.status === "removed") return true;
219
- return seesHeld(actor) || comment.orgUserId === actor.id;
308
+ return seesHeld || comment.orgUserId === actor.id;
220
309
  });
221
310
  const replies = readable.filter((comment) => comment.parentId !== null && comment.status !== "removed");
222
311
  const heldOpen = new Set(replies.map((reply) => reply.parentId));
223
312
  const roots = readable.filter((comment) => comment.parentId === null && (comment.status !== "removed" || heldOpen.has(comment.id)));
224
313
  const rootIds = new Set(roots.map((root) => root.id));
225
314
  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) {
315
+ const people = this.peopleIn(served);
316
+ const [authors, permissions, reactions, mentioned] = await Promise.all([
317
+ this.repo.authorsOf(orgId, people),
318
+ this.badgesOf(orgId, people),
319
+ reactionsOn ? this.repo.reactionsOf(orgId, served.map((comment) => comment.id), actor.id) : Promise.resolve({}),
320
+ mentionViews(this.mentions, this.repo, orgId, "comment", served.map((comment) => comment.id))
321
+ ]);
322
+ return served.map((comment) => this.toView(orgId, comment, itemId, authors, permissions, reactions[comment.id] ?? EMPTY_REACTIONS, mentioned[comment.id] ?? []));
323
+ }
324
+ async postOfComment(actor, commentId, postId) {
234
325
  try {
235
- return await this.visiblePost(orgId, actor, postId);
326
+ return await this.posts.visible(postId, actor);
236
327
  } catch (error) {
237
328
  if (error instanceof NotFoundError) throw new NotFoundError("Comment", commentId);
238
329
  throw error;
239
330
  }
240
331
  }
241
- canModerate(actor) {
242
- return canModerate(actor.role);
243
- }
244
- inCommunity(comment, communityId) {
245
- return communityId === void 0 || comment.communityId === communityId;
332
+ isWithin(comment, within) {
333
+ if (within === "any") return true;
334
+ if (within === "lesson") return comment.activityId !== null;
335
+ return comment.communityId === within.communityId;
246
336
  }
247
337
  commentsOpen(post, space) {
248
338
  return post.commentsClosed === null ? space.settings.comments : !post.commentsClosed;
249
339
  }
250
- async activityComments(orgId, activityId, actor) {
251
- await this.requireActivityOpen(orgId, activityId, actor);
252
- const config = await this.resolveConfig(orgId, activityId);
340
+ /** Where the comment sits, having proved the actor may reach it: the post
341
+ * is visible to them, or they open the course. */
342
+ async whereOf(comment, actor) {
343
+ if (comment.postId) {
344
+ const { post, space } = await this.postOfComment(actor, comment.id, comment.postId);
345
+ return {
346
+ kind: "post",
347
+ communityId: post.communityId,
348
+ post,
349
+ space
350
+ };
351
+ }
352
+ const activity = await this.activityOf(actor.orgId, this.activityIdOf(comment));
353
+ if (!activity) throw new NotFoundError("Comment", comment.id);
354
+ if (!await this.opensCourse(actor, activity.courseId)) {
355
+ this.logger.warn("comment refused — no access", {
356
+ orgId: actor.orgId,
357
+ commentId: comment.id,
358
+ actorId: actor.id
359
+ });
360
+ throw new ForbiddenError("You cannot open this activity");
361
+ }
362
+ return {
363
+ kind: "course",
364
+ courseId: activity.courseId,
365
+ activity
366
+ };
367
+ }
368
+ moderates(actor, where) {
369
+ return where.kind === "course" ? actor.permissions.has("courses:moderate", where.courseId) : actor.permissions.has("communities:moderate", where.communityId);
370
+ }
371
+ requireModerates(actor, where) {
372
+ actor.permissions.require(where.kind === "course" ? "courses:moderate" : "communities:moderate", this.itemOf(where));
373
+ }
374
+ async findIn(commentId, within, actor, action) {
375
+ const comment = await this.getComment(actor.orgId, commentId);
376
+ if (!comment || !this.isWithin(comment, within)) {
377
+ this.logger.warn(`comment ${action} rejected — not found`, {
378
+ orgId: actor.orgId,
379
+ commentId,
380
+ actorId: actor.id
381
+ });
382
+ throw new NotFoundError("Comment", commentId);
383
+ }
384
+ return comment;
385
+ }
386
+ async activityComments(activityId, actor) {
387
+ const activity = await this.requireActivityOpen(activityId, actor);
388
+ const config = await this.configOf(actor.orgId, activity);
253
389
  if (!config.enabled) return {
254
390
  config,
255
391
  comments: []
256
392
  };
257
- const rows = await this.repo.listByActivity(orgId, activityId);
393
+ const rows = await this.repo.listByActivity(actor.orgId, activityId);
258
394
  return {
259
395
  config,
260
- comments: await this.thread(orgId, rows, actor, config.reactions, (person) => this.isStaff(person))
396
+ comments: await this.thread(actor.orgId, rows, actor, activity.courseId, config.reactions, actor.permissions.has("courses:moderate", activity.courseId))
261
397
  };
262
398
  }
263
- async edit(orgId, commentId, actor, body) {
264
- const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
399
+ async edit(commentId, body, actor) {
400
+ const orgId = actor.orgId;
401
+ const { comment, config, where } = await this.loadWithConfig(commentId, actor);
265
402
  if (!config.enabled) throw new ForbiddenError(comment.postId ? "Comments are closed on this post" : "Discussion is not open on this activity");
266
403
  if (comment.orgUserId !== actor.id) throw new ForbiddenError("You can edit only your own comment");
267
404
  if (comment.status === "removed") throw new ForbiddenError("A removed comment cannot be edited");
268
- const updated = await this.repo.updateComment(orgId, commentId, { body });
269
- if (!updated) throw new NotFoundError("Comment", commentId);
270
- return this.renderOne(orgId, updated, actor);
271
- }
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) {
276
- const comment = await this.getComment(orgId, commentId);
277
- if (!comment || !this.inCommunity(comment, communityId)) {
278
- this.logger.warn("comment remove rejected — not found", {
405
+ const candidates = await this.openersOf(orgId, comment, mentionTokens(body));
406
+ const updated = await this.uow.run(async (scope) => {
407
+ const next = await scope.discussion.updateComment(orgId, commentId, { body });
408
+ if (!next) throw new NotFoundError("Comment", commentId);
409
+ const added = await scope.mentions.sync(orgId, "comment", commentId, candidates, /* @__PURE__ */ new Date());
410
+ await scope.outbox.append([discussionEvents.commentUpdated.make({
411
+ orgId,
412
+ data: {
413
+ ...next,
414
+ mentionedOrgUserIds: next.status === "published" ? keepAllowed(candidates, added) : []
415
+ },
416
+ metadata: { actorId: actor.id }
417
+ })]);
418
+ return next;
419
+ });
420
+ this.logger.info("comment edited", {
421
+ orgId,
422
+ commentId,
423
+ actorId: actor.id
424
+ });
425
+ return this.renderOne(updated, where, actor);
426
+ }
427
+ async remove(commentId, within, actor) {
428
+ const orgId = actor.orgId;
429
+ const comment = await this.findIn(commentId, within, actor, "remove");
430
+ const where = await this.whereOf(comment, actor);
431
+ if (comment.orgUserId !== actor.id && !this.moderates(actor, where)) {
432
+ this.logger.warn("comment remove refused — not the author or a moderator", {
279
433
  orgId,
280
434
  commentId,
281
435
  actorId: actor.id
282
436
  });
283
- throw new NotFoundError("Comment", commentId);
437
+ throw new ForbiddenError("You cannot remove this comment");
284
438
  }
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
- }
292
- if (comment.status === "removed") return comment;
293
- return this.uow.run(async (scope) => {
439
+ if (comment.status === "removed") return this.renderOne(comment, where, actor);
440
+ const removed = await this.uow.run(async (scope) => {
294
441
  const updated = await scope.discussion.updateComment(orgId, commentId, {
295
442
  status: "removed",
296
443
  removedBy: actor.id
@@ -304,7 +451,7 @@ var DiscussionServiceImpl = class {
304
451
  await scope.outbox.append([discussionEvents.commentRemoved.make({
305
452
  orgId,
306
453
  data: updated,
307
- metadata: updated.postId ? { actorOrgUserId: actor.id } : void 0
454
+ metadata: { actorId: actor.id }
308
455
  })]);
309
456
  this.logger.info("comment removed", {
310
457
  orgId,
@@ -313,85 +460,96 @@ var DiscussionServiceImpl = class {
313
460
  });
314
461
  return updated;
315
462
  });
463
+ return this.renderOne(removed, where, actor);
316
464
  }
317
- async restore(orgId, commentId, actor, communityId) {
318
- return this.renderOne(orgId, await this.restoreComment(orgId, commentId, actor, communityId), actor);
465
+ async restore(commentId, within, actor) {
466
+ const comment = await this.findIn(commentId, within, actor, "restore");
467
+ const where = await this.whereOf(comment, actor);
468
+ this.requireModerates(actor, where);
469
+ return this.renderOne(await this.restoreComment(comment, actor), where, actor);
319
470
  }
320
- async restoreComment(orgId, commentId, actor, communityId) {
321
- if (!this.isStaff(actor)) throw new ForbiddenError("You cannot restore this comment");
322
- const comment = await this.getComment(orgId, 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
- }
471
+ async restoreComment(comment, actor) {
331
472
  if (comment.status !== "removed") return comment;
332
- const updated = await this.repo.updateComment(orgId, commentId, {
473
+ const updated = await this.repo.updateComment(actor.orgId, comment.id, {
333
474
  status: "published",
334
475
  removedBy: null
335
476
  });
336
- if (!updated) throw new NotFoundError("Comment", commentId);
477
+ if (!updated) throw new NotFoundError("Comment", comment.id);
337
478
  this.logger.info("comment restored", {
338
- orgId,
339
- commentId,
479
+ orgId: actor.orgId,
480
+ commentId: comment.id,
340
481
  actorId: actor.id
341
482
  });
342
483
  return updated;
343
484
  }
344
- /** Load a comment together with its activity's resolved comments config,
345
- * having proved the actor may reach that activity at all. Every path that
346
- * acts on one comment needs the pair, and none should read the row twice. */
347
- async loadWithConfig(orgId, commentId, actor) {
348
- const comment = await this.getComment(orgId, commentId);
485
+ /** Load a comment together with where it sits and the comments config that
486
+ * applies there, having proved the actor may reach it at all. Every path
487
+ * that acts on one comment needs them, and none should read the row twice. */
488
+ async loadWithConfig(commentId, actor) {
489
+ const comment = await this.getComment(actor.orgId, commentId);
349
490
  if (!comment) throw new NotFoundError("Comment", commentId);
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");
491
+ const where = await this.whereOf(comment, actor);
492
+ if (where.kind === "post") return {
493
+ comment,
494
+ config: {
495
+ enabled: this.commentsOpen(where.post, where.space),
496
+ threaded: true,
497
+ requireReview: false,
498
+ reactions: where.post.reactionsEnabled ?? where.space.settings.reactions
499
+ },
500
+ where
501
+ };
364
502
  return {
365
503
  comment,
366
- config: await this.resolveConfig(orgId, activityId)
504
+ config: await this.configOf(actor.orgId, where.activity),
505
+ where
367
506
  };
368
507
  }
369
- async setReaction(orgId, commentId, actor, emoji) {
370
- const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
508
+ async setReaction(commentId, emoji, actor) {
509
+ const orgId = actor.orgId;
510
+ const { comment, config, where } = await this.loadWithConfig(commentId, actor);
371
511
  if (!config.enabled && !comment.postId) throw new ForbiddenError("Discussion is not open on this activity");
372
512
  if (!config.reactions && emoji !== null) throw new ForbiddenError(comment.postId ? "Reactions are off on this post" : "Reactions are off on this course");
373
513
  if (comment.status === "removed") throw new ForbiddenError("A removed comment cannot be reacted to");
374
- await this.repo.setReaction(orgId, commentId, actor.id, emoji);
514
+ await this.uow.run(async (scope) => {
515
+ if (!await scope.discussion.setReaction(orgId, commentId, actor.id, emoji) || emoji === null) return;
516
+ await scope.outbox.append([discussionEvents.commentReactionAdded.make({
517
+ orgId,
518
+ data: {
519
+ commentId,
520
+ postId: comment.postId,
521
+ activityId: comment.activityId,
522
+ spaceId: where.kind === "post" ? where.post.spaceId : null,
523
+ communityId: comment.communityId,
524
+ orgUserId: actor.id,
525
+ reaction: emoji
526
+ },
527
+ metadata: { actorId: actor.id }
528
+ })]);
529
+ });
375
530
  return (await this.repo.reactionsOf(orgId, [commentId], actor.id))[commentId] ?? EMPTY_REACTIONS;
376
531
  }
377
- async approve(orgId, commentId, actor) {
378
- if (!this.isStaff(actor)) throw new ForbiddenError("You cannot approve this comment");
379
- const comment = await this.getComment(orgId, commentId);
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
- }
532
+ async approve(commentId, actor) {
533
+ const comment = await this.findIn(commentId, "any", actor, "approve");
534
+ const where = await this.whereOf(comment, actor);
535
+ this.requireModerates(actor, where);
536
+ return this.approveComment(comment, actor);
537
+ }
538
+ async approveComment(comment, actor) {
539
+ const orgId = actor.orgId;
540
+ const commentId = comment.id;
388
541
  if (comment.status !== "pending") throw new ForbiddenError("Only a comment awaiting review can be approved");
542
+ const mentioned = await this.openersOf(orgId, comment, await this.mentions.ofItem(orgId, "comment", commentId));
389
543
  return this.uow.run(async (scope) => {
390
544
  const updated = await scope.discussion.updateComment(orgId, commentId, { status: "published" });
391
545
  if (!updated) throw new NotFoundError("Comment", commentId);
392
546
  await scope.outbox.append([discussionEvents.commentPublished.make({
393
547
  orgId,
394
- data: updated
548
+ data: {
549
+ ...updated,
550
+ mentionedOrgUserIds: mentioned
551
+ },
552
+ metadata: { actorId: actor.id }
395
553
  })]);
396
554
  this.logger.info("comment published", {
397
555
  orgId,
@@ -402,51 +560,33 @@ var DiscussionServiceImpl = class {
402
560
  });
403
561
  }
404
562
  /** Loads the comment and dispatches to whichever transition applies — the
405
- * caller must not decide that itself. `approve` and `restore` still enforce
406
- * their own staff check; the check here covers the third path, where a
407
- * comment is already published and neither of them would run. */
408
- async publish(orgId, commentId, actor, communityId) {
409
- if (!this.isStaff(actor)) throw new ForbiddenError("You cannot publish this comment");
410
- const comment = await this.getComment(orgId, 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
- }
419
- if (comment.status === "pending") return this.approve(orgId, commentId, actor);
420
- if (comment.status === "removed") return this.restoreComment(orgId, commentId, actor);
563
+ * caller must not decide that itself. */
564
+ async publish(commentId, within, actor) {
565
+ const comment = await this.findIn(commentId, within, actor, "publish");
566
+ const where = await this.whereOf(comment, actor);
567
+ this.requireModerates(actor, where);
568
+ if (comment.status === "pending") return this.approveComment(comment, actor);
569
+ if (comment.status === "removed") return this.restoreComment(comment, actor);
421
570
  return comment;
422
571
  }
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, {
572
+ async resolveReports(commentId, within, actor) {
573
+ const comment = await this.findIn(commentId, within, actor, "reports resolve");
574
+ const where = await this.whereOf(comment, actor);
575
+ this.requireModerates(actor, where);
576
+ await this.repo.resolveReportsFor(actor.orgId, commentId, {
438
577
  resolvedAt: /* @__PURE__ */ new Date(),
439
578
  resolvedBy: actor.id,
440
579
  resolution: "dismissed"
441
580
  });
442
581
  this.logger.info("comment reports resolved", {
443
- orgId,
582
+ orgId: actor.orgId,
444
583
  commentId,
445
584
  actorId: actor.id
446
585
  });
447
586
  }
448
- async reportComment(orgId, commentId, actor, reason) {
449
- const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
587
+ async reportComment(commentId, reason, actor) {
588
+ const orgId = actor.orgId;
589
+ const { comment, config } = await this.loadWithConfig(commentId, actor);
450
590
  if (!config.enabled && !comment.postId) throw new ForbiddenError("Discussion is not open on this activity");
451
591
  const now = /* @__PURE__ */ new Date();
452
592
  const report = {
@@ -474,7 +614,7 @@ var DiscussionServiceImpl = class {
474
614
  ...saved,
475
615
  communityId: comment.communityId
476
616
  },
477
- metadata: comment.postId ? { actorOrgUserId: actor.id } : void 0
617
+ metadata: { actorId: actor.id }
478
618
  })]);
479
619
  this.logger.info("comment reported", {
480
620
  orgId,
@@ -484,15 +624,25 @@ var DiscussionServiceImpl = class {
484
624
  return saved;
485
625
  });
486
626
  }
487
- async listComments(orgId, query) {
488
- const page = await this.repo.listComments(orgId, query);
627
+ async listComments(query, actor) {
628
+ const orgId = actor.orgId;
629
+ const where = actor.permissions.where("courses:moderate");
630
+ if (!where.org && where.content.length === 0) {
631
+ this.logger.warn("comment list refused — moderates no course", {
632
+ orgId,
633
+ actorId: actor.id
634
+ });
635
+ throw new ForbiddenError("You need courses:moderate");
636
+ }
637
+ const page = await this.repo.listComments(orgId, query, where.org ? void 0 : where.content);
489
638
  const rows = page.rows.map((r) => r.comment);
490
639
  if (rows.length === 0) return {
491
640
  ...page,
492
641
  rows: []
493
642
  };
494
643
  const reports = await this.repo.listOpenReports(orgId, rows.map((c) => c.id));
495
- const authors = await this.repo.authorsOf(orgId, [.../* @__PURE__ */ new Set([...this.peopleIn(rows), ...reports.map((r) => r.orgUserId)])]);
644
+ const people = [.../* @__PURE__ */ new Set([...this.peopleIn(rows), ...reports.map((r) => r.orgUserId)])];
645
+ const [authors, permissions] = await Promise.all([this.repo.authorsOf(orgId, people), this.badgesOf(orgId, people)]);
496
646
  const urls = this.fileUrls;
497
647
  const byComment = /* @__PURE__ */ new Map();
498
648
  for (const report of reports) byComment.set(report.commentId, [...byComment.get(report.commentId) ?? [], report]);
@@ -509,14 +659,14 @@ var DiscussionServiceImpl = class {
509
659
  courseId,
510
660
  body: comment.body,
511
661
  status: comment.status,
512
- author: authorView(orgId, author, urls),
662
+ author: authorView(orgId, author, urls, teamBadge(permissions.get(author.id), courseId)),
513
663
  authorEmail: author.email,
514
664
  removedBy: comment.removedBy,
515
665
  reports: (byComment.get(comment.id) ?? []).map((r) => {
516
666
  const reporter = authors[r.orgUserId];
517
667
  if (!reporter) throw new NotFoundError("Member", r.orgUserId);
518
668
  return {
519
- reporter: authorView(orgId, reporter, urls),
669
+ reporter: authorView(orgId, reporter, urls, teamBadge(permissions.get(reporter.id), courseId)),
520
670
  reason: r.reason,
521
671
  createdAt: r.createdAt
522
672
  };
@@ -530,28 +680,6 @@ var DiscussionServiceImpl = class {
530
680
  getComment(orgId, commentId) {
531
681
  return this.repo.findComment(orgId, commentId);
532
682
  }
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
- }
555
683
  };
556
684
  //#endregion
557
685
  export { DEFAULT_SETTINGS, DiscussionServiceImpl };