@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,16 +1,28 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { 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
+ import { NotificationCategoryDefaults, NotificationCategoryList, NotificationChannelInfos, NotificationListScopeQuery, NotificationTypeInfos, ReplaceNotificationCategories } from "../schemas/notifications.js";
6
7
  import "../schemas/index.js";
7
8
  import { z } from "zod";
8
- import { NotFoundError } from "@headless-lms/core/shared/errors";
9
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
+ }
10
20
  async function organizationsRoutes(app, container) {
11
21
  const r = app.withTypeProvider();
12
22
  const organizations = container.organizations;
13
23
  const tags = ["Organizations"];
24
+ const peopleTags = ["People"];
25
+ const roleTags = ["Roles"];
14
26
  r.route({
15
27
  method: "POST",
16
28
  url: "/api/organizations",
@@ -24,7 +36,7 @@ async function organizationsRoutes(app, container) {
24
36
  response: { 201: Organization }
25
37
  },
26
38
  handler: async (req, reply) => {
27
- const org = await container.orgProvider.createOrganization(req.headers, req.body);
39
+ const org = await organizations.createOrganization(req.body, req.headers);
28
40
  return reply.code(201).send({
29
41
  name: org.name,
30
42
  slug: org.slug,
@@ -40,102 +52,272 @@ async function organizationsRoutes(app, container) {
40
52
  schema: {
41
53
  operationId: "updateOrganization",
42
54
  tags,
43
- summary: "Update the active organization (staff)",
44
- 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.`,
45
57
  body: UpdateOrganization,
46
58
  response: {
47
59
  200: Organization,
48
60
  ...refusals(403, 409)
49
61
  }
50
62
  },
51
- handler: async (req, reply) => {
52
- const scope = await resolveScope(container, req);
53
- const org = await container.orgProvider.updateOrganization(req.headers, scope.authOrgId, req.body);
54
- 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 {
55
67
  id: org.id,
56
68
  name: org.name,
57
69
  slug: org.slug,
58
70
  createdAt: org.createdAt.toISOString()
59
- });
71
+ };
60
72
  }
61
73
  });
62
74
  r.route({
63
- method: "GET",
64
- url: "/api/organizations/members",
75
+ method: "POST",
76
+ url: "/api/organizations/transfer-ownership",
65
77
  preHandler: app.requireOrgSession,
66
78
  schema: {
67
- operationId: "listMembers",
79
+ operationId: "transferOwnership",
68
80
  tags,
69
- summary: "List the organization's team members (staff)",
70
- description: "Staff only (owner, admin, instructor). The team: everyone in the organization who is not a student.",
71
- 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,
72
84
  response: {
73
- 200: MembersPage,
74
- ...refusals(403)
85
+ 204: z.void(),
86
+ ...refusals(403, 404, 409)
75
87
  }
76
88
  },
77
- handler: async (req) => {
78
- const scope = await resolveScope(container, req);
79
- 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();
80
93
  }
81
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
+ });
82
128
  r.route({
83
129
  method: "PATCH",
84
- url: "/api/organizations/members/:id/role",
130
+ url: "/api/organizations/roles/:key",
85
131
  preHandler: app.requireOrgSession,
86
132
  schema: {
87
- operationId: "updateMemberRole",
88
- tags,
89
- summary: "Change a member's role (staff)",
90
- description: "Owner or admin only. The owner role can be neither granted nor changed (409), and only a moderator can become a student.",
91
- params: MemberIdParam,
92
- 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,
139
+ response: {
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,
93
156
  response: {
94
- 200: MemberRoleChange,
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,
95
175
  ...refusals(403, 404, 409)
96
176
  }
97
177
  },
98
- handler: async (req) => {
99
- const scope = await resolveScope(container, req);
100
- const ctx = {
101
- orgId: scope.orgId,
102
- authOrgId: scope.authOrgId,
103
- headers: req.headers
104
- };
105
- const member = await organizations.updateMemberRole(ctx, {
106
- id: scope.orgUserId,
107
- role: scope.role
108
- }, req.params.id, req.body.role);
109
- if (!member) throw new NotFoundError("Member", req.params.id);
110
- 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();
111
198
  }
112
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
+ });
113
270
  r.route({
114
271
  method: "DELETE",
115
- url: "/api/organizations/members/:id",
272
+ url: "/api/organizations/people/:orgUserId/roles/content/:contentId",
116
273
  preHandler: app.requireOrgSession,
117
274
  schema: {
118
- operationId: "removeMember",
119
- tags,
120
- summary: "Remove an organization member (staff)",
121
- description: "Owner or admin only. Removing a pending member cancels their invite. The owner cannot be removed (409).",
122
- 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,
123
314
  response: {
124
315
  204: z.void(),
125
316
  ...refusals(403, 404, 409)
126
317
  }
127
318
  },
128
319
  handler: async (req, reply) => {
129
- const scope = await resolveScope(container, req);
130
- const ctx = {
131
- orgId: scope.orgId,
132
- authOrgId: scope.authOrgId,
133
- headers: req.headers
134
- };
135
- if (!await organizations.removeMember(ctx, {
136
- id: scope.orgUserId,
137
- role: scope.role
138
- }, req.params.id)) throw new NotFoundError("Member", req.params.id);
320
+ await organizations.removePerson(req.params.orgUserId, await resolveActor(container, req));
139
321
  return reply.code(204).send();
140
322
  }
141
323
  });
@@ -145,38 +327,60 @@ async function organizationsRoutes(app, container) {
145
327
  preHandler: app.requireOrgSession,
146
328
  schema: {
147
329
  operationId: "createInvite",
148
- tags,
149
- summary: "Invite a member or student into the active organization (staff)",
150
- 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).`,
151
333
  body: CreateInvite,
152
334
  response: {
153
335
  201: Invite,
154
- ...refusals(403, 409)
336
+ ...refusals(403, 404, 409)
155
337
  }
156
338
  },
157
339
  handler: async (req, reply) => {
158
- const scope = await resolveScope(container, req);
159
- const invite = await organizations.createInvite({
160
- id: scope.orgUserId,
161
- role: scope.role
162
- }, {
163
- orgId: scope.orgId,
164
- inviterUserId: scope.userId,
165
- email: req.body.email,
166
- role: req.body.role,
167
- ...req.body.firstName !== void 0 && { firstName: req.body.firstName },
168
- ...req.body.lastName !== void 0 && { lastName: req.body.lastName },
169
- sendEmail: req.body.sendEmail
170
- });
171
- req.log.debug({ inviteId: invite.id }, "created invite");
172
- return reply.code(201).send({
173
- id: invite.id,
174
- email: invite.email,
175
- role: invite.role,
176
- status: invite.status,
177
- expiresAt: invite.expiresAt,
178
- createdAt: invite.createdAt
179
- });
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();
180
384
  }
181
385
  });
182
386
  r.route({
@@ -184,7 +388,7 @@ async function organizationsRoutes(app, container) {
184
388
  url: "/api/organizations/invites/:token",
185
389
  schema: {
186
390
  operationId: "getInvite",
187
- tags,
391
+ tags: peopleTags,
188
392
  summary: "Read an invite by its token",
189
393
  description: "Public: no session needed. The same 404 answers forged, expired and accepted tokens, so a probe learns nothing.",
190
394
  params: InviteTokenParam,
@@ -193,16 +397,7 @@ async function organizationsRoutes(app, container) {
193
397
  ...refusals(404)
194
398
  }
195
399
  },
196
- handler: async (req) => {
197
- const invite = await organizations.peekInvite(req.params.token);
198
- if (!invite) throw new NotFoundError("Invite", "token");
199
- const person = await container.identity.getUserByEmail(invite.email);
200
- return {
201
- email: invite.email,
202
- firstName: person?.firstName ?? null,
203
- lastName: person?.lastName ?? null
204
- };
205
- }
400
+ handler: async (req) => organizations.describeInvite(req.params.token)
206
401
  });
207
402
  r.route({
208
403
  method: "POST",
@@ -210,9 +405,9 @@ async function organizationsRoutes(app, container) {
210
405
  preHandler: app.requireSession,
211
406
  schema: {
212
407
  operationId: "acceptInvite",
213
- tags,
408
+ tags: peopleTags,
214
409
  summary: "Accept an invite with the signed-in account",
215
- 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.",
216
411
  body: AcceptInvite,
217
412
  response: {
218
413
  200: AcceptInviteResult,
@@ -220,23 +415,133 @@ async function organizationsRoutes(app, container) {
220
415
  }
221
416
  },
222
417
  handler: async (req) => {
223
- const user = await container.identity.getUserByExternalId(req.authUser.id);
224
- if (!user) throw new NotFoundError("User", req.authUser.id);
225
- const accepted = await container.organizations.acceptInvite({
418
+ await organizations.acceptInvite({
226
419
  token: req.body.token,
227
- userId: user.id,
420
+ authUserId: req.authUser.id,
228
421
  email: req.authUser.email
229
- });
230
- const org = await container.organizations.getById(accepted.orgId);
231
- if (!org) throw new NotFoundError("Organization", accepted.orgId);
232
- const stamped = await container.sessions.stampActiveOrganization(req.headers, org.id);
233
- req.log.info({
234
- accepted,
235
- stamped
236
- }, "accepted invite");
422
+ }, req.headers);
237
423
  return {};
238
424
  }
239
425
  });
426
+ const notificationTags = ["Notifications"];
427
+ r.route({
428
+ method: "GET",
429
+ url: "/api/notification-types",
430
+ preHandler: app.requireOrgSession,
431
+ schema: {
432
+ operationId: "listNotificationTypes",
433
+ tags: notificationTags,
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.`,
436
+ querystring: NotificationListScopeQuery,
437
+ response: {
438
+ 200: NotificationTypeInfos,
439
+ ...refusals(403)
440
+ }
441
+ },
442
+ handler: async (req) => {
443
+ const actor = await resolveActor(container, req);
444
+ return container.notificationCatalog.types(actor, req.query.scope);
445
+ }
446
+ });
447
+ r.route({
448
+ method: "GET",
449
+ url: "/api/notification-channels",
450
+ preHandler: app.requireOrgSession,
451
+ schema: {
452
+ operationId: "listNotificationChannels",
453
+ tags: notificationTags,
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.`,
456
+ response: {
457
+ 200: NotificationChannelInfos,
458
+ ...refusals(403)
459
+ }
460
+ },
461
+ handler: async (req) => {
462
+ const actor = await resolveActor(container, req);
463
+ return container.notificationCatalog.channels(actor);
464
+ }
465
+ });
466
+ r.route({
467
+ method: "GET",
468
+ url: "/api/notification-categories",
469
+ preHandler: app.requireOrgSession,
470
+ schema: {
471
+ operationId: "getOrgNotificationCategories",
472
+ tags: notificationTags,
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.`,
475
+ response: {
476
+ 200: NotificationCategoryList,
477
+ ...refusals(403)
478
+ }
479
+ },
480
+ handler: async (req) => {
481
+ const actor = await resolveActor(container, req);
482
+ return container.notificationCategories.org.get(actor.orgId, actor);
483
+ }
484
+ });
485
+ r.route({
486
+ method: "PUT",
487
+ url: "/api/notification-categories",
488
+ preHandler: app.requireOrgSession,
489
+ schema: {
490
+ operationId: "replaceOrgNotificationCategories",
491
+ tags: notificationTags,
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.`,
494
+ body: ReplaceNotificationCategories,
495
+ response: {
496
+ 200: NotificationCategoryList,
497
+ ...refusals(400, 403, 422)
498
+ }
499
+ },
500
+ handler: async (req) => {
501
+ const actor = await resolveActor(container, req);
502
+ return container.notificationCategories.org.replace(actor.orgId, req.body.categories, actor);
503
+ }
504
+ });
505
+ r.route({
506
+ method: "DELETE",
507
+ url: "/api/notification-categories",
508
+ preHandler: app.requireOrgSession,
509
+ schema: {
510
+ operationId: "resetOrgNotificationCategories",
511
+ tags: notificationTags,
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.`,
514
+ response: {
515
+ 204: z.void(),
516
+ ...refusals(403)
517
+ }
518
+ },
519
+ handler: async (req, reply) => {
520
+ const actor = await resolveActor(container, req);
521
+ await container.notificationCategories.org.reset(actor.orgId, actor);
522
+ return reply.code(204).send();
523
+ }
524
+ });
525
+ r.route({
526
+ method: "GET",
527
+ url: "/api/notification-categories/defaults",
528
+ preHandler: app.requireOrgSession,
529
+ schema: {
530
+ operationId: "getDefaultNotificationCategories",
531
+ tags: notificationTags,
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.`,
534
+ querystring: NotificationListScopeQuery,
535
+ response: {
536
+ 200: NotificationCategoryDefaults,
537
+ ...refusals(403)
538
+ }
539
+ },
540
+ handler: async (req) => {
541
+ const actor = await resolveActor(container, req);
542
+ return { categories: container.notificationCatalog.defaults(actor, req.query.scope) };
543
+ }
544
+ });
240
545
  }
241
546
  //#endregion
242
547
  export { organizationsRoutes };