@headless-lms/server 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 (222) hide show
  1. package/README.md +14 -0
  2. package/dist/app/community-mail.d.ts +5 -3
  3. package/dist/app/community-mail.js +60 -12
  4. package/dist/app/community-mail.js.map +1 -1
  5. package/dist/app/container.d.ts +24 -3
  6. package/dist/app/container.js +102 -38
  7. package/dist/app/container.js.map +1 -1
  8. package/dist/app/notification-channels.js +1 -0
  9. package/dist/app/notification-channels.js.map +1 -1
  10. package/dist/app/notification-reads.d.ts +3 -3
  11. package/dist/app/notification-reads.js +7 -28
  12. package/dist/app/notification-reads.js.map +1 -1
  13. package/dist/app/platform.d.ts +14 -0
  14. package/dist/app/platform.js +14 -0
  15. package/dist/app/platform.js.map +1 -0
  16. package/dist/http/config.d.ts +4 -0
  17. package/dist/http/errors.js +9 -4
  18. package/dist/http/errors.js.map +1 -1
  19. package/dist/http/fastify.d.ts +8 -0
  20. package/dist/http/mcp/catalogue.d.ts +11 -9
  21. package/dist/http/mcp/catalogue.js +15 -21
  22. package/dist/http/mcp/catalogue.js.map +1 -1
  23. package/dist/http/mcp/challenge.js +3 -3
  24. package/dist/http/mcp/challenge.js.map +1 -1
  25. package/dist/http/mcp/route.js +7 -7
  26. package/dist/http/mcp/route.js.map +1 -1
  27. package/dist/http/mcp/test-support.d.ts +6 -5
  28. package/dist/http/mcp/test-support.js +32 -11
  29. package/dist/http/mcp/test-support.js.map +1 -1
  30. package/dist/http/mcp/tool.d.ts +3 -9
  31. package/dist/http/mcp/tool.js +14 -6
  32. package/dist/http/mcp/tool.js.map +1 -1
  33. package/dist/http/mcp/tools/automations.js +10 -20
  34. package/dist/http/mcp/tools/automations.js.map +1 -1
  35. package/dist/http/mcp/tools/catalog.js +25 -42
  36. package/dist/http/mcp/tools/catalog.js.map +1 -1
  37. package/dist/http/mcp/tools/comments.js +9 -19
  38. package/dist/http/mcp/tools/comments.js.map +1 -1
  39. package/dist/http/mcp/tools/communities.js +17 -32
  40. package/dist/http/mcp/tools/communities.js.map +1 -1
  41. package/dist/http/mcp/tools/community-members.js +9 -16
  42. package/dist/http/mcp/tools/community-members.js.map +1 -1
  43. package/dist/http/mcp/tools/community-moderation.js +13 -25
  44. package/dist/http/mcp/tools/community-moderation.js.map +1 -1
  45. package/dist/http/mcp/tools/courses.js +11 -21
  46. package/dist/http/mcp/tools/courses.js.map +1 -1
  47. package/dist/http/mcp/tools/events.d.ts +6 -0
  48. package/dist/http/mcp/tools/events.js +170 -0
  49. package/dist/http/mcp/tools/events.js.map +1 -0
  50. package/dist/http/mcp/tools/index.d.ts +5 -1
  51. package/dist/http/mcp/tools/index.js +45 -10
  52. package/dist/http/mcp/tools/index.js.map +1 -1
  53. package/dist/http/mcp/tools/organization.d.ts +6 -0
  54. package/dist/http/mcp/tools/organization.js +214 -0
  55. package/dist/http/mcp/tools/organization.js.map +1 -0
  56. package/dist/http/mcp/tools/people.js +30 -49
  57. package/dist/http/mcp/tools/people.js.map +1 -1
  58. package/dist/http/plugins/auth.js +37 -8
  59. package/dist/http/plugins/auth.js.map +1 -1
  60. package/dist/http/plugins/error-handler.d.ts +5 -1
  61. package/dist/http/plugins/error-handler.js +10 -2
  62. package/dist/http/plugins/error-handler.js.map +1 -1
  63. package/dist/http/plugins/openapi.js +18 -2
  64. package/dist/http/plugins/openapi.js.map +1 -1
  65. package/dist/http/plugins/platform.d.ts +7 -0
  66. package/dist/http/plugins/platform.js +13 -0
  67. package/dist/http/plugins/platform.js.map +1 -0
  68. package/dist/http/routes/access.js +54 -36
  69. package/dist/http/routes/access.js.map +1 -1
  70. package/dist/http/routes/activities.js +45 -45
  71. package/dist/http/routes/activities.js.map +1 -1
  72. package/dist/http/routes/assets.js +30 -65
  73. package/dist/http/routes/assets.js.map +1 -1
  74. package/dist/http/routes/automations.js +37 -42
  75. package/dist/http/routes/automations.js.map +1 -1
  76. package/dist/http/routes/bundles.js +33 -36
  77. package/dist/http/routes/bundles.js.map +1 -1
  78. package/dist/http/routes/communities.js +93 -145
  79. package/dist/http/routes/communities.js.map +1 -1
  80. package/dist/http/routes/community-moderation.js +83 -137
  81. package/dist/http/routes/community-moderation.js.map +1 -1
  82. package/dist/http/routes/course-analytics.js +32 -15
  83. package/dist/http/routes/course-analytics.js.map +1 -1
  84. package/dist/http/routes/courses.js +25 -32
  85. package/dist/http/routes/courses.js.map +1 -1
  86. package/dist/http/routes/dashboard.js +9 -9
  87. package/dist/http/routes/dashboard.js.map +1 -1
  88. package/dist/http/routes/discussion.js +21 -39
  89. package/dist/http/routes/discussion.js.map +1 -1
  90. package/dist/http/routes/downloads.js +41 -47
  91. package/dist/http/routes/downloads.js.map +1 -1
  92. package/dist/http/routes/entitlements.js +19 -23
  93. package/dist/http/routes/entitlements.js.map +1 -1
  94. package/dist/http/routes/events.d.ts +7 -0
  95. package/dist/http/routes/events.js +333 -0
  96. package/dist/http/routes/events.js.map +1 -0
  97. package/dist/http/routes/integrations.js +40 -73
  98. package/dist/http/routes/integrations.js.map +1 -1
  99. package/dist/http/routes/member/assets.js +10 -13
  100. package/dist/http/routes/member/assets.js.map +1 -1
  101. package/dist/http/routes/member/chats.js +170 -47
  102. package/dist/http/routes/member/chats.js.map +1 -1
  103. package/dist/http/routes/member/comments.js +65 -26
  104. package/dist/http/routes/member/comments.js.map +1 -1
  105. package/dist/http/routes/member/community.js +45 -52
  106. package/dist/http/routes/member/community.js.map +1 -1
  107. package/dist/http/routes/member/courses.js +12 -19
  108. package/dist/http/routes/member/courses.js.map +1 -1
  109. package/dist/http/routes/member/devices.js +9 -10
  110. package/dist/http/routes/member/devices.js.map +1 -1
  111. package/dist/http/routes/member/downloads.js +6 -9
  112. package/dist/http/routes/member/downloads.js.map +1 -1
  113. package/dist/http/routes/member/events.d.ts +7 -0
  114. package/dist/http/routes/member/events.js +386 -0
  115. package/dist/http/routes/member/events.js.map +1 -0
  116. package/dist/http/routes/member/files.js +88 -46
  117. package/dist/http/routes/member/files.js.map +1 -1
  118. package/dist/http/routes/member/index.js +2 -0
  119. package/dist/http/routes/member/index.js.map +1 -1
  120. package/dist/http/routes/member/notification-settings.js +15 -23
  121. package/dist/http/routes/member/notification-settings.js.map +1 -1
  122. package/dist/http/routes/member/notifications.js +16 -16
  123. package/dist/http/routes/member/notifications.js.map +1 -1
  124. package/dist/http/routes/member/org.js +5 -15
  125. package/dist/http/routes/member/org.js.map +1 -1
  126. package/dist/http/routes/member/posts.js +67 -68
  127. package/dist/http/routes/member/posts.js.map +1 -1
  128. package/dist/http/routes/member/profile.js +15 -6
  129. package/dist/http/routes/member/profile.js.map +1 -1
  130. package/dist/http/routes/member/progress.js +7 -11
  131. package/dist/http/routes/member/progress.js.map +1 -1
  132. package/dist/http/routes/organizations.js +319 -135
  133. package/dist/http/routes/organizations.js.map +1 -1
  134. package/dist/http/routes/platform.d.ts +7 -0
  135. package/dist/http/routes/platform.js +61 -0
  136. package/dist/http/routes/platform.js.map +1 -0
  137. package/dist/http/routes/students.js +12 -75
  138. package/dist/http/routes/students.js.map +1 -1
  139. package/dist/http/routes/webhooks.js +30 -39
  140. package/dist/http/routes/webhooks.js.map +1 -1
  141. package/dist/http/routes.js +9 -0
  142. package/dist/http/routes.js.map +1 -1
  143. package/dist/http/schemas/access.d.ts +346 -64
  144. package/dist/http/schemas/access.js +39 -12
  145. package/dist/http/schemas/access.js.map +1 -1
  146. package/dist/http/schemas/assets.d.ts +8 -3
  147. package/dist/http/schemas/assets.js +6 -5
  148. package/dist/http/schemas/assets.js.map +1 -1
  149. package/dist/http/schemas/automations.d.ts +4 -4
  150. package/dist/http/schemas/chats.d.ts +216 -37
  151. package/dist/http/schemas/chats.js +13 -4
  152. package/dist/http/schemas/chats.js.map +1 -1
  153. package/dist/http/schemas/community.d.ts +105 -31
  154. package/dist/http/schemas/community.js +25 -9
  155. package/dist/http/schemas/community.js.map +1 -1
  156. package/dist/http/schemas/course-analytics.d.ts +51 -1
  157. package/dist/http/schemas/course-analytics.js +19 -1
  158. package/dist/http/schemas/course-analytics.js.map +1 -1
  159. package/dist/http/schemas/dashboard.d.ts +1 -0
  160. package/dist/http/schemas/dashboard.js +2 -1
  161. package/dist/http/schemas/dashboard.js.map +1 -1
  162. package/dist/http/schemas/discussion.d.ts +22 -72
  163. package/dist/http/schemas/discussion.js +2 -2
  164. package/dist/http/schemas/discussion.js.map +1 -1
  165. package/dist/http/schemas/entitlements.d.ts +9 -12
  166. package/dist/http/schemas/entitlements.js +11 -5
  167. package/dist/http/schemas/entitlements.js.map +1 -1
  168. package/dist/http/schemas/events.d.ts +527 -0
  169. package/dist/http/schemas/events.js +64 -0
  170. package/dist/http/schemas/events.js.map +1 -0
  171. package/dist/http/schemas/files.d.ts +24 -5
  172. package/dist/http/schemas/files.js +15 -2
  173. package/dist/http/schemas/files.js.map +1 -1
  174. package/dist/http/schemas/index.d.ts +11 -9
  175. package/dist/http/schemas/index.js +11 -9
  176. package/dist/http/schemas/invites.d.ts +3 -47
  177. package/dist/http/schemas/invites.js +4 -25
  178. package/dist/http/schemas/invites.js.map +1 -1
  179. package/dist/http/schemas/member.d.ts +94 -7
  180. package/dist/http/schemas/member.js +7 -3
  181. package/dist/http/schemas/member.js.map +1 -1
  182. package/dist/http/schemas/members.d.ts +329 -65
  183. package/dist/http/schemas/members.js +67 -25
  184. package/dist/http/schemas/members.js.map +1 -1
  185. package/dist/http/schemas/notifications.d.ts +58 -54
  186. package/dist/http/schemas/platform.d.ts +38 -0
  187. package/dist/http/schemas/platform.js +19 -0
  188. package/dist/http/schemas/platform.js.map +1 -0
  189. package/dist/http/schemas/posts.d.ts +55 -35
  190. package/dist/http/schemas/posts.js +4 -4
  191. package/dist/http/schemas/posts.js.map +1 -1
  192. package/dist/http/schemas/profile.d.ts +1 -1
  193. package/dist/http/schemas/shared.d.ts +1 -11
  194. package/dist/http/schemas/shared.js +1 -10
  195. package/dist/http/schemas/shared.js.map +1 -1
  196. package/dist/http/schemas/students.d.ts +22 -0
  197. package/dist/http/schemas/students.js +3 -1
  198. package/dist/http/schemas/students.js.map +1 -1
  199. package/dist/http/scope.d.ts +9 -13
  200. package/dist/http/scope.js +29 -40
  201. package/dist/http/scope.js.map +1 -1
  202. package/dist/http/server.js +9 -0
  203. package/dist/http/server.js.map +1 -1
  204. package/dist/http/shutdown.d.ts +11 -0
  205. package/dist/http/shutdown.js +40 -0
  206. package/dist/http/shutdown.js.map +1 -0
  207. package/dist/http/sse.d.ts +19 -0
  208. package/dist/http/sse.js +104 -0
  209. package/dist/http/sse.js.map +1 -0
  210. package/dist/http/test-caller.d.ts +13 -0
  211. package/dist/http/test-caller.js +14 -0
  212. package/dist/http/test-caller.js.map +1 -0
  213. package/dist/http/well-known.js +2 -2
  214. package/dist/http/well-known.js.map +1 -1
  215. package/dist/index.d.ts +4 -2
  216. package/dist/index.js +4 -2
  217. package/dist/index.js.map +1 -1
  218. package/dist/packages/server/package.js +1 -1
  219. package/package.json +7 -6
  220. package/dist/http/routes/member/actor.d.ts +0 -14
  221. package/dist/http/routes/member/actor.js +0 -11
  222. package/dist/http/routes/member/actor.js.map +0 -1
@@ -1,18 +1,28 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveConfiguratorScope, resolveScope } from "../scope.js";
2
+ import { resolveActor, resolveCaller } from "../scope.js";
3
+ import { AssignmentList, ContentIdParam, CreateRole, MembersPage, MembersQuery, OrgRoleList, OrgRoleView, OrgUserContentParam, OrgUserIdParam, RoleKeyParam, SetRole, TransferOwnership, UpdateRole } from "../schemas/members.js";
3
4
  import { CreateOrganization, Organization, UpdateOrganization } from "../schemas/organizations.js";
4
- import { MemberIdParam, MemberRoleChange, MembersPage, MembersQuery, UpdateMemberRole } from "../schemas/members.js";
5
5
  import { AcceptInvite, AcceptInviteResult, CreateInvite, GetInviteResult, Invite, InviteTokenParam } from "../schemas/invites.js";
6
6
  import { NotificationCategoryDefaults, NotificationCategoryList, NotificationChannelInfos, NotificationListScopeQuery, NotificationTypeInfos, ReplaceNotificationCategories } from "../schemas/notifications.js";
7
7
  import "../schemas/index.js";
8
- import { typesForList } from "@headless-lms/core/shared/notification-categories";
9
8
  import { z } from "zod";
10
- import { NotFoundError } from "@headless-lms/core/shared/errors";
11
9
  //#region src/http/routes/organizations.ts
10
+ function inviteView(invite) {
11
+ return {
12
+ id: invite.id,
13
+ email: invite.email,
14
+ roleKey: invite.roleKey,
15
+ status: invite.status,
16
+ expiresAt: invite.expiresAt,
17
+ createdAt: invite.createdAt
18
+ };
19
+ }
12
20
  async function organizationsRoutes(app, container) {
13
21
  const r = app.withTypeProvider();
14
22
  const organizations = container.organizations;
15
23
  const tags = ["Organizations"];
24
+ const peopleTags = ["People"];
25
+ const roleTags = ["Roles"];
16
26
  r.route({
17
27
  method: "POST",
18
28
  url: "/api/organizations",
@@ -26,7 +36,7 @@ async function organizationsRoutes(app, container) {
26
36
  response: { 201: Organization }
27
37
  },
28
38
  handler: async (req, reply) => {
29
- const org = await container.orgProvider.createOrganization(req.headers, req.body);
39
+ const org = await organizations.createOrganization(req.body, req.headers);
30
40
  return reply.code(201).send({
31
41
  name: org.name,
32
42
  slug: org.slug,
@@ -42,102 +52,272 @@ async function organizationsRoutes(app, container) {
42
52
  schema: {
43
53
  operationId: "updateOrganization",
44
54
  tags,
45
- summary: "Update the active organization (staff)",
46
- description: "Owner or admin. Changes its name or slug; 409 when the slug is taken.",
55
+ summary: "Update the active organization",
56
+ description: `Requires \`organizations:write\`. Changes its name or slug; 409 when the slug is taken.`,
47
57
  body: UpdateOrganization,
48
58
  response: {
49
59
  200: Organization,
50
60
  ...refusals(403, 409)
51
61
  }
52
62
  },
53
- handler: async (req, reply) => {
54
- const scope = await resolveScope(container, req);
55
- const org = await container.orgProvider.updateOrganization(req.headers, scope.authOrgId, req.body);
56
- return reply.send({
63
+ handler: async (req) => {
64
+ const actor = await resolveActor(container, req);
65
+ const org = await organizations.updateOrganization(req.body, actor);
66
+ return {
57
67
  id: org.id,
58
68
  name: org.name,
59
69
  slug: org.slug,
60
70
  createdAt: org.createdAt.toISOString()
61
- });
71
+ };
62
72
  }
63
73
  });
64
74
  r.route({
65
- method: "GET",
66
- url: "/api/organizations/members",
75
+ method: "POST",
76
+ url: "/api/organizations/transfer-ownership",
67
77
  preHandler: app.requireOrgSession,
68
78
  schema: {
69
- operationId: "listMembers",
79
+ operationId: "transferOwnership",
70
80
  tags,
71
- summary: "List the organization's team members (staff)",
72
- description: "Staff only (owner, admin, instructor). The team: everyone in the organization who is not a student.",
73
- querystring: MembersQuery,
81
+ summary: "Transfer ownership of the organization",
82
+ description: "Owner only. The new owner must be an active member. The previous owner takes the named org role, Member by default. 403 for anyone else, including the owner's API keys.",
83
+ body: TransferOwnership,
74
84
  response: {
75
- 200: MembersPage,
76
- ...refusals(403)
85
+ 204: z.void(),
86
+ ...refusals(403, 404, 409)
77
87
  }
78
88
  },
79
- handler: async (req) => {
80
- const scope = await resolveScope(container, req);
81
- return organizations.listMembers(scope.orgId, req.query);
89
+ handler: async (req, reply) => {
90
+ const actor = await resolveActor(container, req);
91
+ await organizations.transferOwnership(req.body, actor);
92
+ return reply.code(204).send();
82
93
  }
83
94
  });
95
+ r.route({
96
+ method: "GET",
97
+ url: "/api/organizations/roles",
98
+ preHandler: app.requireOrgSession,
99
+ schema: {
100
+ operationId: "listRoles",
101
+ tags: roleTags,
102
+ summary: "The organization's roles",
103
+ description: `Requires \`roles:read\`. Each role with its levels, permissions and the number of people who hold it.`,
104
+ response: {
105
+ 200: OrgRoleList,
106
+ ...refusals(403)
107
+ }
108
+ },
109
+ handler: async (req) => organizations.listRoles(await resolveActor(container, req))
110
+ });
111
+ r.route({
112
+ method: "GET",
113
+ url: "/api/organizations/roles/:key",
114
+ preHandler: app.requireOrgSession,
115
+ schema: {
116
+ operationId: "getRole",
117
+ tags: roleTags,
118
+ summary: "Read a role",
119
+ description: "Requires `roles:read`.",
120
+ params: RoleKeyParam,
121
+ response: {
122
+ 200: OrgRoleView,
123
+ ...refusals(403, 404)
124
+ }
125
+ },
126
+ handler: async (req) => organizations.getRole(req.params.key, await resolveActor(container, req))
127
+ });
84
128
  r.route({
85
129
  method: "PATCH",
86
- url: "/api/organizations/members/:id/role",
130
+ url: "/api/organizations/roles/:key",
87
131
  preHandler: app.requireOrgSession,
88
132
  schema: {
89
- operationId: "updateMemberRole",
90
- tags,
91
- summary: "Change a member's role (staff)",
92
- description: "Owner or admin only. The owner role can be neither granted nor changed (409), and only a moderator can become a student.",
93
- params: MemberIdParam,
94
- body: UpdateMemberRole,
133
+ operationId: "updateRole",
134
+ tags: roleTags,
135
+ summary: "Rename or change a role",
136
+ description: `Requires \`roles:write\`. A rename needs nothing else. Changing permissions, levels or the team flag needs every permission the role holds before and after the change (403 names the missing ones). Owner and Member can only be renamed (409). Names are unique in the organization (409).`,
137
+ params: RoleKeyParam,
138
+ body: UpdateRole,
95
139
  response: {
96
- 200: MemberRoleChange,
140
+ 200: OrgRoleView,
141
+ ...refusals(400, 403, 404, 409)
142
+ }
143
+ },
144
+ handler: async (req) => organizations.updateRole(req.params.key, req.body, await resolveActor(container, req))
145
+ });
146
+ r.route({
147
+ method: "POST",
148
+ url: "/api/organizations/roles",
149
+ preHandler: app.requireOrgSession,
150
+ schema: {
151
+ operationId: "createRole",
152
+ tags: roleTags,
153
+ summary: "Add a role",
154
+ description: `Requires \`roles:write\`. The role starts from a deployment role (\`base\`) and needs every permission it holds.`,
155
+ body: CreateRole,
156
+ response: {
157
+ 201: OrgRoleView,
158
+ ...refusals(400, 403, 409)
159
+ }
160
+ },
161
+ handler: async (req, reply) => reply.code(201).send(await organizations.createRole(req.body, await resolveActor(container, req)))
162
+ });
163
+ r.route({
164
+ method: "DELETE",
165
+ url: "/api/organizations/roles/:key/customization",
166
+ preHandler: app.requireOrgSession,
167
+ schema: {
168
+ operationId: "resetRole",
169
+ tags: roleTags,
170
+ summary: "Reset a role to the deployment's version",
171
+ description: `Requires \`roles:write\`. Needs every permission the role holds before and after.`,
172
+ params: RoleKeyParam,
173
+ response: {
174
+ 200: OrgRoleView,
97
175
  ...refusals(403, 404, 409)
98
176
  }
99
177
  },
100
- handler: async (req) => {
101
- const scope = await resolveScope(container, req);
102
- const ctx = {
103
- orgId: scope.orgId,
104
- authOrgId: scope.authOrgId,
105
- headers: req.headers
106
- };
107
- const member = await organizations.updateMemberRole(ctx, {
108
- id: scope.orgUserId,
109
- role: scope.role
110
- }, req.params.id, req.body.role);
111
- if (!member) throw new NotFoundError("Member", req.params.id);
112
- return member;
178
+ handler: async (req) => organizations.resetRole(req.params.key, await resolveActor(container, req))
179
+ });
180
+ r.route({
181
+ method: "DELETE",
182
+ url: "/api/organizations/roles/:key",
183
+ preHandler: app.requireOrgSession,
184
+ schema: {
185
+ operationId: "deleteRole",
186
+ tags: roleTags,
187
+ summary: "Delete a role the organization added",
188
+ description: `Requires \`roles:delete\`. Refused while anyone holds it (409, with the count).`,
189
+ params: RoleKeyParam,
190
+ response: {
191
+ 204: z.void(),
192
+ ...refusals(403, 404, 409)
193
+ }
194
+ },
195
+ handler: async (req, reply) => {
196
+ await organizations.deleteRole(req.params.key, await resolveActor(container, req));
197
+ return reply.code(204).send();
113
198
  }
114
199
  });
200
+ r.route({
201
+ method: "GET",
202
+ url: "/api/organizations/people",
203
+ preHandler: app.requireOrgSession,
204
+ schema: {
205
+ operationId: "listTeam",
206
+ tags: peopleTags,
207
+ summary: "The organization's team",
208
+ description: `Requires \`people:read\`. Everyone with an org role other than Member or a role on a content item, invited people included.`,
209
+ querystring: MembersQuery,
210
+ response: {
211
+ 200: MembersPage,
212
+ ...refusals(403)
213
+ }
214
+ },
215
+ handler: async (req) => organizations.listTeam(req.query, await resolveActor(container, req))
216
+ });
217
+ r.route({
218
+ method: "GET",
219
+ url: "/api/organizations/people/:orgUserId/roles",
220
+ preHandler: app.requireOrgSession,
221
+ schema: {
222
+ operationId: "listPersonRoles",
223
+ tags: peopleTags,
224
+ summary: "A person's roles",
225
+ description: `Requires \`people:read\`. Their org role and every role they hold on a content item.`,
226
+ params: OrgUserIdParam,
227
+ response: {
228
+ 200: AssignmentList,
229
+ ...refusals(403)
230
+ }
231
+ },
232
+ handler: async (req) => organizations.listAssignments(req.params.orgUserId, await resolveActor(container, req))
233
+ });
234
+ r.route({
235
+ method: "PUT",
236
+ url: "/api/organizations/people/:orgUserId/roles/org",
237
+ preHandler: app.requireOrgSession,
238
+ schema: {
239
+ operationId: "setOrgRole",
240
+ tags: peopleTags,
241
+ summary: "Set a person's org role",
242
+ description: `Requires \`people:write\`. Needs every permission the old and the new role hold. Nobody changes their own roles, and the owner's role changes only by transfer.`,
243
+ params: OrgUserIdParam,
244
+ body: SetRole,
245
+ response: {
246
+ 200: AssignmentList,
247
+ ...refusals(403, 404, 409)
248
+ }
249
+ },
250
+ handler: async (req) => organizations.setOrgRole(req.params.orgUserId, req.body.roleKey, await resolveActor(container, req))
251
+ });
252
+ r.route({
253
+ method: "PUT",
254
+ url: "/api/organizations/people/:orgUserId/roles/content/:contentId",
255
+ preHandler: app.requireOrgSession,
256
+ schema: {
257
+ operationId: "assignRole",
258
+ tags: peopleTags,
259
+ summary: "Give a person a role on a content item",
260
+ description: `Requires \`people:write\`. The role must list the item's type as a level. Needs every permission the old and the new role give there.`,
261
+ params: OrgUserContentParam,
262
+ body: SetRole,
263
+ response: {
264
+ 200: AssignmentList,
265
+ ...refusals(403, 404, 409)
266
+ }
267
+ },
268
+ handler: async (req) => organizations.assignRole(req.params.orgUserId, req.params.contentId, req.body.roleKey, await resolveActor(container, req))
269
+ });
115
270
  r.route({
116
271
  method: "DELETE",
117
- url: "/api/organizations/members/:id",
272
+ url: "/api/organizations/people/:orgUserId/roles/content/:contentId",
118
273
  preHandler: app.requireOrgSession,
119
274
  schema: {
120
- operationId: "removeMember",
121
- tags,
122
- summary: "Remove an organization member (staff)",
123
- description: "Owner or admin only. Removing a pending member cancels their invite. The owner cannot be removed (409).",
124
- params: MemberIdParam,
275
+ operationId: "unassignRole",
276
+ tags: peopleTags,
277
+ summary: "Remove a person's role on a content item",
278
+ description: "Requires `people:write`.",
279
+ params: OrgUserContentParam,
280
+ response: {
281
+ 200: AssignmentList,
282
+ ...refusals(403, 404)
283
+ }
284
+ },
285
+ handler: async (req) => organizations.unassignRole(req.params.orgUserId, req.params.contentId, await resolveActor(container, req))
286
+ });
287
+ r.route({
288
+ method: "GET",
289
+ url: "/api/organizations/content/:contentId/roles",
290
+ preHandler: app.requireOrgSession,
291
+ schema: {
292
+ operationId: "listRolesOn",
293
+ tags: peopleTags,
294
+ summary: "The people with a role on a content item",
295
+ description: "Requires `people:read`.",
296
+ params: ContentIdParam,
297
+ response: {
298
+ 200: AssignmentList,
299
+ ...refusals(403)
300
+ }
301
+ },
302
+ handler: async (req) => organizations.listAssignmentsOn(req.params.contentId, await resolveActor(container, req))
303
+ });
304
+ r.route({
305
+ method: "DELETE",
306
+ url: "/api/organizations/people/:orgUserId",
307
+ preHandler: app.requireOrgSession,
308
+ schema: {
309
+ operationId: "removePerson",
310
+ tags: peopleTags,
311
+ summary: "Remove a person from the organization",
312
+ description: `Requires \`people:delete\`. Needs every permission the person holds, where they hold it. Revokes their API keys and connected apps here. The owner can't be removed (409); neither can someone whose posts must be kept (409).`,
313
+ params: OrgUserIdParam,
125
314
  response: {
126
315
  204: z.void(),
127
316
  ...refusals(403, 404, 409)
128
317
  }
129
318
  },
130
319
  handler: async (req, reply) => {
131
- const scope = await resolveScope(container, req);
132
- const ctx = {
133
- orgId: scope.orgId,
134
- authOrgId: scope.authOrgId,
135
- headers: req.headers
136
- };
137
- if (!await organizations.removeMember(ctx, {
138
- id: scope.orgUserId,
139
- role: scope.role
140
- }, req.params.id)) throw new NotFoundError("Member", req.params.id);
320
+ await organizations.removePerson(req.params.orgUserId, await resolveActor(container, req));
141
321
  return reply.code(204).send();
142
322
  }
143
323
  });
@@ -147,38 +327,60 @@ async function organizationsRoutes(app, container) {
147
327
  preHandler: app.requireOrgSession,
148
328
  schema: {
149
329
  operationId: "createInvite",
150
- tags,
151
- summary: "Invite a member or student into the active organization (staff)",
152
- description: "Owner or admin only. A student is created by this call, so their access can be granted before they accept. 409 when a student with that email is already in the organization.",
330
+ tags: peopleTags,
331
+ summary: "Invite a person with an org role",
332
+ description: `Requires \`people:write\`. The person is created by this call, so access and item roles can be given before they accept. Needs every permission the role holds; never \`owner\` (409).`,
153
333
  body: CreateInvite,
154
334
  response: {
155
335
  201: Invite,
156
- ...refusals(403, 409)
336
+ ...refusals(403, 404, 409)
157
337
  }
158
338
  },
159
339
  handler: async (req, reply) => {
160
- const scope = await resolveScope(container, req);
161
- const invite = await organizations.createInvite({
162
- id: scope.orgUserId,
163
- role: scope.role
164
- }, {
165
- orgId: scope.orgId,
166
- inviterUserId: scope.userId,
167
- email: req.body.email,
168
- role: req.body.role,
169
- ...req.body.firstName !== void 0 && { firstName: req.body.firstName },
170
- ...req.body.lastName !== void 0 && { lastName: req.body.lastName },
171
- sendEmail: req.body.sendEmail
172
- });
173
- req.log.debug({ inviteId: invite.id }, "created invite");
174
- return reply.code(201).send({
175
- id: invite.id,
176
- email: invite.email,
177
- role: invite.role,
178
- status: invite.status,
179
- expiresAt: invite.expiresAt,
180
- createdAt: invite.createdAt
181
- });
340
+ const caller = await resolveCaller(container, req);
341
+ const invite = await organizations.createInvite(req.body, caller.actor, caller.userId);
342
+ return reply.code(201).send(inviteView(invite));
343
+ }
344
+ });
345
+ r.route({
346
+ method: "POST",
347
+ url: "/api/organizations/people/:orgUserId/invite/resend",
348
+ preHandler: app.requireOrgSession,
349
+ schema: {
350
+ operationId: "resendInvite",
351
+ tags: peopleTags,
352
+ summary: "Resend a person's pending invite",
353
+ description: `Requires \`people:write\`. Issues a new link; the resender becomes the inviter.`,
354
+ params: OrgUserIdParam,
355
+ response: {
356
+ 204: z.void(),
357
+ ...refusals(403, 404)
358
+ }
359
+ },
360
+ handler: async (req, reply) => {
361
+ const caller = await resolveCaller(container, req);
362
+ await organizations.resendInvite(req.params.orgUserId, caller.actor, caller.userId);
363
+ return reply.code(204).send();
364
+ }
365
+ });
366
+ r.route({
367
+ method: "DELETE",
368
+ url: "/api/organizations/people/:orgUserId/invite",
369
+ preHandler: app.requireOrgSession,
370
+ schema: {
371
+ operationId: "cancelInvite",
372
+ tags: peopleTags,
373
+ summary: "Cancel a person's pending invite",
374
+ description: `Requires \`people:write\`. Someone still invited who holds no access and no item role is removed with it.`,
375
+ params: OrgUserIdParam,
376
+ response: {
377
+ 204: z.void(),
378
+ ...refusals(403, 404)
379
+ }
380
+ },
381
+ handler: async (req, reply) => {
382
+ await organizations.cancelInvite(req.params.orgUserId, await resolveActor(container, req));
383
+ return reply.code(204).send();
182
384
  }
183
385
  });
184
386
  r.route({
@@ -186,7 +388,7 @@ async function organizationsRoutes(app, container) {
186
388
  url: "/api/organizations/invites/:token",
187
389
  schema: {
188
390
  operationId: "getInvite",
189
- tags,
391
+ tags: peopleTags,
190
392
  summary: "Read an invite by its token",
191
393
  description: "Public: no session needed. The same 404 answers forged, expired and accepted tokens, so a probe learns nothing.",
192
394
  params: InviteTokenParam,
@@ -195,16 +397,7 @@ async function organizationsRoutes(app, container) {
195
397
  ...refusals(404)
196
398
  }
197
399
  },
198
- handler: async (req) => {
199
- const invite = await organizations.peekInvite(req.params.token);
200
- if (!invite) throw new NotFoundError("Invite", "token");
201
- const person = await container.identity.getUserByEmail(invite.email);
202
- return {
203
- email: invite.email,
204
- firstName: person?.firstName ?? null,
205
- lastName: person?.lastName ?? null
206
- };
207
- }
400
+ handler: async (req) => organizations.describeInvite(req.params.token)
208
401
  });
209
402
  r.route({
210
403
  method: "POST",
@@ -212,9 +405,9 @@ async function organizationsRoutes(app, container) {
212
405
  preHandler: app.requireSession,
213
406
  schema: {
214
407
  operationId: "acceptInvite",
215
- tags,
408
+ tags: peopleTags,
216
409
  summary: "Accept an invite with the signed-in account",
217
- description: "For any signed-in person whose email matches the invitation. Makes the invite's organization the session's active one. 400 when the token is invalid or expired, or the email does not match.",
410
+ description: "For any signed-in person whose email matches the invitation. Makes the invite's organization the session's active one. 400 when the token is invalid or expired, the email does not match, or the inviter no longer holds the role.",
218
411
  body: AcceptInvite,
219
412
  response: {
220
413
  200: AcceptInviteResult,
@@ -222,20 +415,11 @@ async function organizationsRoutes(app, container) {
222
415
  }
223
416
  },
224
417
  handler: async (req) => {
225
- const user = await container.identity.getUserByExternalId(req.authUser.id);
226
- if (!user) throw new NotFoundError("User", req.authUser.id);
227
- const accepted = await container.organizations.acceptInvite({
418
+ await organizations.acceptInvite({
228
419
  token: req.body.token,
229
- userId: user.id,
420
+ authUserId: req.authUser.id,
230
421
  email: req.authUser.email
231
- });
232
- const org = await container.organizations.getById(accepted.orgId);
233
- if (!org) throw new NotFoundError("Organization", accepted.orgId);
234
- const stamped = await container.sessions.stampActiveOrganization(req.headers, org.id);
235
- req.log.info({
236
- accepted,
237
- stamped
238
- }, "accepted invite");
422
+ }, req.headers);
239
423
  return {};
240
424
  }
241
425
  });
@@ -247,8 +431,8 @@ async function organizationsRoutes(app, container) {
247
431
  schema: {
248
432
  operationId: "listNotificationTypes",
249
433
  tags: notificationTags,
250
- summary: "List the notification types a category list can cover (staff)",
251
- description: "Owner or admin only. `scope=org` lists the types the org list can cover, `scope=community` those a community list can cover. Each type carries all its scopes, so an editor can tell which types can be per space.",
434
+ summary: "List the notification types a category list can cover",
435
+ description: `Requires one of \`notification_categories:read\`, \`communities:read\`. \`scope=org\` lists the types the org list can cover and needs \`notification_categories:read\`; \`scope=community\` lists those a community list can cover and also takes \`communities:read\` on any community. Each type carries all its scopes, so an editor can tell which types can be per space.`,
252
436
  querystring: NotificationListScopeQuery,
253
437
  response: {
254
438
  200: NotificationTypeInfos,
@@ -256,8 +440,8 @@ async function organizationsRoutes(app, container) {
256
440
  }
257
441
  },
258
442
  handler: async (req) => {
259
- await resolveConfiguratorScope(container, req);
260
- return typesForList(container.notificationTypes, req.query.scope);
443
+ const actor = await resolveActor(container, req);
444
+ return container.notificationCatalog.types(actor, req.query.scope);
261
445
  }
262
446
  });
263
447
  r.route({
@@ -267,16 +451,16 @@ async function organizationsRoutes(app, container) {
267
451
  schema: {
268
452
  operationId: "listNotificationChannels",
269
453
  tags: notificationTags,
270
- summary: "List the notification channels this deployment can deliver on (staff)",
271
- description: "Owner or admin only. `inApp` is always first.",
454
+ summary: "List the notification channels this deployment can deliver on",
455
+ description: `Requires one of \`notification_categories:read\`, \`communities:read\`. \`communities:read\` on any community is enough. \`inApp\` is always first.`,
272
456
  response: {
273
457
  200: NotificationChannelInfos,
274
458
  ...refusals(403)
275
459
  }
276
460
  },
277
461
  handler: async (req) => {
278
- await resolveConfiguratorScope(container, req);
279
- return container.notificationChannels.list().map((channel) => ({ channel }));
462
+ const actor = await resolveActor(container, req);
463
+ return container.notificationCatalog.channels(actor);
280
464
  }
281
465
  });
282
466
  r.route({
@@ -286,16 +470,16 @@ async function organizationsRoutes(app, container) {
286
470
  schema: {
287
471
  operationId: "getOrgNotificationCategories",
288
472
  tags: notificationTags,
289
- summary: "Get the organization's notification categories (staff)",
290
- description: "Owner or admin only. The list in use for notifications outside any community, with `source` saying whether it is the default or a stored list.",
473
+ summary: "Get the organization's notification categories",
474
+ description: `Requires \`notification_categories:read\`. The list in use for notifications outside any community, with \`source\` saying whether it is the default or a stored list.`,
291
475
  response: {
292
476
  200: NotificationCategoryList,
293
477
  ...refusals(403)
294
478
  }
295
479
  },
296
480
  handler: async (req) => {
297
- const scope = await resolveConfiguratorScope(container, req);
298
- return container.notificationCategories.org.get(scope.orgId, scope.orgId);
481
+ const actor = await resolveActor(container, req);
482
+ return container.notificationCategories.org.get(actor.orgId, actor);
299
483
  }
300
484
  });
301
485
  r.route({
@@ -305,8 +489,8 @@ async function organizationsRoutes(app, container) {
305
489
  schema: {
306
490
  operationId: "replaceOrgNotificationCategories",
307
491
  tags: notificationTags,
308
- summary: "Replace the organization's notification categories (staff)",
309
- description: "Owner or admin only. Stores the whole list. 422 when a key repeats, a type cannot be in the org list, a reason is not allowed for its type, a channel is not configured, or earlier categories already cover everything a category covers.",
492
+ summary: "Replace the organization's notification categories",
493
+ description: `Requires \`notification_categories:write\`. Stores the whole list. 422 when a key repeats, a type cannot be in the org list, a reason is not allowed for its type, a channel is not configured, or earlier categories already cover everything a category covers.`,
310
494
  body: ReplaceNotificationCategories,
311
495
  response: {
312
496
  200: NotificationCategoryList,
@@ -314,8 +498,8 @@ async function organizationsRoutes(app, container) {
314
498
  }
315
499
  },
316
500
  handler: async (req) => {
317
- const scope = await resolveConfiguratorScope(container, req);
318
- return container.notificationCategories.org.replace(scope.orgId, scope.orgId, req.body.categories);
501
+ const actor = await resolveActor(container, req);
502
+ return container.notificationCategories.org.replace(actor.orgId, req.body.categories, actor);
319
503
  }
320
504
  });
321
505
  r.route({
@@ -325,16 +509,16 @@ async function organizationsRoutes(app, container) {
325
509
  schema: {
326
510
  operationId: "resetOrgNotificationCategories",
327
511
  tags: notificationTags,
328
- summary: "Go back to the default notification categories for the organization (staff)",
329
- description: "Owner or admin only. Removes the stored list; the default applies again.",
512
+ summary: "Go back to the default notification categories for the organization",
513
+ description: `Requires \`notification_categories:write\`. Removes the stored list; the default applies again.`,
330
514
  response: {
331
515
  204: z.void(),
332
516
  ...refusals(403)
333
517
  }
334
518
  },
335
519
  handler: async (req, reply) => {
336
- const scope = await resolveConfiguratorScope(container, req);
337
- await container.notificationCategories.org.reset(scope.orgId, scope.orgId);
520
+ const actor = await resolveActor(container, req);
521
+ await container.notificationCategories.org.reset(actor.orgId, actor);
338
522
  return reply.code(204).send();
339
523
  }
340
524
  });
@@ -345,8 +529,8 @@ async function organizationsRoutes(app, container) {
345
529
  schema: {
346
530
  operationId: "getDefaultNotificationCategories",
347
531
  tags: notificationTags,
348
- summary: "Get the default notification categories (staff)",
349
- description: "Owner or admin only. The default org list (`scope=org`) or community list (`scope=community`), offering only the configured channels.",
532
+ summary: "Get the default notification categories",
533
+ description: `Requires one of \`notification_categories:read\`, \`communities:read\`. The default org list (\`scope=org\`, which needs \`notification_categories:read\`) or community list (\`scope=community\`, which also takes \`communities:read\` on any community), offering only the configured channels.`,
350
534
  querystring: NotificationListScopeQuery,
351
535
  response: {
352
536
  200: NotificationCategoryDefaults,
@@ -354,8 +538,8 @@ async function organizationsRoutes(app, container) {
354
538
  }
355
539
  },
356
540
  handler: async (req) => {
357
- await resolveConfiguratorScope(container, req);
358
- return { categories: (req.query.scope === "org" ? container.notificationCategories.org : container.notificationCategories.community).defaults() };
541
+ const actor = await resolveActor(container, req);
542
+ return { categories: container.notificationCatalog.defaults(actor, req.query.scope) };
359
543
  }
360
544
  });
361
545
  }