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