@headless-lms/server 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 (228) hide show
  1. package/README.md +14 -0
  2. package/dist/app/community-mail.d.ts +10 -10
  3. package/dist/app/community-mail.js +72 -28
  4. package/dist/app/community-mail.js.map +1 -1
  5. package/dist/app/container.d.ts +48 -10
  6. package/dist/app/container.js +268 -48
  7. package/dist/app/container.js.map +1 -1
  8. package/dist/app/notification-channels.d.ts +47 -0
  9. package/dist/app/notification-channels.js +175 -0
  10. package/dist/app/notification-channels.js.map +1 -0
  11. package/dist/app/notification-reads.d.ts +23 -0
  12. package/dist/app/notification-reads.js +74 -0
  13. package/dist/app/notification-reads.js.map +1 -0
  14. package/dist/app/platform.d.ts +14 -0
  15. package/dist/app/platform.js +14 -0
  16. package/dist/app/platform.js.map +1 -0
  17. package/dist/http/config.d.ts +4 -0
  18. package/dist/http/errors.d.ts +1 -0
  19. package/dist/http/errors.js +24 -4
  20. package/dist/http/errors.js.map +1 -1
  21. package/dist/http/fastify.d.ts +8 -0
  22. package/dist/http/mcp/catalogue.d.ts +11 -9
  23. package/dist/http/mcp/catalogue.js +15 -21
  24. package/dist/http/mcp/catalogue.js.map +1 -1
  25. package/dist/http/mcp/challenge.js +3 -3
  26. package/dist/http/mcp/challenge.js.map +1 -1
  27. package/dist/http/mcp/route.js +7 -7
  28. package/dist/http/mcp/route.js.map +1 -1
  29. package/dist/http/mcp/test-support.d.ts +6 -5
  30. package/dist/http/mcp/test-support.js +32 -11
  31. package/dist/http/mcp/test-support.js.map +1 -1
  32. package/dist/http/mcp/tool.d.ts +3 -9
  33. package/dist/http/mcp/tool.js +14 -6
  34. package/dist/http/mcp/tool.js.map +1 -1
  35. package/dist/http/mcp/tools/automations.js +10 -20
  36. package/dist/http/mcp/tools/automations.js.map +1 -1
  37. package/dist/http/mcp/tools/catalog.js +25 -42
  38. package/dist/http/mcp/tools/catalog.js.map +1 -1
  39. package/dist/http/mcp/tools/comments.js +9 -19
  40. package/dist/http/mcp/tools/comments.js.map +1 -1
  41. package/dist/http/mcp/tools/communities.js +16 -31
  42. package/dist/http/mcp/tools/communities.js.map +1 -1
  43. package/dist/http/mcp/tools/community-members.js +9 -16
  44. package/dist/http/mcp/tools/community-members.js.map +1 -1
  45. package/dist/http/mcp/tools/community-moderation.js +13 -25
  46. package/dist/http/mcp/tools/community-moderation.js.map +1 -1
  47. package/dist/http/mcp/tools/courses.js +11 -21
  48. package/dist/http/mcp/tools/courses.js.map +1 -1
  49. package/dist/http/mcp/tools/events.d.ts +6 -0
  50. package/dist/http/mcp/tools/events.js +170 -0
  51. package/dist/http/mcp/tools/events.js.map +1 -0
  52. package/dist/http/mcp/tools/index.d.ts +5 -1
  53. package/dist/http/mcp/tools/index.js +45 -10
  54. package/dist/http/mcp/tools/index.js.map +1 -1
  55. package/dist/http/mcp/tools/organization.d.ts +6 -0
  56. package/dist/http/mcp/tools/organization.js +214 -0
  57. package/dist/http/mcp/tools/organization.js.map +1 -0
  58. package/dist/http/mcp/tools/people.js +30 -49
  59. package/dist/http/mcp/tools/people.js.map +1 -1
  60. package/dist/http/plugins/auth.js +37 -8
  61. package/dist/http/plugins/auth.js.map +1 -1
  62. package/dist/http/plugins/error-handler.d.ts +5 -1
  63. package/dist/http/plugins/error-handler.js +13 -3
  64. package/dist/http/plugins/error-handler.js.map +1 -1
  65. package/dist/http/plugins/openapi.js +18 -2
  66. package/dist/http/plugins/openapi.js.map +1 -1
  67. package/dist/http/plugins/platform.d.ts +7 -0
  68. package/dist/http/plugins/platform.js +13 -0
  69. package/dist/http/plugins/platform.js.map +1 -0
  70. package/dist/http/routes/access.js +54 -36
  71. package/dist/http/routes/access.js.map +1 -1
  72. package/dist/http/routes/activities.js +45 -45
  73. package/dist/http/routes/activities.js.map +1 -1
  74. package/dist/http/routes/assets.js +30 -65
  75. package/dist/http/routes/assets.js.map +1 -1
  76. package/dist/http/routes/automations.js +37 -42
  77. package/dist/http/routes/automations.js.map +1 -1
  78. package/dist/http/routes/bundles.js +33 -36
  79. package/dist/http/routes/bundles.js.map +1 -1
  80. package/dist/http/routes/communities.js +144 -136
  81. package/dist/http/routes/communities.js.map +1 -1
  82. package/dist/http/routes/community-moderation.js +83 -137
  83. package/dist/http/routes/community-moderation.js.map +1 -1
  84. package/dist/http/routes/course-analytics.js +32 -15
  85. package/dist/http/routes/course-analytics.js.map +1 -1
  86. package/dist/http/routes/courses.js +25 -32
  87. package/dist/http/routes/courses.js.map +1 -1
  88. package/dist/http/routes/dashboard.js +9 -9
  89. package/dist/http/routes/dashboard.js.map +1 -1
  90. package/dist/http/routes/discussion.js +21 -39
  91. package/dist/http/routes/discussion.js.map +1 -1
  92. package/dist/http/routes/downloads.js +41 -47
  93. package/dist/http/routes/downloads.js.map +1 -1
  94. package/dist/http/routes/entitlements.js +19 -23
  95. package/dist/http/routes/entitlements.js.map +1 -1
  96. package/dist/http/routes/events.d.ts +7 -0
  97. package/dist/http/routes/events.js +333 -0
  98. package/dist/http/routes/events.js.map +1 -0
  99. package/dist/http/routes/integrations.js +40 -73
  100. package/dist/http/routes/integrations.js.map +1 -1
  101. package/dist/http/routes/member/assets.js +10 -13
  102. package/dist/http/routes/member/assets.js.map +1 -1
  103. package/dist/http/routes/member/chats.js +177 -75
  104. package/dist/http/routes/member/chats.js.map +1 -1
  105. package/dist/http/routes/member/comments.js +65 -26
  106. package/dist/http/routes/member/comments.js.map +1 -1
  107. package/dist/http/routes/member/community.js +68 -51
  108. package/dist/http/routes/member/community.js.map +1 -1
  109. package/dist/http/routes/member/courses.js +12 -19
  110. package/dist/http/routes/member/courses.js.map +1 -1
  111. package/dist/http/routes/member/devices.d.ts +7 -0
  112. package/dist/http/routes/member/devices.js +97 -0
  113. package/dist/http/routes/member/devices.js.map +1 -0
  114. package/dist/http/routes/member/downloads.js +6 -9
  115. package/dist/http/routes/member/downloads.js.map +1 -1
  116. package/dist/http/routes/member/events.d.ts +7 -0
  117. package/dist/http/routes/member/events.js +386 -0
  118. package/dist/http/routes/member/events.js.map +1 -0
  119. package/dist/http/routes/member/files.js +71 -46
  120. package/dist/http/routes/member/files.js.map +1 -1
  121. package/dist/http/routes/member/index.js +6 -0
  122. package/dist/http/routes/member/index.js.map +1 -1
  123. package/dist/http/routes/member/notification-settings.d.ts +7 -0
  124. package/dist/http/routes/member/notification-settings.js +100 -0
  125. package/dist/http/routes/member/notification-settings.js.map +1 -0
  126. package/dist/http/routes/member/notifications.js +69 -45
  127. package/dist/http/routes/member/notifications.js.map +1 -1
  128. package/dist/http/routes/member/org.js +5 -15
  129. package/dist/http/routes/member/org.js.map +1 -1
  130. package/dist/http/routes/member/posts.js +94 -95
  131. package/dist/http/routes/member/posts.js.map +1 -1
  132. package/dist/http/routes/member/profile.js +15 -6
  133. package/dist/http/routes/member/profile.js.map +1 -1
  134. package/dist/http/routes/member/progress.js +7 -11
  135. package/dist/http/routes/member/progress.js.map +1 -1
  136. package/dist/http/routes/organizations.js +415 -110
  137. package/dist/http/routes/organizations.js.map +1 -1
  138. package/dist/http/routes/platform.d.ts +7 -0
  139. package/dist/http/routes/platform.js +61 -0
  140. package/dist/http/routes/platform.js.map +1 -0
  141. package/dist/http/routes/students.js +12 -75
  142. package/dist/http/routes/students.js.map +1 -1
  143. package/dist/http/routes/webhooks.js +30 -39
  144. package/dist/http/routes/webhooks.js.map +1 -1
  145. package/dist/http/routes.js +9 -0
  146. package/dist/http/routes.js.map +1 -1
  147. package/dist/http/schemas/access.d.ts +346 -64
  148. package/dist/http/schemas/access.js +39 -12
  149. package/dist/http/schemas/access.js.map +1 -1
  150. package/dist/http/schemas/assets.d.ts +13 -8
  151. package/dist/http/schemas/assets.js +6 -5
  152. package/dist/http/schemas/assets.js.map +1 -1
  153. package/dist/http/schemas/automations.d.ts +4 -8
  154. package/dist/http/schemas/chats.d.ts +238 -36
  155. package/dist/http/schemas/chats.js +14 -7
  156. package/dist/http/schemas/chats.js.map +1 -1
  157. package/dist/http/schemas/community.d.ts +140 -33
  158. package/dist/http/schemas/community.js +25 -9
  159. package/dist/http/schemas/community.js.map +1 -1
  160. package/dist/http/schemas/course-analytics.d.ts +51 -1
  161. package/dist/http/schemas/course-analytics.js +19 -1
  162. package/dist/http/schemas/course-analytics.js.map +1 -1
  163. package/dist/http/schemas/dashboard.d.ts +1 -0
  164. package/dist/http/schemas/dashboard.js +2 -1
  165. package/dist/http/schemas/dashboard.js.map +1 -1
  166. package/dist/http/schemas/discussion.d.ts +45 -73
  167. package/dist/http/schemas/discussion.js +16 -4
  168. package/dist/http/schemas/discussion.js.map +1 -1
  169. package/dist/http/schemas/entitlements.d.ts +9 -12
  170. package/dist/http/schemas/entitlements.js +11 -5
  171. package/dist/http/schemas/entitlements.js.map +1 -1
  172. package/dist/http/schemas/events.d.ts +527 -0
  173. package/dist/http/schemas/events.js +64 -0
  174. package/dist/http/schemas/events.js.map +1 -0
  175. package/dist/http/schemas/files.d.ts +15 -5
  176. package/dist/http/schemas/files.js +10 -2
  177. package/dist/http/schemas/files.js.map +1 -1
  178. package/dist/http/schemas/index.d.ts +14 -12
  179. package/dist/http/schemas/index.js +14 -12
  180. package/dist/http/schemas/invites.d.ts +3 -47
  181. package/dist/http/schemas/invites.js +4 -25
  182. package/dist/http/schemas/invites.js.map +1 -1
  183. package/dist/http/schemas/member.d.ts +94 -7
  184. package/dist/http/schemas/member.js +7 -3
  185. package/dist/http/schemas/member.js.map +1 -1
  186. package/dist/http/schemas/members.d.ts +329 -65
  187. package/dist/http/schemas/members.js +67 -25
  188. package/dist/http/schemas/members.js.map +1 -1
  189. package/dist/http/schemas/notifications.d.ts +502 -175
  190. package/dist/http/schemas/notifications.js +148 -28
  191. package/dist/http/schemas/notifications.js.map +1 -1
  192. package/dist/http/schemas/platform.d.ts +37 -0
  193. package/dist/http/schemas/platform.js +19 -0
  194. package/dist/http/schemas/platform.js.map +1 -0
  195. package/dist/http/schemas/posts.d.ts +92 -38
  196. package/dist/http/schemas/posts.js +4 -4
  197. package/dist/http/schemas/posts.js.map +1 -1
  198. package/dist/http/schemas/profile.d.ts +1 -1
  199. package/dist/http/schemas/shared.d.ts +10 -13
  200. package/dist/http/schemas/shared.js +3 -11
  201. package/dist/http/schemas/shared.js.map +1 -1
  202. package/dist/http/schemas/students.d.ts +22 -0
  203. package/dist/http/schemas/students.js +3 -1
  204. package/dist/http/schemas/students.js.map +1 -1
  205. package/dist/http/scope.d.ts +9 -12
  206. package/dist/http/scope.js +30 -35
  207. package/dist/http/scope.js.map +1 -1
  208. package/dist/http/server.js +9 -0
  209. package/dist/http/server.js.map +1 -1
  210. package/dist/http/shutdown.d.ts +11 -0
  211. package/dist/http/shutdown.js +40 -0
  212. package/dist/http/shutdown.js.map +1 -0
  213. package/dist/http/sse.d.ts +19 -0
  214. package/dist/http/sse.js +104 -0
  215. package/dist/http/sse.js.map +1 -0
  216. package/dist/http/test-caller.d.ts +13 -0
  217. package/dist/http/test-caller.js +14 -0
  218. package/dist/http/test-caller.js.map +1 -0
  219. package/dist/http/well-known.js +2 -2
  220. package/dist/http/well-known.js.map +1 -1
  221. package/dist/index.d.ts +5 -2
  222. package/dist/index.js +4 -2
  223. package/dist/index.js.map +1 -1
  224. package/dist/packages/server/package.js +1 -1
  225. package/package.json +7 -6
  226. package/dist/http/routes/member/actor.d.ts +0 -14
  227. package/dist/http/routes/member/actor.js +0 -11
  228. package/dist/http/routes/member/actor.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"community-moderation.js","names":[],"sources":["../../../src/http/routes/community-moderation.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport { z } from 'zod';\nimport type { Container } from '../../app/container.js';\nimport { NotFoundError } from '@headless-lms/core/shared/errors';\nimport { resolveScope } from '../scope.js';\nimport {\n Chat,\n CommentView,\n CommunityChatParam,\n CommunityCommentParam,\n CommunityIdParam,\n CommunityMembersPage,\n CommunityMembersQuery,\n CommunityMessageParam,\n CommunityPostParam,\n CommunitySpaceParam,\n OrgUserBody,\n PageQuery,\n PostView,\n ResolveCommentFlags,\n ResolveMessageFlags,\n ResolvePostFlags,\n SpaceMember,\n SpaceMemberParam,\n SpaceMembersPage,\n SpaceQueue,\n refusals,\n} from '../schemas/index.js';\n\nexport async function communityModerationRoutes(\n app: FastifyInstance,\n container: Container,\n): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n const tags = ['Communities'];\n\n r.route({\n method: 'GET',\n url: '/api/communities/:communityId/members',\n schema: {\n operationId: 'listCommunityMembers',\n tags,\n summary: 'List the people who hold a community (staff)',\n description:\n 'Staff only (owner, admin, instructor). Everyone with active access to the community, granted directly or through a bundle, paged and searched by name or email.',\n params: CommunityIdParam,\n querystring: CommunityMembersQuery,\n response: { 200: CommunityMembersPage, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n const page = await container.reporting.admin.communityMembers(\n scope.orgId,\n req.params.communityId,\n req.query,\n );\n if (!page) {\n throw new NotFoundError('Community', req.params.communityId);\n }\n return page;\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/communities/:communityId/spaces/:spaceId/members',\n schema: {\n operationId: 'listCommunitySpaceMembers',\n tags,\n summary: 'List the members, requests and invitations of a space (staff)',\n description:\n \"Staff only (owner, admin, instructor). The space's members, its open requests to join and its open invitations.\",\n params: CommunitySpaceParam,\n querystring: PageQuery,\n response: { 200: SpaceMembersPage, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.entitlements.listSpaceMembers(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n req.query,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members',\n schema: {\n operationId: 'addCommunitySpaceMember',\n tags,\n summary: 'Add a person to a space (staff)',\n description:\n 'Staff only (owner, admin, instructor). Places a member of the community straight into the space, with no request or invitation. 409 when the space is open to all members, the person is already placed, or the space is full.',\n params: CommunitySpaceParam,\n body: OrgUserBody,\n response: { 201: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n const member = await container.entitlements.addSpaceMember(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n req.body.orgUserId,\n );\n return reply.code(201).send(member);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/invites',\n schema: {\n operationId: 'inviteCommunitySpaceMember',\n tags,\n summary: 'Invite a person into a space (staff)',\n description:\n 'Staff only (owner, admin, instructor). Invites a member of the community into a `by_invite` space; they join when they accept. 403 when the person is staff or does not hold the community; 409 when the space is not invite-only or the person is already placed.',\n params: CommunitySpaceParam,\n body: OrgUserBody,\n response: { 201: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n const member = await container.entitlements.invite(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n req.body.orgUserId,\n );\n return reply.code(201).send(member);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members/:orgUserId/approval',\n schema: {\n operationId: 'approveCommunitySpaceRequest',\n tags,\n summary: \"Approve a person's request to join a space (staff)\",\n description:\n 'Staff only (owner, admin, instructor). Makes the requester an active member. 404 when the person has no open request; 409 when the space is full or they are already a member.',\n params: SpaceMemberParam,\n response: { 200: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.entitlements.approve(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n req.params.orgUserId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members/:orgUserId/decline',\n schema: {\n operationId: 'declineCommunitySpaceRequest',\n tags,\n summary: \"Decline a person's request to join a space (staff)\",\n description:\n 'Staff only (owner, admin, instructor). Closes the open request. 404 when the person has no open request; 409 when they are already a member.',\n params: SpaceMemberParam,\n response: { 200: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.entitlements.decline(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n req.params.orgUserId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members/:orgUserId/removal',\n schema: {\n operationId: 'removeCommunitySpaceMember',\n tags,\n summary: 'Remove a person from a space (staff)',\n description:\n 'Staff only (owner, admin, instructor). Revokes the placement, whether it is a membership, a request or an invitation. 409 when the space is open to all members or the placement is already revoked.',\n params: SpaceMemberParam,\n response: { 200: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.entitlements.remove(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n req.params.orgUserId,\n );\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/communities/:communityId/spaces/:spaceId/queue',\n schema: {\n operationId: 'getCommunitySpaceQueue',\n tags,\n summary: 'The moderation queue of a space (staff)',\n description:\n 'Staff only (owner, admin, instructor). Posts awaiting review, and the open flags on posts and on comments in the space. Each list holds at most the 100 oldest entries: pending posts by creation, flags by when they were raised; working the queue brings in the next ones.',\n params: CommunitySpaceParam,\n response: { 200: SpaceQueue, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.posts.moderationQueue(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n req.params.spaceId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/approval',\n schema: {\n operationId: 'approveCommunityPost',\n tags,\n summary: 'Publish a post that is awaiting review (staff)',\n description:\n 'Staff only (owner, admin, instructor). 409 when the post is not awaiting review.',\n params: CommunityPostParam,\n response: { 200: PostView, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.posts.approve(\n scope.orgId,\n req.params.postId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/removal',\n schema: {\n operationId: 'removeCommunityPost',\n tags,\n summary: 'Remove a post (staff)',\n description:\n 'Staff only (owner, admin, instructor). Takes a published or pending post down and resolves its open flags as `removed`. 409 when it is already removed or was never submitted.',\n params: CommunityPostParam,\n response: { 200: PostView, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.posts.remove(\n scope.orgId,\n req.params.postId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/restoration',\n schema: {\n operationId: 'restoreCommunityPost',\n tags,\n summary: 'Restore a removed post (staff)',\n description: 'Staff only (owner, admin, instructor). 409 when the post is not removed.',\n params: CommunityPostParam,\n response: { 200: PostView, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.posts.restore(\n scope.orgId,\n req.params.postId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/flags/resolution',\n schema: {\n operationId: 'resolveCommunityPostFlags',\n tags,\n summary: 'Dismiss the open flags on a post (staff)',\n description:\n 'Staff only (owner, admin, instructor). Closes every open flag on the post and leaves it up.',\n params: CommunityPostParam,\n body: ResolvePostFlags,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n await container.posts.resolveFlags(\n scope.orgId,\n req.params.postId,\n { id: scope.orgUserId, role: scope.role },\n req.body.resolution,\n req.params.communityId,\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/comments/:commentId/removal',\n schema: {\n operationId: 'removeCommunityComment',\n tags,\n summary: 'Remove a comment on a post (staff)',\n description:\n 'Staff only (owner, admin, instructor). Hides the comment and resolves its open flags as `removed`. Removing a removed comment returns it unchanged.',\n params: CommunityCommentParam,\n response: { 200: CommentView, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.discussion.remove(\n scope.orgId,\n req.params.commentId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/comments/:commentId/restoration',\n schema: {\n operationId: 'restoreCommunityComment',\n tags,\n summary: 'Restore a removed comment on a post (staff)',\n description:\n 'Staff only (owner, admin, instructor). Publishes the comment again. Restoring a comment that is not removed returns it unchanged.',\n params: CommunityCommentParam,\n response: { 200: CommentView, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.discussion.restore(\n scope.orgId,\n req.params.commentId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/comments/:commentId/flags/resolution',\n schema: {\n operationId: 'resolveCommunityCommentFlags',\n tags,\n summary: 'Dismiss the open flags on a comment (staff)',\n description:\n 'Staff only (owner, admin, instructor). Closes every open flag on the comment and leaves it up.',\n params: CommunityCommentParam,\n body: ResolveCommentFlags,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n await container.discussion.resolveReports(\n scope.orgId,\n req.params.commentId,\n { id: scope.orgUserId, role: scope.role },\n req.params.communityId,\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/messages/:messageId/flags/resolution',\n schema: {\n operationId: 'resolveCommunityMessageFlags',\n tags,\n summary: 'Dismiss the open flags on a message (staff)',\n description:\n 'Staff only (owner, admin, instructor). Closes every open flag on the message and leaves it up.',\n params: CommunityMessageParam,\n body: ResolveMessageFlags,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n await container.chats.resolveMessageFlags(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.messageId,\n req.body.resolution,\n req.params.communityId,\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/chats/:chatId/lock',\n schema: {\n operationId: 'lockCommunityChat',\n tags,\n summary: 'Lock a chat (staff)',\n description:\n 'Staff only (owner, admin, instructor). Nobody can send or edit messages until it is unlocked; its open message flags are resolved as `locked`. Locking a locked chat returns it unchanged.',\n params: CommunityChatParam,\n response: { 200: Chat, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.chats.lock(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.chatId,\n req.params.communityId,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/chats/:chatId/unlock',\n schema: {\n operationId: 'unlockCommunityChat',\n tags,\n summary: 'Unlock a chat (staff)',\n description:\n 'Staff only (owner, admin, instructor). Lets participants send messages again. Unlocking an unlocked chat returns it unchanged.',\n params: CommunityChatParam,\n response: { 200: Chat, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.chats.unlock(\n scope.orgId,\n { id: scope.orgUserId, role: scope.role },\n req.params.chatId,\n req.params.communityId,\n );\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"community-moderation.js","names":[],"sources":["../../../src/http/routes/community-moderation.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport { z } from 'zod';\nimport type { Container } from '../../app/container.js';\nimport { resolveActor } from '../scope.js';\nimport {\n Chat,\n CommentView,\n CommunityChatParam,\n CommunityCommentParam,\n CommunityIdParam,\n CommunityMembersPage,\n CommunityMembersQuery,\n CommunityMessageParam,\n CommunityPostParam,\n CommunitySpaceParam,\n OrgUserBody,\n PageQuery,\n PostView,\n ResolveCommentFlags,\n ResolveMessageFlags,\n ResolvePostFlags,\n SpaceMember,\n SpaceMemberParam,\n SpaceMembersPage,\n SpaceQueue,\n refusals,\n} from '../schemas/index.js';\n\nexport async function communityModerationRoutes(\n app: FastifyInstance,\n container: Container,\n): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n const tags = ['Communities'];\n\n r.route({\n method: 'GET',\n url: '/api/communities/:communityId/members',\n schema: {\n operationId: 'listCommunityMembers',\n tags,\n summary: 'List the people who hold a community',\n description: `Requires \\`access:read\\`. Everyone with active access to the community, granted directly or through a bundle, paged and searched by name or email.`,\n params: CommunityIdParam,\n querystring: CommunityMembersQuery,\n response: { 200: CommunityMembersPage, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.reporting.admin.communityMembers(req.params.communityId, req.query, actor);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/communities/:communityId/spaces/:spaceId/members',\n schema: {\n operationId: 'listCommunitySpaceMembers',\n tags,\n summary: 'List the members, requests and invitations of a space',\n description: `For a person who can open the space. Its members; a holder of \\`communities:moderate\\` on the community also sees its open requests to join and open invitations. People who open every space of the community through their role are not listed. 404 when you cannot open the space.`,\n params: CommunitySpaceParam,\n querystring: PageQuery,\n response: { 200: SpaceMembersPage, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.entitlements.listSpaceMembers(\n req.params.communityId,\n req.params.spaceId,\n req.query,\n actor,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members',\n schema: {\n operationId: 'addCommunitySpaceMember',\n tags,\n summary: 'Add a person to a space',\n description: `Requires \\`communities:moderate\\`. Places a member of the community straight into the space, with no request or invitation. 409 when the space is open to all members, the person is already placed, or the space is full.`,\n params: CommunitySpaceParam,\n body: OrgUserBody,\n response: { 201: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n const member = await container.entitlements.addSpaceMember(\n req.params.communityId,\n req.params.spaceId,\n req.body.orgUserId,\n actor,\n );\n return reply.code(201).send(member);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/invites',\n schema: {\n operationId: 'inviteCommunitySpaceMember',\n tags,\n summary: 'Invite a person into a space',\n description: `Requires \\`communities:moderate\\`. Invites a member of the community into a \\`by_invite\\` space; they join when they accept. 403 when the person opens every space or is not a member of the community; 409 when the space is not invite-only or the person is already placed.`,\n params: CommunitySpaceParam,\n body: OrgUserBody,\n response: { 201: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n const member = await container.entitlements.invite(\n req.params.communityId,\n req.params.spaceId,\n req.body.orgUserId,\n actor,\n );\n return reply.code(201).send(member);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members/:orgUserId/approve',\n schema: {\n operationId: 'approveCommunitySpaceRequest',\n tags,\n summary: \"Approve a person's request to join a space\",\n description: `Requires \\`communities:moderate\\`. Makes the requester an active member. 404 when the person has no open request; 409 when the space is full or they are already a member.`,\n params: SpaceMemberParam,\n response: { 200: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.entitlements.approve(\n req.params.communityId,\n req.params.spaceId,\n req.params.orgUserId,\n actor,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members/:orgUserId/decline',\n schema: {\n operationId: 'declineCommunitySpaceRequest',\n tags,\n summary: \"Decline a person's request to join a space\",\n description: `Requires \\`communities:moderate\\`. Closes the open request. 404 when the person has no open request; 409 when they are already a member.`,\n params: SpaceMemberParam,\n response: { 200: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.entitlements.decline(\n req.params.communityId,\n req.params.spaceId,\n req.params.orgUserId,\n actor,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/spaces/:spaceId/members/:orgUserId/remove',\n schema: {\n operationId: 'removeCommunitySpaceMember',\n tags,\n summary: 'Remove a person from a space',\n description: `Requires \\`communities:moderate\\`. Revokes the placement, whether it is a membership, a request or an invitation. 409 when the space is open to all members or the placement is already revoked.`,\n params: SpaceMemberParam,\n response: { 200: SpaceMember, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.entitlements.remove(\n req.params.communityId,\n req.params.spaceId,\n req.params.orgUserId,\n actor,\n );\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/communities/:communityId/spaces/:spaceId/queue',\n schema: {\n operationId: 'getCommunitySpaceQueue',\n tags,\n summary: 'The moderation queue of a space',\n description: `Requires \\`communities:moderate\\`. Posts awaiting review, and the open flags on posts and on comments in the space. Each list holds at most the 100 oldest entries: pending posts by creation, flags by when they were raised; working the queue brings in the next ones.`,\n params: CommunitySpaceParam,\n response: { 200: SpaceQueue, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.posts.moderationQueue(req.params.communityId, req.params.spaceId, actor);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/approve',\n schema: {\n operationId: 'approveCommunityPost',\n tags,\n summary: 'Publish a post that is awaiting review',\n description: `Requires \\`communities:moderate\\`. 409 when the post is not awaiting review.`,\n params: CommunityPostParam,\n response: { 200: PostView, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.posts.approve(req.params.postId, req.params.communityId, actor);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/remove',\n schema: {\n operationId: 'removeCommunityPost',\n tags,\n summary: 'Remove a post',\n description: `The author can remove their own post; anyone else needs \\`communities:moderate\\` on the community. Takes a published or pending post down; removing someone else's post resolves its open flags as \\`removed\\`. 409 when it is already removed or was never submitted.`,\n params: CommunityPostParam,\n response: { 200: PostView, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.posts.remove(req.params.postId, req.params.communityId, actor);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/restore',\n schema: {\n operationId: 'restoreCommunityPost',\n tags,\n summary: 'Restore a removed post',\n description: `Requires \\`communities:moderate\\`. 409 when the post is not removed.`,\n params: CommunityPostParam,\n response: { 200: PostView, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.posts.restore(req.params.postId, req.params.communityId, actor);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/posts/:postId/flags/resolve',\n schema: {\n operationId: 'resolveCommunityPostFlags',\n tags,\n summary: 'Dismiss the open flags on a post',\n description: `Requires \\`communities:moderate\\`. Closes every open flag on the post and leaves it up.`,\n params: CommunityPostParam,\n body: ResolvePostFlags,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n await container.posts.resolveFlags(\n req.params.postId,\n req.body.resolution,\n req.params.communityId,\n actor,\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/comments/:commentId/remove',\n schema: {\n operationId: 'removeCommunityComment',\n tags,\n summary: 'Remove a comment on a post',\n description: `The author can remove their own comment; anyone else needs \\`communities:moderate\\` on the community. Hides the comment; removing someone else's comment resolves its open flags as \\`removed\\`. Removing a removed comment returns it unchanged.`,\n params: CommunityCommentParam,\n response: { 200: CommentView, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.discussion.remove(\n req.params.commentId,\n { communityId: req.params.communityId },\n actor,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/comments/:commentId/restore',\n schema: {\n operationId: 'restoreCommunityComment',\n tags,\n summary: 'Restore a removed comment on a post',\n description: `Requires one of \\`courses:moderate\\`, \\`communities:moderate\\`. Publishes the comment again. Restoring a comment that is not removed returns it unchanged.`,\n params: CommunityCommentParam,\n response: { 200: CommentView, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.discussion.restore(\n req.params.commentId,\n { communityId: req.params.communityId },\n actor,\n );\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/comments/:commentId/flags/resolve',\n schema: {\n operationId: 'resolveCommunityCommentFlags',\n tags,\n summary: 'Dismiss the open flags on a comment',\n description: `Requires one of \\`courses:moderate\\`, \\`communities:moderate\\`. Closes every open flag on the comment and leaves it up.`,\n params: CommunityCommentParam,\n body: ResolveCommentFlags,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n await container.discussion.resolveReports(\n req.params.commentId,\n { communityId: req.params.communityId },\n actor,\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/messages/:messageId/flags/resolve',\n schema: {\n operationId: 'resolveCommunityMessageFlags',\n tags,\n summary: 'Dismiss the open flags on a message',\n description: `Requires \\`communities:moderate\\`. Closes every open flag on the message and leaves it up.`,\n params: CommunityMessageParam,\n body: ResolveMessageFlags,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n await container.chats.resolveMessageFlags(\n req.params.messageId,\n req.body.resolution,\n req.params.communityId,\n actor,\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/chats/:chatId/lock',\n schema: {\n operationId: 'lockCommunityChat',\n tags,\n summary: 'Lock a chat',\n description: `Requires \\`communities:moderate\\`. Nobody can send or edit messages until it is unlocked; its open message flags are resolved as \\`locked\\`. Locking a locked chat returns it unchanged.`,\n params: CommunityChatParam,\n response: { 200: Chat, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.chats.lock(req.params.chatId, req.params.communityId, actor);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/communities/:communityId/chats/:chatId/unlock',\n schema: {\n operationId: 'unlockCommunityChat',\n tags,\n summary: 'Unlock a chat',\n description: `Requires \\`communities:moderate\\`. Lets participants send messages again. Unlocking an unlocked chat returns it unchanged.`,\n params: CommunityChatParam,\n response: { 200: Chat, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.chats.unlock(req.params.chatId, req.params.communityId, actor);\n },\n });\n}\n"],"mappings":"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"}
@@ -1,10 +1,10 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveScope } from "../scope.js";
2
+ import { resolveActor } from "../scope.js";
3
3
  import { CourseIdParam } from "../schemas/content.js";
4
+ import { CourseIdPathParam } from "../schemas/activities.js";
4
5
  import { EnrollmentSeries, EnrollmentSeriesQuery } from "../schemas/dashboard.js";
5
- import { CourseAnalytics } from "../schemas/course-analytics.js";
6
+ import { CourseAnalytics, CourseLearnersPage, CourseLearnersQuery } from "../schemas/course-analytics.js";
6
7
  import "../schemas/index.js";
7
- import { NotFoundError } from "@headless-lms/core/shared/errors";
8
8
  //#region src/http/routes/course-analytics.ts
9
9
  async function courseAnalyticsRoutes(app, container) {
10
10
  const r = app.withTypeProvider();
@@ -15,8 +15,8 @@ async function courseAnalyticsRoutes(app, container) {
15
15
  schema: {
16
16
  operationId: "getCourseAnalytics",
17
17
  tags: ["Reporting"],
18
- summary: "Completion and engagement stats for a course (staff)",
19
- description: "Staff only (owner, admin, instructor).",
18
+ summary: "Completion and engagement stats for a course",
19
+ description: "Requires `progress:read`.",
20
20
  params: CourseIdParam,
21
21
  response: {
22
22
  200: CourseAnalytics,
@@ -24,10 +24,8 @@ async function courseAnalyticsRoutes(app, container) {
24
24
  }
25
25
  },
26
26
  handler: async (req) => {
27
- const scope = await resolveScope(container, req);
28
- const analytics = await container.reporting.admin.courseAnalytics(scope.orgId, req.params.id);
29
- if (!analytics) throw new NotFoundError("Course", req.params.id);
30
- return analytics;
27
+ const actor = await resolveActor(container, req);
28
+ return container.reporting.admin.courseAnalytics(req.params.id, actor);
31
29
  }
32
30
  });
33
31
  r.route({
@@ -37,8 +35,8 @@ async function courseAnalyticsRoutes(app, container) {
37
35
  schema: {
38
36
  operationId: "getCourseEnrollmentSeries",
39
37
  tags: ["Reporting"],
40
- summary: "Enrollments granted per day for a course over a trailing window (staff)",
41
- description: "Staff only (owner, admin, instructor).",
38
+ summary: "Enrollments granted per day for a course over a trailing window",
39
+ description: "Requires `progress:read`.",
42
40
  params: CourseIdParam,
43
41
  querystring: EnrollmentSeriesQuery,
44
42
  response: {
@@ -47,10 +45,29 @@ async function courseAnalyticsRoutes(app, container) {
47
45
  }
48
46
  },
49
47
  handler: async (req) => {
50
- const scope = await resolveScope(container, req);
51
- const series = await container.reporting.admin.courseEnrollments(scope.orgId, req.params.id, req.query.days);
52
- if (!series) throw new NotFoundError("Course", req.params.id);
53
- return series;
48
+ const actor = await resolveActor(container, req);
49
+ return container.reporting.admin.courseEnrollments(req.params.id, req.query.days, actor);
50
+ }
51
+ });
52
+ r.route({
53
+ method: "GET",
54
+ url: "/api/courses/:courseId/learners",
55
+ preHandler: app.requireOrgSession,
56
+ schema: {
57
+ operationId: "listCourseLearners",
58
+ tags: ["Reporting"],
59
+ summary: "List a course's learners with their access and progress",
60
+ description: `Requires \`progress:read\`. Everyone with a grant that opens the course, directly or through a bundle, active or not, each with that grant and their progress. Paged and searched by name or email.`,
61
+ params: CourseIdPathParam,
62
+ querystring: CourseLearnersQuery,
63
+ response: {
64
+ 200: CourseLearnersPage,
65
+ ...refusals(403, 404)
66
+ }
67
+ },
68
+ handler: async (req) => {
69
+ const actor = await resolveActor(container, req);
70
+ return container.reporting.admin.courseLearners(req.params.courseId, req.query, actor);
54
71
  }
55
72
  });
56
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"course-analytics.js","names":[],"sources":["../../../src/http/routes/course-analytics.ts"],"sourcesContent":["// HTTP routes for per-course analytics (reporting).\nimport type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport {\n CourseAnalytics,\n CourseIdParam,\n EnrollmentSeries,\n EnrollmentSeriesQuery,\n refusals,\n} from '../schemas/index.js';\nimport { NotFoundError } from '@headless-lms/core/shared/errors';\nimport type { Container } from '../../app/container.js';\nimport { resolveScope } from '../scope.js';\n\nexport async function courseAnalyticsRoutes(\n app: FastifyInstance,\n container: Container,\n): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n\n r.route({\n method: 'GET',\n url: '/api/courses/:id/analytics',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getCourseAnalytics',\n tags: ['Reporting'],\n summary: 'Completion and engagement stats for a course (staff)',\n description: 'Staff only (owner, admin, instructor).',\n params: CourseIdParam,\n response: { 200: CourseAnalytics, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n const analytics = await container.reporting.admin.courseAnalytics(scope.orgId, req.params.id);\n if (!analytics) {\n throw new NotFoundError('Course', req.params.id);\n }\n return analytics;\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/courses/:id/analytics/enrollments',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getCourseEnrollmentSeries',\n tags: ['Reporting'],\n summary: 'Enrollments granted per day for a course over a trailing window (staff)',\n description: 'Staff only (owner, admin, instructor).',\n params: CourseIdParam,\n querystring: EnrollmentSeriesQuery,\n response: { 200: EnrollmentSeries, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n const series = await container.reporting.admin.courseEnrollments(\n scope.orgId,\n req.params.id,\n req.query.days,\n );\n if (!series) {\n throw new NotFoundError('Course', req.params.id);\n }\n return series;\n },\n });\n}\n"],"mappings":";;;;;;;;AAcA,eAAsB,sBACpB,KACA,WACe;CACf,MAAM,IAAI,IAAI,iBAAkC;CAEhD,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,UAAU;IAAE,KAAK;IAAiB,GAAG,SAAS,KAAK,GAAG;GAAE;EAC1D;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,MAAM,YAAY,MAAM,UAAU,UAAU,MAAM,gBAAgB,MAAM,OAAO,IAAI,OAAO,EAAE;GAC5F,IAAI,CAAC,WACH,MAAM,IAAI,cAAc,UAAU,IAAI,OAAO,EAAE;GAEjD,OAAO;EACT;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,aAAa;GACb,UAAU;IAAE,KAAK;IAAkB,GAAG,SAAS,KAAK,GAAG;GAAE;EAC3D;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,MAAM,SAAS,MAAM,UAAU,UAAU,MAAM,kBAC7C,MAAM,OACN,IAAI,OAAO,IACX,IAAI,MAAM,IACZ;GACA,IAAI,CAAC,QACH,MAAM,IAAI,cAAc,UAAU,IAAI,OAAO,EAAE;GAEjD,OAAO;EACT;CACF,CAAC;AACH"}
1
+ {"version":3,"file":"course-analytics.js","names":[],"sources":["../../../src/http/routes/course-analytics.ts"],"sourcesContent":["// HTTP routes for per-course analytics (reporting).\nimport type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport {\n CourseAnalytics,\n CourseIdParam,\n CourseIdPathParam,\n CourseLearnersPage,\n CourseLearnersQuery,\n EnrollmentSeries,\n EnrollmentSeriesQuery,\n refusals,\n} from '../schemas/index.js';\nimport type { Container } from '../../app/container.js';\nimport { resolveActor } from '../scope.js';\n\nexport async function courseAnalyticsRoutes(\n app: FastifyInstance,\n container: Container,\n): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n\n r.route({\n method: 'GET',\n url: '/api/courses/:id/analytics',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getCourseAnalytics',\n tags: ['Reporting'],\n summary: 'Completion and engagement stats for a course',\n description: 'Requires `progress:read`.',\n params: CourseIdParam,\n response: { 200: CourseAnalytics, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.reporting.admin.courseAnalytics(req.params.id, actor);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/courses/:id/analytics/enrollments',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getCourseEnrollmentSeries',\n tags: ['Reporting'],\n summary: 'Enrollments granted per day for a course over a trailing window',\n description: 'Requires `progress:read`.',\n params: CourseIdParam,\n querystring: EnrollmentSeriesQuery,\n response: { 200: EnrollmentSeries, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.reporting.admin.courseEnrollments(req.params.id, req.query.days, actor);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/courses/:courseId/learners',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listCourseLearners',\n tags: ['Reporting'],\n summary: \"List a course's learners with their access and progress\",\n description: `Requires \\`progress:read\\`. Everyone with a grant that opens the course, directly or through a bundle, active or not, each with that grant and their progress. Paged and searched by name or email.`,\n params: CourseIdPathParam,\n querystring: CourseLearnersQuery,\n response: { 200: CourseLearnersPage, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.reporting.admin.courseLearners(req.params.courseId, req.query, actor);\n },\n });\n}\n"],"mappings":";;;;;;;;AAgBA,eAAsB,sBACpB,KACA,WACe;CACf,MAAM,IAAI,IAAI,iBAAkC;CAEhD,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,UAAU;IAAE,KAAK;IAAiB,GAAG,SAAS,KAAK,GAAG;GAAE;EAC1D;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,gBAAgB,IAAI,OAAO,IAAI,KAAK;EACvE;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,aAAa;GACb,UAAU;IAAE,KAAK;IAAkB,GAAG,SAAS,KAAK,GAAG;GAAE;EAC3D;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,kBAAkB,IAAI,OAAO,IAAI,IAAI,MAAM,MAAM,KAAK;EACzF;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,aAAa;GACb,UAAU;IAAE,KAAK;IAAoB,GAAG,SAAS,KAAK,GAAG;GAAE;EAC7D;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,eAAe,IAAI,OAAO,UAAU,IAAI,OAAO,KAAK;EACvF;CACF,CAAC;AACH"}
@@ -1,9 +1,8 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveScope } from "../scope.js";
2
+ import { resolveActor } from "../scope.js";
3
3
  import { Course, CourseIdParam, CourseSettings, CoursesPage, CoursesQuery, CreateCourse, PatchCourseSettings, UpdateCourse } from "../schemas/content.js";
4
4
  import "../schemas/index.js";
5
5
  import { z } from "zod";
6
- import { NotFoundError } from "@headless-lms/core/shared/errors";
7
6
  //#region src/http/routes/courses.ts
8
7
  async function coursesRoutes(app, container) {
9
8
  const r = app.withTypeProvider();
@@ -15,8 +14,8 @@ async function coursesRoutes(app, container) {
15
14
  schema: {
16
15
  operationId: "listCourses",
17
16
  tags: ["Content"],
18
- summary: "List courses (staff)",
19
- description: "Staff only (owner, admin, instructor). Every course in the organization, drafts included, filtered by status and category and searched by title.",
17
+ summary: "List courses",
18
+ description: `Requires \`courses:read\`. Every course in the organization, drafts included, filtered by status and category and searched by title.`,
20
19
  querystring: CoursesQuery,
21
20
  response: {
22
21
  200: CoursesPage,
@@ -24,8 +23,8 @@ async function coursesRoutes(app, container) {
24
23
  }
25
24
  },
26
25
  handler: async (req) => {
27
- const scope = await resolveScope(container, req);
28
- return courses.listCourses(scope.orgId, req.query);
26
+ const actor = await resolveActor(container, req);
27
+ return courses.listCourses(req.query, actor);
29
28
  }
30
29
  });
31
30
  r.route({
@@ -35,8 +34,8 @@ async function coursesRoutes(app, container) {
35
34
  schema: {
36
35
  operationId: "getCourse",
37
36
  tags: ["Content"],
38
- summary: "Get a course (staff)",
39
- description: "Staff only (owner, admin, instructor).",
37
+ summary: "Get a course",
38
+ description: "Requires `courses:read`.",
40
39
  params: CourseIdParam,
41
40
  response: {
42
41
  200: Course,
@@ -44,10 +43,8 @@ async function coursesRoutes(app, container) {
44
43
  }
45
44
  },
46
45
  handler: async (req) => {
47
- const scope = await resolveScope(container, req);
48
- const course = await courses.getCourse(scope.orgId, req.params.id);
49
- if (!course) throw new NotFoundError("Course", req.params.id);
50
- return course;
46
+ const actor = await resolveActor(container, req);
47
+ return courses.getCourse(req.params.id, actor);
51
48
  }
52
49
  });
53
50
  r.route({
@@ -57,8 +54,8 @@ async function coursesRoutes(app, container) {
57
54
  schema: {
58
55
  operationId: "createCourse",
59
56
  tags: ["Content"],
60
- summary: "Create a course (staff)",
61
- description: "Staff only (owner, admin, instructor). The course starts as a draft. 409 when its slug is taken.",
57
+ summary: "Create a course",
58
+ description: `Requires \`courses:write\`. The course starts as a draft. 409 when its slug is taken.`,
62
59
  body: CreateCourse,
63
60
  response: {
64
61
  201: Course,
@@ -66,8 +63,8 @@ async function coursesRoutes(app, container) {
66
63
  }
67
64
  },
68
65
  handler: async (req, reply) => {
69
- const scope = await resolveScope(container, req);
70
- const course = await courses.createCourse(scope.orgId, req.body);
66
+ const actor = await resolveActor(container, req);
67
+ const course = await courses.createCourse(req.body, actor);
71
68
  return reply.code(201).send(course);
72
69
  }
73
70
  });
@@ -78,8 +75,8 @@ async function coursesRoutes(app, container) {
78
75
  schema: {
79
76
  operationId: "updateCourse",
80
77
  tags: ["Content"],
81
- summary: "Update a course (staff)",
82
- description: "Staff only (owner, admin, instructor). Changes only the fields sent. `thumbnailAssetId` must be a ready library image (409 otherwise); null clears it.",
78
+ summary: "Update a course",
79
+ description: `Requires \`courses:write\`. Changes only the fields sent. \`thumbnailAssetId\` must be a ready library image (409 otherwise); null clears it. A file it does not already use must be uploaded for it, or the caller needs \`media:read\` (403 otherwise).`,
83
80
  params: CourseIdParam,
84
81
  body: UpdateCourse,
85
82
  response: {
@@ -88,8 +85,8 @@ async function coursesRoutes(app, container) {
88
85
  }
89
86
  },
90
87
  handler: async (req) => {
91
- const scope = await resolveScope(container, req);
92
- return courses.updateCourse(scope.orgId, req.params.id, req.body);
88
+ const actor = await resolveActor(container, req);
89
+ return courses.updateCourse(req.params.id, req.body, actor);
93
90
  }
94
91
  });
95
92
  r.route({
@@ -99,8 +96,8 @@ async function coursesRoutes(app, container) {
99
96
  schema: {
100
97
  operationId: "updateCourseSettings",
101
98
  tags: ["Content"],
102
- summary: "Update a course's settings (staff)",
103
- description: "Staff only (owner, admin, instructor). Merges the settings sent into the current ones.",
99
+ summary: "Update a course's settings",
100
+ description: `Requires \`courses:write\`. Merges the settings sent into the current ones.`,
104
101
  params: CourseIdParam,
105
102
  body: PatchCourseSettings,
106
103
  response: {
@@ -109,9 +106,8 @@ async function coursesRoutes(app, container) {
109
106
  }
110
107
  },
111
108
  handler: async (req) => {
112
- const scope = await resolveScope(container, req);
113
- req.log.info({ body: req.body }, "update course settings");
114
- return courses.patchCourseSettings(scope.orgId, req.params.id, req.body);
109
+ const actor = await resolveActor(container, req);
110
+ return courses.patchCourseSettings(req.params.id, req.body, actor);
115
111
  }
116
112
  });
117
113
  r.route({
@@ -121,8 +117,8 @@ async function coursesRoutes(app, container) {
121
117
  schema: {
122
118
  operationId: "deleteCourse",
123
119
  tags: ["Content"],
124
- summary: "Delete a course (staff)",
125
- description: "Staff only (owner, admin, instructor).",
120
+ summary: "Delete a course",
121
+ description: "Requires `courses:delete`.",
126
122
  params: CourseIdParam,
127
123
  response: {
128
124
  204: z.void(),
@@ -130,11 +126,8 @@ async function coursesRoutes(app, container) {
130
126
  }
131
127
  },
132
128
  handler: async (req, reply) => {
133
- const scope = await resolveScope(container, req);
134
- await courses.deleteCourse(scope.orgId, req.params.id, {
135
- id: scope.orgUserId,
136
- role: scope.role
137
- });
129
+ const actor = await resolveActor(container, req);
130
+ await courses.deleteCourse(req.params.id, actor);
138
131
  return reply.code(204).send();
139
132
  }
140
133
  });
@@ -1 +1 @@
1
- {"version":3,"file":"courses.js","names":[],"sources":["../../../src/http/routes/courses.ts"],"sourcesContent":["// HTTP routes for the courses context. Request + response are validated against\n// the shared contract schemas by the Zod type provider, and @fastify/swagger\n// reads the same schemas to build the OpenAPI spec the SDK is generated from.\nimport type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport {\n Course,\n CourseIdParam,\n CourseSettings,\n CoursesPage,\n CoursesQuery,\n CreateCourse,\n PatchCourseSettings,\n UpdateCourse,\n refusals,\n} from '../schemas/index.js';\nimport { z } from 'zod';\nimport type { Container } from '../../app/container.js';\nimport { NotFoundError } from '@headless-lms/core/shared/errors';\nimport { resolveScope } from '../scope.js';\n\nexport async function coursesRoutes(app: FastifyInstance, container: Container): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n const courses = container.content;\n\n r.route({\n method: 'GET',\n url: '/api/courses',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listCourses',\n tags: ['Content'],\n summary: 'List courses (staff)',\n description:\n 'Staff only (owner, admin, instructor). Every course in the organization, drafts included, filtered by status and category and searched by title.',\n querystring: CoursesQuery,\n response: { 200: CoursesPage, ...refusals(403) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return courses.listCourses(scope.orgId, req.query);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/courses/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getCourse',\n tags: ['Content'],\n summary: 'Get a course (staff)',\n description: 'Staff only (owner, admin, instructor).',\n params: CourseIdParam,\n response: { 200: Course, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n const course = await courses.getCourse(scope.orgId, req.params.id);\n if (!course) {\n throw new NotFoundError('Course', req.params.id);\n }\n return course;\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/courses',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'createCourse',\n tags: ['Content'],\n summary: 'Create a course (staff)',\n description:\n 'Staff only (owner, admin, instructor). The course starts as a draft. 409 when its slug is taken.',\n body: CreateCourse,\n response: { 201: Course, ...refusals(403, 409) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n const course = await courses.createCourse(scope.orgId, req.body);\n return reply.code(201).send(course);\n },\n });\n\n r.route({\n method: 'PATCH',\n url: '/api/courses/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'updateCourse',\n tags: ['Content'],\n summary: 'Update a course (staff)',\n description:\n 'Staff only (owner, admin, instructor). Changes only the fields sent. `thumbnailAssetId` must be a ready library image (409 otherwise); null clears it.',\n params: CourseIdParam,\n body: UpdateCourse,\n response: { 200: Course, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return courses.updateCourse(scope.orgId, req.params.id, req.body);\n },\n });\n\n r.route({\n method: 'PATCH',\n url: '/api/courses/:id/settings',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'updateCourseSettings',\n tags: ['Content'],\n summary: \"Update a course's settings (staff)\",\n description:\n 'Staff only (owner, admin, instructor). Merges the settings sent into the current ones.',\n params: CourseIdParam,\n body: PatchCourseSettings,\n response: { 200: CourseSettings, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n req.log.info({ body: req.body }, 'update course settings');\n return courses.patchCourseSettings(scope.orgId, req.params.id, req.body);\n },\n });\n\n r.route({\n method: 'DELETE',\n url: '/api/courses/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'deleteCourse',\n tags: ['Content'],\n summary: 'Delete a course (staff)',\n description: 'Staff only (owner, admin, instructor).',\n params: CourseIdParam,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n await courses.deleteCourse(scope.orgId, req.params.id, {\n id: scope.orgUserId,\n role: scope.role,\n });\n return reply.code(204).send();\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"courses.js","names":[],"sources":["../../../src/http/routes/courses.ts"],"sourcesContent":["// HTTP routes for the courses context. Request + response are validated against\n// the shared contract schemas by the Zod type provider, and @fastify/swagger\n// reads the same schemas to build the OpenAPI spec the SDK is generated from.\nimport type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport {\n Course,\n CourseIdParam,\n CourseSettings,\n CoursesPage,\n CoursesQuery,\n CreateCourse,\n PatchCourseSettings,\n UpdateCourse,\n refusals,\n} from '../schemas/index.js';\nimport { z } from 'zod';\nimport type { Container } from '../../app/container.js';\nimport { resolveActor } from '../scope.js';\n\nexport async function coursesRoutes(app: FastifyInstance, container: Container): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n const courses = container.content;\n\n r.route({\n method: 'GET',\n url: '/api/courses',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listCourses',\n tags: ['Content'],\n summary: 'List courses',\n description: `Requires \\`courses:read\\`. Every course in the organization, drafts included, filtered by status and category and searched by title.`,\n querystring: CoursesQuery,\n response: { 200: CoursesPage, ...refusals(403) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return courses.listCourses(req.query, actor);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/courses/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getCourse',\n tags: ['Content'],\n summary: 'Get a course',\n description: 'Requires `courses:read`.',\n params: CourseIdParam,\n response: { 200: Course, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return courses.getCourse(req.params.id, actor);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/courses',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'createCourse',\n tags: ['Content'],\n summary: 'Create a course',\n description: `Requires \\`courses:write\\`. The course starts as a draft. 409 when its slug is taken.`,\n body: CreateCourse,\n response: { 201: Course, ...refusals(403, 409) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n const course = await courses.createCourse(req.body, actor);\n return reply.code(201).send(course);\n },\n });\n\n r.route({\n method: 'PATCH',\n url: '/api/courses/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'updateCourse',\n tags: ['Content'],\n summary: 'Update a course',\n description: `Requires \\`courses:write\\`. Changes only the fields sent. \\`thumbnailAssetId\\` must be a ready library image (409 otherwise); null clears it. A file it does not already use must be uploaded for it, or the caller needs \\`media:read\\` (403 otherwise).`,\n params: CourseIdParam,\n body: UpdateCourse,\n response: { 200: Course, ...refusals(403, 404, 409) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return courses.updateCourse(req.params.id, req.body, actor);\n },\n });\n\n r.route({\n method: 'PATCH',\n url: '/api/courses/:id/settings',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'updateCourseSettings',\n tags: ['Content'],\n summary: \"Update a course's settings\",\n description: `Requires \\`courses:write\\`. Merges the settings sent into the current ones.`,\n params: CourseIdParam,\n body: PatchCourseSettings,\n response: { 200: CourseSettings, ...refusals(403, 404) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return courses.patchCourseSettings(req.params.id, req.body, actor);\n },\n });\n\n r.route({\n method: 'DELETE',\n url: '/api/courses/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'deleteCourse',\n tags: ['Content'],\n summary: 'Delete a course',\n description: 'Requires `courses:delete`.',\n params: CourseIdParam,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const actor = await resolveActor(container, req);\n await courses.deleteCourse(req.params.id, actor);\n return reply.code(204).send();\n },\n });\n}\n"],"mappings":";;;;;;AAoBA,eAAsB,cAAc,KAAsB,WAAqC;CAC7F,MAAM,IAAI,IAAI,iBAAkC;CAChD,MAAM,UAAU,UAAU;CAE1B,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,SAAS;GAChB,SAAS;GACT,aAAa;GACb,aAAa;GACb,UAAU;IAAE,KAAK;IAAa,GAAG,SAAS,GAAG;GAAE;EACjD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,QAAQ,YAAY,IAAI,OAAO,KAAK;EAC7C;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,SAAS;GAChB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,UAAU;IAAE,KAAK;IAAQ,GAAG,SAAS,KAAK,GAAG;GAAE;EACjD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,QAAQ,UAAU,IAAI,OAAO,IAAI,KAAK;EAC/C;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,SAAS;GAChB,SAAS;GACT,aAAa;GACb,MAAM;GACN,UAAU;IAAE,KAAK;IAAQ,GAAG,SAAS,KAAK,GAAG;GAAE;EACjD;EACA,SAAS,OAAO,KAAK,UAAU;GAC7B,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,MAAM,SAAS,MAAM,QAAQ,aAAa,IAAI,MAAM,KAAK;GACzD,OAAO,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,MAAM;EACpC;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,SAAS;GAChB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,MAAM;GACN,UAAU;IAAE,KAAK;IAAQ,GAAG,SAAS,KAAK,KAAK,GAAG;GAAE;EACtD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,QAAQ,aAAa,IAAI,OAAO,IAAI,IAAI,MAAM,KAAK;EAC5D;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,SAAS;GAChB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,MAAM;GACN,UAAU;IAAE,KAAK;IAAgB,GAAG,SAAS,KAAK,GAAG;GAAE;EACzD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,QAAQ,oBAAoB,IAAI,OAAO,IAAI,IAAI,MAAM,KAAK;EACnE;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,SAAS;GAChB,SAAS;GACT,aAAa;GACb,QAAQ;GACR,UAAU;IAAE,KAAK,EAAE,KAAK;IAAG,GAAG,SAAS,KAAK,GAAG;GAAE;EACnD;EACA,SAAS,OAAO,KAAK,UAAU;GAC7B,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,MAAM,QAAQ,aAAa,IAAI,OAAO,IAAI,KAAK;GAC/C,OAAO,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK;EAC9B;CACF,CAAC;AACH"}
@@ -1,5 +1,5 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveScope } from "../scope.js";
2
+ import { resolveActor } from "../scope.js";
3
3
  import { EnrollmentSeries, EnrollmentSeriesQuery, OverviewStats } from "../schemas/dashboard.js";
4
4
  import "../schemas/index.js";
5
5
  //#region src/http/routes/dashboard.ts
@@ -12,16 +12,16 @@ async function dashboardRoutes(app, container) {
12
12
  schema: {
13
13
  operationId: "getOverview",
14
14
  tags: ["Reporting"],
15
- summary: "Back-office overview stats (staff)",
16
- description: "Staff only (owner, admin, instructor).",
15
+ summary: "Back-office overview stats",
16
+ description: "Requires `analytics:read`.",
17
17
  response: {
18
18
  200: OverviewStats,
19
19
  ...refusals(403)
20
20
  }
21
21
  },
22
22
  handler: async (req) => {
23
- const scope = await resolveScope(container, req);
24
- return container.reporting.admin.overview(scope.orgId);
23
+ const actor = await resolveActor(container, req);
24
+ return container.reporting.admin.overview(actor);
25
25
  }
26
26
  });
27
27
  r.route({
@@ -31,8 +31,8 @@ async function dashboardRoutes(app, container) {
31
31
  schema: {
32
32
  operationId: "getEnrollmentSeries",
33
33
  tags: ["Reporting"],
34
- summary: "Enrollments granted per day over a trailing window (staff)",
35
- description: "Staff only (owner, admin, instructor).",
34
+ summary: "Enrollments granted per day over a trailing window",
35
+ description: "Requires `analytics:read`.",
36
36
  querystring: EnrollmentSeriesQuery,
37
37
  response: {
38
38
  200: EnrollmentSeries,
@@ -40,8 +40,8 @@ async function dashboardRoutes(app, container) {
40
40
  }
41
41
  },
42
42
  handler: async (req) => {
43
- const scope = await resolveScope(container, req);
44
- return container.reporting.admin.enrollments(scope.orgId, req.query.days);
43
+ const actor = await resolveActor(container, req);
44
+ return container.reporting.admin.enrollments(req.query.days, actor);
45
45
  }
46
46
  });
47
47
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard.js","names":[],"sources":["../../../src/http/routes/dashboard.ts"],"sourcesContent":["// HTTP routes for the dashboard (overview) context.\nimport type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport {\n EnrollmentSeries,\n EnrollmentSeriesQuery,\n OverviewStats,\n refusals,\n} from '../schemas/index.js';\nimport type { Container } from '../../app/container.js';\nimport { resolveScope } from '../scope.js';\n\nexport async function dashboardRoutes(app: FastifyInstance, container: Container): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n\n r.route({\n method: 'GET',\n url: '/api/overview',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getOverview',\n tags: ['Reporting'],\n summary: 'Back-office overview stats (staff)',\n description: 'Staff only (owner, admin, instructor).',\n response: { 200: OverviewStats, ...refusals(403) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.reporting.admin.overview(scope.orgId);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/overview/enrollments',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getEnrollmentSeries',\n tags: ['Reporting'],\n summary: 'Enrollments granted per day over a trailing window (staff)',\n description: 'Staff only (owner, admin, instructor).',\n querystring: EnrollmentSeriesQuery,\n response: { 200: EnrollmentSeries, ...refusals(403) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.reporting.admin.enrollments(scope.orgId, req.query.days);\n },\n });\n}\n"],"mappings":";;;;;AAYA,eAAsB,gBAAgB,KAAsB,WAAqC;CAC/F,MAAM,IAAI,IAAI,iBAAkC;CAEhD,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,UAAU;IAAE,KAAK;IAAe,GAAG,SAAS,GAAG;GAAE;EACnD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,SAAS,MAAM,KAAK;EACvD;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,aAAa;GACb,UAAU;IAAE,KAAK;IAAkB,GAAG,SAAS,GAAG;GAAE;EACtD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,YAAY,MAAM,OAAO,IAAI,MAAM,IAAI;EAC1E;CACF,CAAC;AACH"}
1
+ {"version":3,"file":"dashboard.js","names":[],"sources":["../../../src/http/routes/dashboard.ts"],"sourcesContent":["// HTTP routes for the dashboard (overview) context.\nimport type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport {\n EnrollmentSeries,\n EnrollmentSeriesQuery,\n OverviewStats,\n refusals,\n} from '../schemas/index.js';\nimport type { Container } from '../../app/container.js';\nimport { resolveActor } from '../scope.js';\n\nexport async function dashboardRoutes(app: FastifyInstance, container: Container): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n\n r.route({\n method: 'GET',\n url: '/api/overview',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getOverview',\n tags: ['Reporting'],\n summary: 'Back-office overview stats',\n description: 'Requires `analytics:read`.',\n response: { 200: OverviewStats, ...refusals(403) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.reporting.admin.overview(actor);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/overview/enrollments',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getEnrollmentSeries',\n tags: ['Reporting'],\n summary: 'Enrollments granted per day over a trailing window',\n description: 'Requires `analytics:read`.',\n querystring: EnrollmentSeriesQuery,\n response: { 200: EnrollmentSeries, ...refusals(403) },\n },\n handler: async (req) => {\n const actor = await resolveActor(container, req);\n return container.reporting.admin.enrollments(req.query.days, actor);\n },\n });\n}\n"],"mappings":";;;;;AAYA,eAAsB,gBAAgB,KAAsB,WAAqC;CAC/F,MAAM,IAAI,IAAI,iBAAkC;CAEhD,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,UAAU;IAAE,KAAK;IAAe,GAAG,SAAS,GAAG;GAAE;EACnD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,SAAS,KAAK;EACjD;CACF,CAAC;CAED,EAAE,MAAM;EACN,QAAQ;EACR,KAAK;EACL,YAAY,IAAI;EAChB,QAAQ;GACN,aAAa;GACb,MAAM,CAAC,WAAW;GAClB,SAAS;GACT,aAAa;GACb,aAAa;GACb,UAAU;IAAE,KAAK;IAAkB,GAAG,SAAS,GAAG;GAAE;EACtD;EACA,SAAS,OAAO,QAAQ;GACtB,MAAM,QAAQ,MAAM,aAAa,WAAW,GAAG;GAC/C,OAAO,UAAU,UAAU,MAAM,YAAY,IAAI,MAAM,MAAM,KAAK;EACpE;CACF,CAAC;AACH"}
@@ -1,25 +1,20 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveScope } from "../scope.js";
2
+ import { resolveActor } from "../scope.js";
3
3
  import { Comment, CommentIdParam, CommentView, CommentsPage, CommentsQuery, PatchComment, ReplyToComment } from "../schemas/discussion.js";
4
4
  import "../schemas/index.js";
5
5
  import { z } from "zod";
6
- import { NotFoundError } from "@headless-lms/core/shared/errors";
7
6
  //#region src/http/routes/discussion.ts
8
7
  async function discussionRoutes(app, container) {
9
8
  const r = app.withTypeProvider();
10
9
  const discussion = container.discussion;
11
- const staff = async (req) => {
12
- await resolveScope(container, req);
13
- };
14
10
  r.route({
15
11
  method: "GET",
16
12
  url: "/api/comments",
17
- preHandler: staff,
18
13
  schema: {
19
14
  operationId: "listComments",
20
15
  tags: ["Discussion"],
21
- summary: "List course comments for moderation (staff)",
22
- description: "Staff only (owner, admin, instructor). Comments on course activities, filtered by course, activity, author, status or open reports; each row carries the author's email and the open reports.",
16
+ summary: "List course comments for moderation",
17
+ description: `Requires \`courses:moderate\`. Comments on the activities of the courses you moderate, filtered by course, activity, author, status or open reports; each row carries the author's email and the open reports.`,
23
18
  querystring: CommentsQuery,
24
19
  response: {
25
20
  200: CommentsPage,
@@ -27,18 +22,18 @@ async function discussionRoutes(app, container) {
27
22
  }
28
23
  },
29
24
  handler: async (req) => {
30
- return discussion.listComments(req.orgId, req.query);
25
+ const actor = await resolveActor(container, req);
26
+ return discussion.listComments(req.query, actor);
31
27
  }
32
28
  });
33
29
  r.route({
34
30
  method: "DELETE",
35
31
  url: "/api/comments/:commentId",
36
- preHandler: staff,
37
32
  schema: {
38
33
  operationId: "moderateRemoveComment",
39
34
  tags: ["Discussion"],
40
- summary: "Remove a course comment (staff)",
41
- description: "Staff only (owner, admin, instructor). Hides the comment and resolves its open reports as `removed`. Removing a removed comment returns it unchanged.",
35
+ summary: "Remove a course comment",
36
+ description: "Needs `courses:moderate` on the course, unless it is your own comment. Hides the comment and resolves its open reports as `removed`. Removing a removed comment returns it unchanged.",
42
37
  params: CommentIdParam,
43
38
  response: {
44
39
  200: CommentView,
@@ -46,19 +41,18 @@ async function discussionRoutes(app, container) {
46
41
  }
47
42
  },
48
43
  handler: async (req) => {
49
- const orgUser = await container.organizations.getOrgUser(req.orgId, req.userId);
50
- return discussion.remove(req.orgId, req.params.commentId, orgUser, null);
44
+ const actor = await resolveActor(container, req);
45
+ return discussion.remove(req.params.commentId, "lesson", actor);
51
46
  }
52
47
  });
53
48
  r.route({
54
49
  method: "PATCH",
55
50
  url: "/api/comments/:commentId",
56
- preHandler: staff,
57
51
  schema: {
58
52
  operationId: "publishComment",
59
53
  tags: ["Discussion"],
60
- summary: "Publish a course comment (staff)",
61
- description: "Staff only (owner, admin, instructor). Approves a pending comment or restores a removed one; a published comment is returned unchanged.",
54
+ summary: "Publish a course comment",
55
+ description: "Needs `courses:moderate` on the course. Approves a pending comment or restores a removed one; a published comment is returned unchanged.",
62
56
  params: CommentIdParam,
63
57
  body: PatchComment,
64
58
  response: {
@@ -67,20 +61,18 @@ async function discussionRoutes(app, container) {
67
61
  }
68
62
  },
69
63
  handler: async (req) => {
70
- const orgUser = await container.organizations.getOrgUser(req.orgId, req.userId);
71
- if (!orgUser) throw new NotFoundError("OrgUser", req.userId);
72
- return discussion.publish(req.orgId, req.params.commentId, orgUser, null);
64
+ const actor = await resolveActor(container, req);
65
+ return discussion.publish(req.params.commentId, "lesson", actor);
73
66
  }
74
67
  });
75
68
  r.route({
76
69
  method: "DELETE",
77
70
  url: "/api/comments/:commentId/reports",
78
- preHandler: staff,
79
71
  schema: {
80
72
  operationId: "dismissCommentReports",
81
73
  tags: ["Discussion"],
82
- summary: "Dismiss every open report on a course comment (staff)",
83
- description: "Staff only (owner, admin, instructor). The comment stays up.",
74
+ summary: "Dismiss every open report on a course comment",
75
+ description: "Needs `courses:moderate` on the course. The comment stays up.",
84
76
  params: CommentIdParam,
85
77
  response: {
86
78
  204: z.void(),
@@ -88,21 +80,19 @@ async function discussionRoutes(app, container) {
88
80
  }
89
81
  },
90
82
  handler: async (req, reply) => {
91
- const orgUser = await container.organizations.getOrgUser(req.orgId, req.userId);
92
- if (!orgUser) throw new NotFoundError("OrgUser", req.userId);
93
- await discussion.resolveReports(req.orgId, req.params.commentId, orgUser, null);
83
+ const actor = await resolveActor(container, req);
84
+ await discussion.resolveReports(req.params.commentId, "lesson", actor);
94
85
  return reply.code(204).send();
95
86
  }
96
87
  });
97
88
  r.route({
98
89
  method: "POST",
99
90
  url: "/api/comments/:commentId/replies",
100
- preHandler: staff,
101
91
  schema: {
102
92
  operationId: "replyToComment",
103
93
  tags: ["Discussion"],
104
- summary: "Reply to a course comment from the moderation queue (staff)",
105
- description: "Staff only (owner, admin, instructor). Replies nest one level: answering a reply attaches to its root comment.",
94
+ summary: "Reply to a course comment from the moderation queue",
95
+ description: `Requires \`courses:moderate\`. Needed on the comment's course. Replies nest one level: answering a reply attaches to its root comment.`,
106
96
  params: CommentIdParam,
107
97
  body: ReplyToComment,
108
98
  response: {
@@ -111,16 +101,8 @@ async function discussionRoutes(app, container) {
111
101
  }
112
102
  },
113
103
  handler: async (req) => {
114
- const orgUser = await container.organizations.getOrgUser(req.orgId, req.userId);
115
- if (!orgUser) throw new NotFoundError("OrgUser", req.userId);
116
- const parent = await discussion.getComment(req.orgId, req.params.commentId);
117
- if (!parent || parent.activityId === null) throw new NotFoundError("Comment", req.params.commentId);
118
- return discussion.postComment(req.orgId, {
119
- actor: orgUser,
120
- activityId: parent.activityId,
121
- parentId: parent.parentId ?? parent.id,
122
- body: req.body.body
123
- });
104
+ const actor = await resolveActor(container, req);
105
+ return discussion.replyToComment(req.params.commentId, req.body.body, actor);
124
106
  }
125
107
  });
126
108
  }