@headless-lms/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/dist/assets/events.d.ts +42 -24
  2. package/dist/assets/events.js +1 -1
  3. package/dist/assets/events.js.map +1 -1
  4. package/dist/assets/file-urls.d.ts +20 -0
  5. package/dist/assets/file-urls.js +25 -0
  6. package/dist/assets/file-urls.js.map +1 -0
  7. package/dist/assets/index.d.ts +6 -4
  8. package/dist/assets/index.js +4 -2
  9. package/dist/assets/jobs.d.ts +15 -0
  10. package/dist/assets/jobs.js +30 -0
  11. package/dist/assets/jobs.js.map +1 -0
  12. package/dist/assets/model.d.ts +2 -2
  13. package/dist/assets/ports.d.ts +46 -15
  14. package/dist/assets/service.d.ts +38 -7
  15. package/dist/assets/service.js +287 -38
  16. package/dist/assets/service.js.map +1 -1
  17. package/dist/automations/actions.d.ts +13 -24
  18. package/dist/automations/actions.js +106 -42
  19. package/dist/automations/actions.js.map +1 -1
  20. package/dist/automations/catalog.js +226 -3
  21. package/dist/automations/catalog.js.map +1 -1
  22. package/dist/automations/community-derivations.d.ts +14 -0
  23. package/dist/automations/community-derivations.js +439 -0
  24. package/dist/automations/community-derivations.js.map +1 -0
  25. package/dist/automations/community-mail.d.ts +98 -0
  26. package/dist/automations/community-mail.js +70 -0
  27. package/dist/automations/community-mail.js.map +1 -0
  28. package/dist/automations/events.d.ts +18 -0
  29. package/dist/automations/index.d.ts +13 -4
  30. package/dist/automations/index.js +10 -2
  31. package/dist/automations/model.d.ts +10 -3
  32. package/dist/automations/model.js +15 -2
  33. package/dist/automations/model.js.map +1 -1
  34. package/dist/automations/notifications/debounce.d.ts +25 -0
  35. package/dist/automations/notifications/debounce.js +93 -0
  36. package/dist/automations/notifications/debounce.js.map +1 -0
  37. package/dist/automations/notifications/defaults.d.ts +8 -0
  38. package/dist/automations/notifications/defaults.js +316 -0
  39. package/dist/automations/notifications/defaults.js.map +1 -0
  40. package/dist/automations/notifications/delivery.d.ts +28 -0
  41. package/dist/automations/notifications/delivery.js +88 -0
  42. package/dist/automations/notifications/delivery.js.map +1 -0
  43. package/dist/automations/notifications/processor.d.ts +64 -0
  44. package/dist/automations/notifications/processor.js +270 -0
  45. package/dist/automations/notifications/processor.js.map +1 -0
  46. package/dist/automations/notifications/rules/content.d.ts +6 -0
  47. package/dist/automations/notifications/rules/content.js +39 -0
  48. package/dist/automations/notifications/rules/content.js.map +1 -0
  49. package/dist/automations/notifications/rules/discussion.d.ts +7 -0
  50. package/dist/automations/notifications/rules/discussion.js +678 -0
  51. package/dist/automations/notifications/rules/discussion.js.map +1 -0
  52. package/dist/automations/notifications/rules/entitlements.d.ts +6 -0
  53. package/dist/automations/notifications/rules/entitlements.js +249 -0
  54. package/dist/automations/notifications/rules/entitlements.js.map +1 -0
  55. package/dist/automations/notifications/rules/index.d.ts +6 -0
  56. package/dist/automations/notifications/rules/index.js +15 -0
  57. package/dist/automations/notifications/rules/index.js.map +1 -0
  58. package/dist/automations/notifications/rules/organizations.d.ts +6 -0
  59. package/dist/automations/notifications/rules/organizations.js +28 -0
  60. package/dist/automations/notifications/rules/organizations.js.map +1 -0
  61. package/dist/automations/notifications/rules/recipients.d.ts +32 -0
  62. package/dist/automations/notifications/rules/recipients.js +99 -0
  63. package/dist/automations/notifications/rules/recipients.js.map +1 -0
  64. package/dist/automations/notifications/service.d.ts +27 -0
  65. package/dist/automations/notifications/service.js +45 -0
  66. package/dist/automations/notifications/service.js.map +1 -0
  67. package/dist/automations/notifications/types.d.ts +7 -0
  68. package/dist/automations/notifications/types.js +473 -0
  69. package/dist/automations/notifications/types.js.map +1 -0
  70. package/dist/automations/ports.d.ts +121 -3
  71. package/dist/automations/schedule.d.ts +6 -0
  72. package/dist/automations/schedule.js +41 -0
  73. package/dist/automations/schedule.js.map +1 -0
  74. package/dist/automations/service.d.ts +20 -4
  75. package/dist/automations/service.js +246 -45
  76. package/dist/automations/service.js.map +1 -1
  77. package/dist/content/events.d.ts +712 -50
  78. package/dist/content/events.js +55 -1
  79. package/dist/content/events.js.map +1 -1
  80. package/dist/content/index.d.ts +6 -4
  81. package/dist/content/index.js +3 -1
  82. package/dist/content/model.d.ts +2 -2
  83. package/dist/content/notification-categories.d.ts +36 -0
  84. package/dist/content/notification-categories.js +64 -0
  85. package/dist/content/notification-categories.js.map +1 -0
  86. package/dist/content/ports.d.ts +90 -18
  87. package/dist/content/service.d.ts +57 -18
  88. package/dist/content/service.js +520 -25
  89. package/dist/content/service.js.map +1 -1
  90. package/dist/content/titles.d.ts +22 -0
  91. package/dist/content/titles.js +69 -0
  92. package/dist/content/titles.js.map +1 -0
  93. package/dist/content/types.d.ts +2 -2
  94. package/dist/discussion/authors.d.ts +10 -0
  95. package/dist/discussion/authors.js +23 -0
  96. package/dist/discussion/authors.js.map +1 -0
  97. package/dist/discussion/chats.d.ts +109 -0
  98. package/dist/discussion/chats.js +831 -0
  99. package/dist/discussion/chats.js.map +1 -0
  100. package/dist/discussion/digests.d.ts +24 -0
  101. package/dist/discussion/digests.js +104 -0
  102. package/dist/discussion/digests.js.map +1 -0
  103. package/dist/discussion/events.d.ts +785 -56
  104. package/dist/discussion/events.js +165 -5
  105. package/dist/discussion/events.js.map +1 -1
  106. package/dist/discussion/index.d.ts +12 -3
  107. package/dist/discussion/index.js +9 -1
  108. package/dist/discussion/jobs.d.ts +16 -0
  109. package/dist/discussion/jobs.js +40 -0
  110. package/dist/discussion/jobs.js.map +1 -0
  111. package/dist/discussion/mentions.d.ts +11 -0
  112. package/dist/discussion/mentions.js +35 -0
  113. package/dist/discussion/mentions.js.map +1 -0
  114. package/dist/discussion/ports.d.ts +372 -28
  115. package/dist/discussion/ports.js +6 -1
  116. package/dist/discussion/ports.js.map +1 -0
  117. package/dist/discussion/posts.d.ts +113 -0
  118. package/dist/discussion/posts.js +775 -0
  119. package/dist/discussion/posts.js.map +1 -0
  120. package/dist/discussion/service.d.ts +42 -11
  121. package/dist/discussion/service.js +364 -85
  122. package/dist/discussion/service.js.map +1 -1
  123. package/dist/discussion/subscriptions.d.ts +37 -0
  124. package/dist/discussion/subscriptions.js +99 -0
  125. package/dist/discussion/subscriptions.js.map +1 -0
  126. package/dist/discussion/titles.d.ts +26 -0
  127. package/dist/discussion/titles.js +84 -0
  128. package/dist/discussion/titles.js.map +1 -0
  129. package/dist/entitlements/events.d.ts +178 -11
  130. package/dist/entitlements/events.js +28 -1
  131. package/dist/entitlements/events.js.map +1 -1
  132. package/dist/entitlements/index.d.ts +6 -4
  133. package/dist/entitlements/index.js +3 -1
  134. package/dist/entitlements/jobs.d.ts +12 -0
  135. package/dist/entitlements/jobs.js +18 -0
  136. package/dist/entitlements/jobs.js.map +1 -0
  137. package/dist/entitlements/model.d.ts +3 -2
  138. package/dist/entitlements/ports.d.ts +128 -11
  139. package/dist/entitlements/rules.d.ts +12 -0
  140. package/dist/entitlements/rules.js +17 -0
  141. package/dist/entitlements/rules.js.map +1 -0
  142. package/dist/entitlements/service.d.ts +57 -3
  143. package/dist/entitlements/service.js +492 -2
  144. package/dist/entitlements/service.js.map +1 -1
  145. package/dist/identity/access.d.ts +6 -0
  146. package/dist/identity/access.js +7 -0
  147. package/dist/identity/access.js.map +1 -0
  148. package/dist/identity/devices.d.ts +30 -0
  149. package/dist/identity/devices.js +107 -0
  150. package/dist/identity/devices.js.map +1 -0
  151. package/dist/identity/events.d.ts +2 -2
  152. package/dist/identity/index.d.ts +7 -4
  153. package/dist/identity/index.js +4 -1
  154. package/dist/identity/model.d.ts +2 -1
  155. package/dist/identity/notification-settings.d.ts +28 -0
  156. package/dist/identity/notification-settings.js +113 -0
  157. package/dist/identity/notification-settings.js.map +1 -0
  158. package/dist/identity/ports.d.ts +97 -3
  159. package/dist/identity/service.d.ts +14 -3
  160. package/dist/identity/service.js +62 -5
  161. package/dist/identity/service.js.map +1 -1
  162. package/dist/integrations/events.d.ts +3 -3
  163. package/dist/integrations/model.js +1 -1
  164. package/dist/integrations/model.js.map +1 -1
  165. package/dist/integrations/service.js +2 -2
  166. package/dist/integrations/service.js.map +1 -1
  167. package/dist/organizations/access-scopes.d.ts +19 -0
  168. package/dist/organizations/access-scopes.js +89 -0
  169. package/dist/organizations/access-scopes.js.map +1 -0
  170. package/dist/organizations/events.d.ts +75 -33
  171. package/dist/organizations/events.js +6 -0
  172. package/dist/organizations/events.js.map +1 -1
  173. package/dist/organizations/index.d.ts +8 -6
  174. package/dist/organizations/index.js +5 -3
  175. package/dist/organizations/members.d.ts +8 -5
  176. package/dist/organizations/members.js.map +1 -1
  177. package/dist/organizations/notification-categories.d.ts +32 -0
  178. package/dist/organizations/notification-categories.js +51 -0
  179. package/dist/organizations/notification-categories.js.map +1 -0
  180. package/dist/organizations/ports.d.ts +17 -11
  181. package/dist/organizations/roles.d.ts +6 -10
  182. package/dist/organizations/roles.js +19 -7
  183. package/dist/organizations/roles.js.map +1 -1
  184. package/dist/organizations/service.d.ts +22 -11
  185. package/dist/organizations/service.js +164 -47
  186. package/dist/organizations/service.js.map +1 -1
  187. package/dist/organizations/types.d.ts +1 -10
  188. package/dist/progress/events.d.ts +14 -14
  189. package/dist/progress/index.d.ts +2 -2
  190. package/dist/progress/ports.d.ts +6 -1
  191. package/dist/progress/service.d.ts +3 -1
  192. package/dist/progress/service.js +6 -2
  193. package/dist/progress/service.js.map +1 -1
  194. package/dist/reporting/admin/index.d.ts +4 -0
  195. package/dist/reporting/admin/index.js +2 -0
  196. package/dist/reporting/admin/model.d.ts +108 -0
  197. package/dist/reporting/admin/ports.d.ts +27 -0
  198. package/dist/reporting/admin/service.d.ts +22 -0
  199. package/dist/reporting/admin/service.js +38 -0
  200. package/dist/reporting/admin/service.js.map +1 -0
  201. package/dist/reporting/member/index.d.ts +5 -0
  202. package/dist/reporting/member/index.js +2 -0
  203. package/dist/reporting/member/model.d.ts +157 -0
  204. package/dist/reporting/member/ports.d.ts +48 -0
  205. package/dist/reporting/member/service.d.ts +92 -0
  206. package/dist/reporting/member/service.js +364 -0
  207. package/dist/reporting/member/service.js.map +1 -0
  208. package/dist/shared/actor.d.ts +9 -0
  209. package/dist/shared/errors.d.ts +19 -1
  210. package/dist/shared/errors.js +27 -1
  211. package/dist/shared/errors.js.map +1 -1
  212. package/dist/shared/id.d.ts +14 -0
  213. package/dist/shared/id.js +15 -1
  214. package/dist/shared/id.js.map +1 -1
  215. package/dist/shared/mailer.d.ts +15 -2
  216. package/dist/shared/mailer.js +32 -5
  217. package/dist/shared/mailer.js.map +1 -1
  218. package/dist/shared/network.d.ts +10 -0
  219. package/dist/shared/network.js +211 -0
  220. package/dist/shared/network.js.map +1 -0
  221. package/dist/shared/notification-categories.d.ts +16 -0
  222. package/dist/shared/notification-categories.js +94 -0
  223. package/dist/shared/notification-categories.js.map +1 -0
  224. package/dist/shared/notification-settings.d.ts +14 -0
  225. package/dist/shared/notification-settings.js +61 -0
  226. package/dist/shared/notification-settings.js.map +1 -0
  227. package/dist/shared/ports.d.ts +48 -2
  228. package/dist/shared/settings.d.ts +2 -1
  229. package/dist/shared/settings.js +1 -0
  230. package/dist/shared/settings.js.map +1 -1
  231. package/dist/types/assets.d.ts +2 -2
  232. package/dist/types/automations.d.ts +15 -1
  233. package/dist/types/content.d.ts +2 -2
  234. package/dist/types/discussion.d.ts +11 -3
  235. package/dist/types/email-templates.d.ts +216 -1
  236. package/dist/types/entitlements.d.ts +2 -2
  237. package/dist/types/identity.d.ts +48 -2
  238. package/dist/types/index.d.ts +9 -8
  239. package/dist/types/notifications.d.ts +157 -0
  240. package/dist/types/ports.d.ts +6 -0
  241. package/dist/types/schemas/assets.d.ts +65 -37
  242. package/dist/types/schemas/assets.js +17 -12
  243. package/dist/types/schemas/assets.js.map +1 -1
  244. package/dist/types/schemas/automations.d.ts +9 -1
  245. package/dist/types/schemas/automations.js +13 -6
  246. package/dist/types/schemas/automations.js.map +1 -1
  247. package/dist/types/schemas/content.d.ts +396 -3
  248. package/dist/types/schemas/content.js +161 -3
  249. package/dist/types/schemas/content.js.map +1 -1
  250. package/dist/types/schemas/discussion.d.ts +443 -3
  251. package/dist/types/schemas/discussion.js +233 -4
  252. package/dist/types/schemas/discussion.js.map +1 -1
  253. package/dist/types/schemas/entitlements.d.ts +36 -2
  254. package/dist/types/schemas/entitlements.js +23 -1
  255. package/dist/types/schemas/entitlements.js.map +1 -1
  256. package/dist/types/schemas/identity.d.ts +27 -1
  257. package/dist/types/schemas/identity.js +14 -2
  258. package/dist/types/schemas/identity.js.map +1 -1
  259. package/dist/types/schemas/index.d.ts +9 -8
  260. package/dist/types/schemas/index.js +9 -8
  261. package/dist/types/schemas/notifications.d.ts +95 -0
  262. package/dist/types/schemas/notifications.js +56 -0
  263. package/dist/types/schemas/notifications.js.map +1 -0
  264. package/dist/types/schemas/organizations.d.ts +8 -0
  265. package/dist/types/schemas/organizations.js +2 -0
  266. package/dist/types/schemas/organizations.js.map +1 -1
  267. package/dist/types/schemas/progress.d.ts +3 -3
  268. package/dist/types/schemas/shared.d.ts +3 -1
  269. package/dist/types/schemas/shared.js +5 -1
  270. package/dist/types/schemas/shared.js.map +1 -1
  271. package/package.json +2 -2
  272. package/dist/reporting/courses/index.d.ts +0 -4
  273. package/dist/reporting/courses/index.js +0 -2
  274. package/dist/reporting/courses/model.d.ts +0 -24
  275. package/dist/reporting/courses/ports.d.ts +0 -13
  276. package/dist/reporting/courses/service.d.ts +0 -19
  277. package/dist/reporting/courses/service.js +0 -20
  278. package/dist/reporting/courses/service.js.map +0 -1
  279. package/dist/reporting/dashboard/index.d.ts +0 -4
  280. package/dist/reporting/dashboard/index.js +0 -2
  281. package/dist/reporting/dashboard/model.d.ts +0 -14
  282. package/dist/reporting/dashboard/ports.d.ts +0 -13
  283. package/dist/reporting/dashboard/service.d.ts +0 -19
  284. package/dist/reporting/dashboard/service.js +0 -20
  285. package/dist/reporting/dashboard/service.js.map +0 -1
  286. package/dist/reporting/learn/index.d.ts +0 -5
  287. package/dist/reporting/learn/index.js +0 -2
  288. package/dist/reporting/learn/model.d.ts +0 -23
  289. package/dist/reporting/learn/ports.d.ts +0 -43
  290. package/dist/reporting/learn/service.d.ts +0 -49
  291. package/dist/reporting/learn/service.js +0 -116
  292. package/dist/reporting/learn/service.js.map +0 -1
  293. package/dist/reporting/students/index.d.ts +0 -4
  294. package/dist/reporting/students/index.js +0 -2
  295. package/dist/reporting/students/model.d.ts +0 -51
  296. package/dist/reporting/students/model.js +0 -1
  297. package/dist/reporting/students/ports.d.ts +0 -15
  298. package/dist/reporting/students/ports.js +0 -1
  299. package/dist/reporting/students/service.d.ts +0 -20
  300. package/dist/reporting/students/service.js +0 -23
  301. package/dist/reporting/students/service.js.map +0 -1
  302. /package/dist/reporting/{courses → admin}/model.js +0 -0
  303. /package/dist/reporting/{courses → admin}/ports.js +0 -0
  304. /package/dist/reporting/{dashboard → member}/model.js +0 -0
  305. /package/dist/reporting/{dashboard → member}/ports.js +0 -0
  306. /package/dist/{reporting/learn/model.js → shared/actor.js} +0 -0
  307. /package/dist/{reporting/learn/ports.js → types/notifications.js} +0 -0
@@ -1,11 +1,12 @@
1
1
  import { AcceptInviteInput, AddOrgUserInput, CreateInviteInput, CreateOrgUserInput, Invite, InviteRole, NewOrganizationInput, OrgUser, Organization, Role, UpdateOrgUserInput } from "../types/schemas/organizations.js";
2
2
  import { OutboxAppender, UnitOfWork } from "../shared/ports.js";
3
- import { AuthHeaders } from "../identity/ports.js";
4
- import "../identity/index.js";
3
+ import { Actor } from "../shared/actor.js";
5
4
  import "./model.js";
6
5
  import "./roles.js";
7
- import { Member, MembersQuery, Page } from "./members.js";
8
- import { CreateOrganizationInput, ResendStudentInviteInput, UpdateOrganizationInput, UpdateStudentInput } from "./types.js";
6
+ import { Member, MemberRoleChange, MembersQuery, Page } from "./members.js";
7
+ import { CreateOrganizationInput, ResendStudentInviteInput, UpdateOrganizationInput } from "./types.js";
8
+ import { AuthHeaders } from "../identity/ports.js";
9
+ import "../identity/index.js";
9
10
  //#region src/organizations/ports.d.ts
10
11
  interface OrganizationService {
11
12
  createOrganization(input: CreateOrganizationInput): Promise<Organization>;
@@ -16,18 +17,21 @@ interface OrganizationService {
16
17
  updateOrgUser(orgId: string, userId: string, patch: UpdateOrgUserInput): Promise<OrgUser>;
17
18
  getByExternalId(externalId: string): Promise<Organization | null>;
18
19
  getOrgUser(orgId: string, userId: string): Promise<OrgUser | null>;
20
+ getOrgUserById(orgId: string, id: string): Promise<OrgUser | null>;
21
+ getOrgUsersByIds(orgId: string, ids: string[]): Promise<OrgUser[]>;
19
22
  getOrgUsersForUser(userId: string): Promise<OrgUser[]>;
20
23
  getById(id: string): Promise<Organization | null>;
21
- createInvite(input: CreateInviteInput): Promise<Invite>;
24
+ orgIds(): Promise<string[]>;
25
+ createInvite(actor: Actor, input: CreateInviteInput): Promise<Invite>;
22
26
  peekInvite(token: string): Promise<Invite | null>;
23
27
  acceptInvite(input: AcceptInviteInput): Promise<OrgUser>;
24
- deleteOrgUser(orgId: string, id: string): Promise<OrgUser>;
25
- resendStudentInvite(input: ResendStudentInviteInput): Promise<void>;
26
- updateStudent(input: UpdateStudentInput): Promise<OrgUser>;
28
+ deleteOrgUser(orgId: string, actor: Actor, id: string): Promise<OrgUser>;
29
+ resendStudentInvite(actor: Actor, input: ResendStudentInviteInput): Promise<void>;
27
30
  getBySlug(slug: string): Promise<Organization | null>;
28
31
  listMembers(orgId: string, query: MembersQuery): Promise<Page<Member>>;
29
- updateMemberRole(ctx: MemberWriteContext, id: string, role: Role): Promise<Member | null>;
30
- removeMember(ctx: MemberWriteContext, id: string): Promise<boolean>;
32
+ updateMemberRole(ctx: MemberWriteContext, actor: Actor, id: string, role: Role): Promise<MemberRoleChange | null>;
33
+ removeMember(ctx: MemberWriteContext, actor: Actor, id: string): Promise<boolean>;
34
+ staffOrgUserIds(orgId: string): Promise<string[]>;
31
35
  }
32
36
  /** Atomic write scope: tx-bound repo + outbox appender, one transaction. */
33
37
  interface OrganizationsWriteScope {
@@ -58,6 +62,7 @@ interface OrganizationsRepository {
58
62
  /** Drops the org row and its dependents; null when none matched. */
59
63
  delete(id: string): Promise<Organization>;
60
64
  findById(id: string): Promise<Organization | null>;
65
+ listIds(): Promise<string[]>;
61
66
  findByExternalId(externalId: string): Promise<Organization | null>;
62
67
  findBySlug(slug: string): Promise<Organization | null>;
63
68
  /** Inserts a pending invite, or re-issues the org's existing pending one
@@ -72,6 +77,8 @@ interface OrganizationsRepository {
72
77
  /** Every org this person belongs to, oldest first. */
73
78
  findOrgUsersByUser(userId: string): Promise<OrgUser[]>;
74
79
  findOrgUserById(orgId: string, id: string): Promise<OrgUser | null>;
80
+ findOrgUsersByIds(orgId: string, ids: string[]): Promise<OrgUser[]>;
81
+ findOrgUserIdsByRole(orgId: string, roles: Role[]): Promise<string[]>;
75
82
  /** Links a user to the org under a role. Throws ConflictError when they
76
83
  * already hold a row there. */
77
84
  createOrgUser(input: CreateOrgUserInput): Promise<OrgUser>;
@@ -79,7 +86,6 @@ interface OrganizationsRepository {
79
86
  /** Every org this person is a student in — the scope of `student.linked`. */
80
87
  findStudentOrgUsers(userId: string): Promise<OrgUser[]>;
81
88
  /** Kills the org's pending invite for an address, if any. */
82
- cancelPendingInvite(orgId: string, email: string): Promise<Invite | null>;
83
89
  deleteOrgUser(orgId: string, id: string): Promise<OrgUser | null>;
84
90
  }
85
91
  /** A member row enriched with the ids needed to drive writes. */
@@ -1,23 +1,19 @@
1
1
  import { Role } from "../types/schemas/organizations.js";
2
2
  import "../types/index.js";
3
3
  //#region src/organizations/roles.d.ts
4
- declare const ROLES: readonly ["owner", "admin", "instructor", "student"];
5
- /**
6
- * The roles that operate the back office. `student` belongs to the org but
7
- * never appears on a staff surface, so the member-management types are typed
8
- * with this narrower union — the exclusion is a compiler guarantee, not a
9
- * convention a query has to remember.
10
- */
11
- type StaffRole = Exclude<Role, 'student'>;
4
+ declare const ROLES: readonly ["owner", "admin", "instructor", "moderator", "student"];
5
+ type StaffRole = 'owner' | 'admin' | 'instructor';
12
6
  declare const STAFF_ROLES: readonly ["owner", "admin", "instructor"];
7
+ type TeamRole = Exclude<Role, 'student'>;
13
8
  declare function isRole(value: string): value is Role;
14
9
  declare function isStaffRole(value: string): value is StaffRole;
15
10
  declare function parseRole(value: string): Role;
16
- type Permission = 'manage_billing' | 'manage_org_settings' | 'manage_users' | 'create_course' | 'edit_assigned_course' | 'view_student_progress' | 'consume_content';
11
+ type Permission = 'manage_billing' | 'manage_org_settings' | 'manage_users' | 'create_course' | 'edit_assigned_course' | 'view_student_progress' | 'moderate' | 'consume_content';
17
12
  type Capability = true | 'assigned' | 'enrolled';
18
13
  declare function capability(role: Role, permission: Permission): Capability | false;
14
+ declare function canModerate(role: Role): boolean;
19
15
  declare function normalizeRole(raw: string): Role;
20
16
  declare const STUDENT_ROLE = "student";
21
17
  //#endregion
22
- export { Capability, Permission, ROLES, type Role, STAFF_ROLES, STUDENT_ROLE, StaffRole, capability, isRole, isStaffRole, normalizeRole, parseRole };
18
+ export { Capability, Permission, ROLES, type Role, STAFF_ROLES, STUDENT_ROLE, StaffRole, TeamRole, canModerate, capability, isRole, isStaffRole, normalizeRole, parseRole };
23
19
  //# sourceMappingURL=roles.d.ts.map
@@ -3,6 +3,7 @@ const ROLES = [
3
3
  "owner",
4
4
  "admin",
5
5
  "instructor",
6
+ "moderator",
6
7
  "student"
7
8
  ];
8
9
  const STAFF_ROLES = [
@@ -27,28 +28,39 @@ const MATRIX = {
27
28
  manage_users: true,
28
29
  create_course: true,
29
30
  edit_assigned_course: true,
30
- view_student_progress: true
31
+ view_student_progress: true,
32
+ moderate: true
31
33
  },
32
34
  admin: {
33
35
  manage_org_settings: true,
34
36
  manage_users: true,
35
37
  create_course: true,
36
38
  edit_assigned_course: true,
37
- view_student_progress: true
39
+ view_student_progress: true,
40
+ moderate: true
38
41
  },
39
42
  instructor: {
40
43
  edit_assigned_course: "assigned",
41
- view_student_progress: "assigned"
44
+ view_student_progress: "assigned",
45
+ moderate: true
46
+ },
47
+ moderator: {
48
+ moderate: true,
49
+ consume_content: "enrolled"
42
50
  },
43
51
  student: { consume_content: "enrolled" }
44
52
  };
45
53
  function capability(role, permission) {
46
54
  return MATRIX[role][permission] ?? false;
47
55
  }
56
+ function canModerate(role) {
57
+ return capability(role, "moderate") === true;
58
+ }
48
59
  const RANK = {
49
- owner: 3,
50
- admin: 2,
51
- instructor: 1,
60
+ owner: 4,
61
+ admin: 3,
62
+ instructor: 2,
63
+ moderator: 1,
52
64
  student: 0
53
65
  };
54
66
  function normalizeRole(raw) {
@@ -65,6 +77,6 @@ function normalizeRole(raw) {
65
77
  }
66
78
  const STUDENT_ROLE = "student";
67
79
  //#endregion
68
- export { ROLES, STAFF_ROLES, STUDENT_ROLE, capability, isRole, isStaffRole, normalizeRole, parseRole };
80
+ export { ROLES, STAFF_ROLES, STUDENT_ROLE, canModerate, capability, isRole, isStaffRole, normalizeRole, parseRole };
69
81
 
70
82
  //# sourceMappingURL=roles.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"roles.js","names":[],"sources":["../../src/organizations/roles.ts"],"sourcesContent":["// organizations context — roles and the authorization matrix.\n// Defined in code (no DB enum). The DB stores role as text; the domain narrows\n// it to Role and answers authorization questions here. The Role type itself is\n// owned by @headless-lms/core/types; RANK/MATRIX below are Record<Role, …>, so the\n// compiler forces this file to cover every role the published type declares.\nimport type { Role } from '../types/index.js';\n\nexport type { Role };\nexport const ROLES = ['owner', 'admin', 'instructor', 'student'] as const satisfies readonly Role[];\n\n/**\n * The roles that operate the back office. `student` belongs to the org but\n * never appears on a staff surface, so the member-management types are typed\n * with this narrower union — the exclusion is a compiler guarantee, not a\n * convention a query has to remember.\n */\nexport type StaffRole = Exclude<Role, 'student'>;\nexport const STAFF_ROLES = ['owner', 'admin', 'instructor'] as const satisfies readonly StaffRole[];\n\nexport function isRole(value: string): value is Role {\n return (ROLES as readonly string[]).includes(value);\n}\n\nexport function isStaffRole(value: string): value is StaffRole {\n return (STAFF_ROLES as readonly string[]).includes(value);\n}\n\nexport function parseRole(value: string): Role {\n if (!isRole(value)) {\n throw new Error(`unknown role: ${value}`);\n }\n return value;\n}\n\nexport type Permission =\n | 'manage_billing'\n | 'manage_org_settings'\n | 'manage_users'\n | 'create_course'\n | 'edit_assigned_course'\n | 'view_student_progress'\n | 'consume_content';\n\n// Unconditional (true), course-scoped (\"assigned\"), or enrollment-scoped\n// (\"enrolled\" — access owned by entitlements). Absent ⇒ denied.\n//\n// \"assigned\" is a granted-but-narrowed answer: the role holds the permission,\n// but only over some subset of courses. Nothing narrows it today — there is no\n// course-assignment store — so callers must decide what a narrowed grant means\n// for them. `authorize` treats it as denied; `tools.ts` treats it as granted.\nexport type Capability = true | 'assigned' | 'enrolled';\n\nconst MATRIX: Record<Role, Partial<Record<Permission, Capability>>> = {\n owner: {\n manage_billing: true,\n manage_org_settings: true,\n manage_users: true,\n create_course: true,\n edit_assigned_course: true,\n view_student_progress: true,\n },\n admin: {\n manage_org_settings: true,\n manage_users: true,\n create_course: true,\n edit_assigned_course: true,\n view_student_progress: true,\n },\n instructor: {\n edit_assigned_course: 'assigned',\n view_student_progress: 'assigned',\n },\n // A learner belongs to the org to consume content they hold an\n // entitlement for. No management capability of any kind.\n student: {\n consume_content: 'enrolled',\n },\n};\n\nexport function capability(role: Role, permission: Permission): Capability | false {\n return MATRIX[role][permission] ?? false;\n}\n\n// Better Auth's role model is broader than ours: it keeps a built-in `member`\n// role and allows comma-joined multi-role strings. Normalize any incoming role\n// string to a single domain Role before persisting: `member` -> `instructor`, a\n// multi-role string -> its highest-privilege known role, unknown -> `instructor`.\nconst RANK: Record<Role, number> = { owner: 3, admin: 2, instructor: 1, student: 0 };\n\nexport function normalizeRole(raw: string): Role {\n let best: Role = 'instructor';\n let bestRank = -1;\n for (const token of raw.split(',').map((t) => t.trim())) {\n const r: Role | null = isRole(token) ? token : token === 'member' ? 'instructor' : null;\n if (r && RANK[r] > bestRank) {\n best = r;\n bestRank = RANK[r];\n }\n }\n return best;\n}\n\n// Invites carry either a staff Role or this marker: the invited account is\n// a portal student, not an org member. Owned by the organizations domain — the\n// invite provider adapter reuses it to branch links, emails, and grants.\nexport const STUDENT_ROLE = 'student';\n"],"mappings":";AAQA,MAAa,QAAQ;CAAC;CAAS;CAAS;CAAc;AAAS;AAS/D,MAAa,cAAc;CAAC;CAAS;CAAS;AAAY;AAE1D,SAAgB,OAAO,OAA8B;CACnD,OAAQ,MAA4B,SAAS,KAAK;AACpD;AAEA,SAAgB,YAAY,OAAmC;CAC7D,OAAQ,YAAkC,SAAS,KAAK;AAC1D;AAEA,SAAgB,UAAU,OAAqB;CAC7C,IAAI,CAAC,OAAO,KAAK,GACf,MAAM,IAAI,MAAM,iBAAiB,OAAO;CAE1C,OAAO;AACT;AAoBA,MAAM,SAAgE;CACpE,OAAO;EACL,gBAAgB;EAChB,qBAAqB;EACrB,cAAc;EACd,eAAe;EACf,sBAAsB;EACtB,uBAAuB;CACzB;CACA,OAAO;EACL,qBAAqB;EACrB,cAAc;EACd,eAAe;EACf,sBAAsB;EACtB,uBAAuB;CACzB;CACA,YAAY;EACV,sBAAsB;EACtB,uBAAuB;CACzB;CAGA,SAAS,EACP,iBAAiB,WACnB;AACF;AAEA,SAAgB,WAAW,MAAY,YAA4C;CACjF,OAAO,OAAO,KAAK,CAAC,eAAe;AACrC;AAMA,MAAM,OAA6B;CAAE,OAAO;CAAG,OAAO;CAAG,YAAY;CAAG,SAAS;AAAE;AAEnF,SAAgB,cAAc,KAAmB;CAC/C,IAAI,OAAa;CACjB,IAAI,WAAW;CACf,KAAK,MAAM,SAAS,IAAI,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM,EAAE,KAAK,CAAC,GAAG;EACvD,MAAM,IAAiB,OAAO,KAAK,IAAI,QAAQ,UAAU,WAAW,eAAe;EACnF,IAAI,KAAK,KAAK,KAAK,UAAU;GAC3B,OAAO;GACP,WAAW,KAAK;EAClB;CACF;CACA,OAAO;AACT;AAKA,MAAa,eAAe"}
1
+ {"version":3,"file":"roles.js","names":[],"sources":["../../src/organizations/roles.ts"],"sourcesContent":["// organizations context — roles and the authorization matrix.\n// Defined in code (no DB enum). The DB stores role as text; the domain narrows\n// it to Role and answers authorization questions here. The Role type itself is\n// owned by @headless-lms/core/types; RANK/MATRIX below are Record<Role, …>, so the\n// compiler forces this file to cover every role the published type declares.\nimport type { Role } from '../types/index.js';\n\nexport type { Role };\nexport const ROLES = [\n 'owner',\n 'admin',\n 'instructor',\n 'moderator',\n 'student',\n] as const satisfies readonly Role[];\n\nexport type StaffRole = 'owner' | 'admin' | 'instructor';\nexport const STAFF_ROLES = ['owner', 'admin', 'instructor'] as const satisfies readonly StaffRole[];\n\nexport type TeamRole = Exclude<Role, 'student'>;\n\nexport function isRole(value: string): value is Role {\n return (ROLES as readonly string[]).includes(value);\n}\n\nexport function isStaffRole(value: string): value is StaffRole {\n return (STAFF_ROLES as readonly string[]).includes(value);\n}\n\nexport function parseRole(value: string): Role {\n if (!isRole(value)) {\n throw new Error(`unknown role: ${value}`);\n }\n return value;\n}\n\nexport type Permission =\n | 'manage_billing'\n | 'manage_org_settings'\n | 'manage_users'\n | 'create_course'\n | 'edit_assigned_course'\n | 'view_student_progress'\n | 'moderate'\n | 'consume_content';\n\n// Unconditional (true), course-scoped (\"assigned\"), or enrollment-scoped\n// (\"enrolled\" — access owned by entitlements). Absent ⇒ denied.\n//\n// \"assigned\" is a granted-but-narrowed answer: the role holds the permission,\n// but only over some subset of courses. Nothing narrows it today — there is no\n// course-assignment store — so callers must decide what a narrowed grant means\n// for them. `authorize` treats it as denied; `tools.ts` treats it as granted.\nexport type Capability = true | 'assigned' | 'enrolled';\n\nconst MATRIX: Record<Role, Partial<Record<Permission, Capability>>> = {\n owner: {\n manage_billing: true,\n manage_org_settings: true,\n manage_users: true,\n create_course: true,\n edit_assigned_course: true,\n view_student_progress: true,\n moderate: true,\n },\n admin: {\n manage_org_settings: true,\n manage_users: true,\n create_course: true,\n edit_assigned_course: true,\n view_student_progress: true,\n moderate: true,\n },\n instructor: {\n edit_assigned_course: 'assigned',\n view_student_progress: 'assigned',\n moderate: true,\n },\n moderator: {\n moderate: true,\n consume_content: 'enrolled',\n },\n student: {\n consume_content: 'enrolled',\n },\n};\n\nexport function capability(role: Role, permission: Permission): Capability | false {\n return MATRIX[role][permission] ?? false;\n}\n\nexport function canModerate(role: Role): boolean {\n return capability(role, 'moderate') === true;\n}\n\n// Better Auth's role model is broader than ours: it keeps a built-in `member`\n// role and allows comma-joined multi-role strings. Normalize any incoming role\n// string to a single domain Role before persisting: `member` -> `instructor`, a\n// multi-role string -> its highest-privilege known role, unknown -> `instructor`.\nconst RANK: Record<Role, number> = { owner: 4, admin: 3, instructor: 2, moderator: 1, student: 0 };\n\nexport function normalizeRole(raw: string): Role {\n let best: Role = 'instructor';\n let bestRank = -1;\n for (const token of raw.split(',').map((t) => t.trim())) {\n const r: Role | null = isRole(token) ? token : token === 'member' ? 'instructor' : null;\n if (r && RANK[r] > bestRank) {\n best = r;\n bestRank = RANK[r];\n }\n }\n return best;\n}\n\n// Invites carry either a staff Role or this marker: the invited account is\n// a portal student, not an org member. Owned by the organizations domain — the\n// invite provider adapter reuses it to branch links, emails, and grants.\nexport const STUDENT_ROLE = 'student';\n"],"mappings":";AAQA,MAAa,QAAQ;CACnB;CACA;CACA;CACA;CACA;AACF;AAGA,MAAa,cAAc;CAAC;CAAS;CAAS;AAAY;AAI1D,SAAgB,OAAO,OAA8B;CACnD,OAAQ,MAA4B,SAAS,KAAK;AACpD;AAEA,SAAgB,YAAY,OAAmC;CAC7D,OAAQ,YAAkC,SAAS,KAAK;AAC1D;AAEA,SAAgB,UAAU,OAAqB;CAC7C,IAAI,CAAC,OAAO,KAAK,GACf,MAAM,IAAI,MAAM,iBAAiB,OAAO;CAE1C,OAAO;AACT;AAqBA,MAAM,SAAgE;CACpE,OAAO;EACL,gBAAgB;EAChB,qBAAqB;EACrB,cAAc;EACd,eAAe;EACf,sBAAsB;EACtB,uBAAuB;EACvB,UAAU;CACZ;CACA,OAAO;EACL,qBAAqB;EACrB,cAAc;EACd,eAAe;EACf,sBAAsB;EACtB,uBAAuB;EACvB,UAAU;CACZ;CACA,YAAY;EACV,sBAAsB;EACtB,uBAAuB;EACvB,UAAU;CACZ;CACA,WAAW;EACT,UAAU;EACV,iBAAiB;CACnB;CACA,SAAS,EACP,iBAAiB,WACnB;AACF;AAEA,SAAgB,WAAW,MAAY,YAA4C;CACjF,OAAO,OAAO,KAAK,CAAC,eAAe;AACrC;AAEA,SAAgB,YAAY,MAAqB;CAC/C,OAAO,WAAW,MAAM,UAAU,MAAM;AAC1C;AAMA,MAAM,OAA6B;CAAE,OAAO;CAAG,OAAO;CAAG,YAAY;CAAG,WAAW;CAAG,SAAS;AAAE;AAEjG,SAAgB,cAAc,KAAmB;CAC/C,IAAI,OAAa;CACjB,IAAI,WAAW;CACf,KAAK,MAAM,SAAS,IAAI,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM,EAAE,KAAK,CAAC,GAAG;EACvD,MAAM,IAAiB,OAAO,KAAK,IAAI,QAAQ,UAAU,WAAW,eAAe;EACnF,IAAI,KAAK,KAAK,KAAK,UAAU;GAC3B,OAAO;GACP,WAAW,KAAK;EAClB;CACF;CACA,OAAO;AACT;AAKA,MAAa,eAAe"}
@@ -1,13 +1,14 @@
1
1
  import { Logger } from "../types/ports.js";
2
2
  import { AcceptInviteInput, AddOrgUserInput, CreateInviteInput, Invite, OrgUser, Organization, Role, UpdateOrgUserInput } from "../types/schemas/organizations.js";
3
3
  import "../shared/ports.js";
4
- import { Mailer } from "../shared/mailer.js";
5
- import { IdentityService } from "../identity/ports.js";
6
- import "../identity/index.js";
4
+ import { Actor } from "../shared/actor.js";
7
5
  import "./model.js";
8
6
  import "./roles.js";
9
- import { Member, MembersQuery, Page } from "./members.js";
10
- import { CreateOrganizationInput, ResendStudentInviteInput, UpdateOrganizationInput, UpdateStudentInput } from "./types.js";
7
+ import { Member, MemberRoleChange, MembersQuery, Page } from "./members.js";
8
+ import { CreateOrganizationInput, ResendStudentInviteInput, UpdateOrganizationInput } from "./types.js";
9
+ import { IdentityService } from "../identity/ports.js";
10
+ import { Mailer } from "../shared/mailer.js";
11
+ import "../identity/index.js";
11
12
  import { MemberWriteContext, MembersRepository, OrgAdmin, OrganizationService, OrganizationsRepository, OrganizationsUnitOfWork } from "./ports.js";
12
13
  //#region src/organizations/service.d.ts
13
14
  interface InviteUrls {
@@ -33,6 +34,7 @@ declare class OrganizationServiceImpl implements OrganizationService {
33
34
  private readonly logger;
34
35
  private readonly mailer?;
35
36
  private readonly inviteUrls?;
37
+ private readonly memberWrites;
36
38
  constructor(params: OrganizationServiceParams);
37
39
  createOrganization(input: CreateOrganizationInput): Promise<Organization>;
38
40
  updateOrganization(id: string, input: UpdateOrganizationInput): Promise<Organization>;
@@ -40,23 +42,32 @@ declare class OrganizationServiceImpl implements OrganizationService {
40
42
  addOrgUser({ orgId, userId, role }: AddOrgUserInput): Promise<OrgUser>;
41
43
  removeOrgUser(orgId: string, userId: string): Promise<OrgUser>;
42
44
  updateOrgUser(orgId: string, userId: string, patch: UpdateOrgUserInput): Promise<OrgUser>;
43
- createInvite(input: CreateInviteInput): Promise<Invite>;
44
- updateStudent(input: UpdateStudentInput): Promise<OrgUser>;
45
- resendStudentInvite(input: ResendStudentInviteInput): Promise<void>;
45
+ createInvite(actor: Actor, input: CreateInviteInput): Promise<Invite>;
46
+ resendStudentInvite(actor: Actor, input: ResendStudentInviteInput): Promise<void>;
46
47
  peekInvite(token: string): Promise<Invite | null>;
47
48
  acceptInvite(input: AcceptInviteInput): Promise<OrgUser>;
48
49
  private acceptStudentInvite;
49
50
  private acceptStaffInvite;
51
+ getOrgUserById(orgId: string, id: string): Promise<OrgUser | null>;
52
+ getOrgUsersByIds(orgId: string, ids: string[]): Promise<OrgUser[]>;
50
53
  getOrgUser(orgId: string, userId: string): Promise<OrgUser | null>;
51
- deleteOrgUser(orgId: string, id: string): Promise<OrgUser>;
54
+ deleteOrgUser(orgId: string, actor: Actor, id: string): Promise<OrgUser>;
52
55
  getOrgUsersForUser(userId: string): Promise<OrgUser[]>;
53
56
  getById(id: string): Promise<Organization | null>;
57
+ orgIds(): Promise<string[]>;
54
58
  private sendInviteEmail;
55
59
  getByExternalId(externalId: string): Promise<Organization | null>;
56
60
  getBySlug(slug: string): Promise<Organization | null>;
61
+ staffOrgUserIds(orgId: string): Promise<string[]>;
57
62
  listMembers(orgId: string, query: MembersQuery): Promise<Page<Member>>;
58
- updateMemberRole(ctx: MemberWriteContext, id: string, role: Role): Promise<Member | null>;
59
- removeMember(ctx: MemberWriteContext, id: string): Promise<boolean>;
63
+ updateMemberRole(ctx: MemberWriteContext, actor: Actor, id: string, role: Role): Promise<MemberRoleChange | null>;
64
+ private demoteModerator;
65
+ private promoteStudent;
66
+ private asActor;
67
+ private memberWrite;
68
+ private assertManagesUsers;
69
+ private assertNotOwner;
70
+ removeMember(ctx: MemberWriteContext, actor: Actor, id: string): Promise<boolean>;
60
71
  }
61
72
  //#endregion
62
73
  export { InviteUrls, OrganizationServiceImpl, OrganizationServiceParams };
@@ -1,10 +1,11 @@
1
- import { noopLogger } from "../shared/logger.js";
2
1
  import { genId } from "../shared/id.js";
3
- import { NotFoundError } from "../shared/errors.js";
4
- import { STUDENT_ROLE } from "./roles.js";
2
+ import { ForbiddenError, NotFoundError } from "../shared/errors.js";
3
+ import { noopLogger } from "../shared/logger.js";
4
+ import { STAFF_ROLES, STUDENT_ROLE, capability } from "./roles.js";
5
5
  import { InviteError, OrganizationRuleError } from "./members.js";
6
6
  import { organizationEvents } from "./events.js";
7
7
  import { generateInviteToken, hashInviteToken } from "../shared/invite-token.js";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
8
9
  //#region src/organizations/service.ts
9
10
  const INVITE_TTL_MS = 10080 * 60 * 1e3;
10
11
  function toMember(r) {
@@ -29,6 +30,7 @@ var OrganizationServiceImpl = class {
29
30
  logger;
30
31
  mailer;
31
32
  inviteUrls;
33
+ memberWrites = new AsyncLocalStorage();
32
34
  constructor(params) {
33
35
  this.repo = params.repo;
34
36
  this.membersRepo = params.membersRepo;
@@ -90,6 +92,10 @@ var OrganizationServiceImpl = class {
90
92
  return deleted;
91
93
  }
92
94
  async addOrgUser({ orgId, userId, role }) {
95
+ if (await this.repo.findOrgUser(orgId, userId)) return this.updateOrgUser(orgId, userId, {
96
+ role,
97
+ status: "active"
98
+ });
93
99
  const orgUser = await this.uow.run(async ({ organizations, outbox }) => {
94
100
  const created = await organizations.createOrgUser({
95
101
  orgId,
@@ -113,9 +119,21 @@ var OrganizationServiceImpl = class {
113
119
  async removeOrgUser(orgId, userId) {
114
120
  const deleted = await this.uow.run(async ({ organizations, outbox }) => {
115
121
  const orgUser = await organizations.findOrgUser(orgId, userId);
116
- if (!orgUser) throw new NotFoundError("OrgUser", userId);
122
+ if (!orgUser) throw new NotFoundError("Member", userId);
123
+ const write = this.memberWrite(orgUser.id);
124
+ if (write?.demote) {
125
+ const demoted = await organizations.updateOrgUser(orgId, orgUser.id, { role: STUDENT_ROLE });
126
+ if (!demoted) throw new NotFoundError("Member", orgUser.id);
127
+ if (orgUser.role !== "student") await outbox.append([organizationEvents.orgUserUpdated.make({
128
+ orgId,
129
+ data: demoted,
130
+ metadata: { actorOrgUserId: write.actorOrgUserId }
131
+ })]);
132
+ return demoted;
133
+ }
134
+ if (orgUser.role === "student") return orgUser;
117
135
  const deletedUser = await organizations.deleteOrgUser(orgId, orgUser.id);
118
- if (!deletedUser) throw new NotFoundError("OrgUser", orgUser.id);
136
+ if (!deletedUser) throw new NotFoundError("Member", orgUser.id);
119
137
  await outbox.append([organizationEvents.orgUserDeleted.make({
120
138
  orgId,
121
139
  data: deletedUser
@@ -130,10 +148,21 @@ var OrganizationServiceImpl = class {
130
148
  return deleted;
131
149
  }
132
150
  async updateOrgUser(orgId, userId, patch) {
133
- const orgUser = await this.repo.findOrgUser(orgId, userId);
134
- if (!orgUser) throw new NotFoundError("OrgUser", userId);
135
- const updated = await this.repo.updateOrgUser(orgId, orgUser.id, patch);
136
- if (!updated) throw new NotFoundError("OrgUser", orgUser.id);
151
+ const updated = await this.uow.run(async ({ organizations, outbox }) => {
152
+ const orgUser = await organizations.findOrgUser(orgId, userId);
153
+ if (!orgUser) throw new NotFoundError("Member", userId);
154
+ const next = await organizations.updateOrgUser(orgId, orgUser.id, patch);
155
+ if (!next) throw new NotFoundError("Member", orgUser.id);
156
+ if (patch.role !== void 0 && patch.role !== orgUser.role) {
157
+ const actorOrgUserId = this.memberWrite(orgUser.id)?.actorOrgUserId;
158
+ await outbox.append([organizationEvents.orgUserUpdated.make({
159
+ orgId,
160
+ data: next,
161
+ ...actorOrgUserId !== void 0 && { metadata: { actorOrgUserId } }
162
+ })]);
163
+ }
164
+ return next;
165
+ });
137
166
  this.logger.info("org user updated", {
138
167
  orgId,
139
168
  userId,
@@ -142,8 +171,10 @@ var OrganizationServiceImpl = class {
142
171
  });
143
172
  return updated;
144
173
  }
145
- async createInvite(input) {
174
+ async createInvite(actor, input) {
146
175
  const { orgId, email, role, inviterUserId, firstName, lastName } = input;
176
+ this.assertManagesUsers(actor, orgId);
177
+ this.assertNotOwner(role, orgId);
147
178
  const sendEmail = input.sendEmail ?? true;
148
179
  const { token, tokenHash } = generateInviteToken();
149
180
  const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
@@ -184,7 +215,8 @@ var OrganizationServiceImpl = class {
184
215
  await this.sendInviteEmail(invite, token);
185
216
  } catch (err) {
186
217
  this.logger.error("failed to send invite email", {
187
- email: invite.email,
218
+ orgId,
219
+ inviteId: invite.id,
188
220
  role: invite.role,
189
221
  err: err instanceof Error ? err : new Error(String(err))
190
222
  });
@@ -197,29 +229,9 @@ var OrganizationServiceImpl = class {
197
229
  });
198
230
  return invite;
199
231
  }
200
- async updateStudent(input) {
201
- const { orgId, orgUserId, firstName, lastName, email } = input;
202
- const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);
203
- if (!orgUser || orgUser.role !== "student") throw new NotFoundError("Student", orgUserId);
204
- const person = await this.people.getUserById(orgUser.userId);
205
- if (!person) throw new NotFoundError("User", orgUser.userId);
206
- const previousEmail = person.email;
207
- await this.people.updateUser(person.id, {
208
- ...firstName !== void 0 && { firstName },
209
- ...lastName !== void 0 && { lastName },
210
- ...email !== void 0 && { email }
211
- });
212
- const emailChanged = email !== void 0 && email.toLowerCase() !== previousEmail.toLowerCase();
213
- if (emailChanged && orgUser.status !== "active") await this.repo.cancelPendingInvite(orgId, previousEmail);
214
- this.logger.info("student updated", {
215
- orgId,
216
- orgUserId,
217
- emailChanged
218
- });
219
- return orgUser;
220
- }
221
- async resendStudentInvite(input) {
232
+ async resendStudentInvite(actor, input) {
222
233
  const { orgId, orgUserId, inviterUserId } = input;
234
+ this.assertManagesUsers(actor, orgId);
223
235
  const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);
224
236
  if (!orgUser || orgUser.role !== "student") throw new NotFoundError("Student", orgUserId);
225
237
  if (orgUser.status === "active") throw new OrganizationRuleError("That student has already accepted their invitation");
@@ -270,9 +282,9 @@ var OrganizationServiceImpl = class {
270
282
  async acceptStudentInvite(invite, userId) {
271
283
  return this.uow.run(async ({ organizations, outbox }) => {
272
284
  const existing = await organizations.findOrgUser(invite.orgId, userId);
273
- if (!existing) throw new NotFoundError("OrgUser", userId);
285
+ if (!existing) throw new NotFoundError("Member", userId);
274
286
  const activated = await organizations.updateOrgUser(invite.orgId, existing.id, { status: "active" });
275
- if (!activated) throw new NotFoundError("OrgUser", existing.id);
287
+ if (!activated) throw new NotFoundError("Member", existing.id);
276
288
  const acceptedInvite = await organizations.setInviteStatus(invite.orgId, invite.id, "accepted");
277
289
  if (!acceptedInvite) throw new NotFoundError("Invite", invite.id);
278
290
  await outbox.append([organizationEvents.inviteAccepted.make({
@@ -291,7 +303,7 @@ var OrganizationServiceImpl = class {
291
303
  await this.orgAdmin().grantMembership(org.externalId ?? org.id, person.externalId, invite.role);
292
304
  return this.uow.run(async ({ organizations, outbox }) => {
293
305
  const orgUser = await organizations.findOrgUser(invite.orgId, userId);
294
- if (!orgUser) throw new NotFoundError("OrgUser", userId);
306
+ if (!orgUser) throw new NotFoundError("Member", userId);
295
307
  const acceptedInvite = await organizations.setInviteStatus(invite.orgId, invite.id, "accepted");
296
308
  if (!acceptedInvite) throw new NotFoundError("Invite", invite.id);
297
309
  await outbox.append([organizationEvents.inviteAccepted.make({
@@ -301,21 +313,27 @@ var OrganizationServiceImpl = class {
301
313
  return orgUser;
302
314
  });
303
315
  }
316
+ getOrgUserById(orgId, id) {
317
+ return this.repo.findOrgUserById(orgId, id);
318
+ }
319
+ async getOrgUsersByIds(orgId, ids) {
320
+ const unique = [...new Set(ids)];
321
+ return unique.length > 0 ? this.repo.findOrgUsersByIds(orgId, unique) : [];
322
+ }
304
323
  async getOrgUser(orgId, userId) {
305
324
  return this.repo.findOrgUser(orgId, userId);
306
325
  }
307
- async deleteOrgUser(orgId, id) {
326
+ async deleteOrgUser(orgId, actor, id) {
327
+ this.assertManagesUsers(actor, orgId);
308
328
  const deleted = await this.uow.run(async ({ organizations, outbox }) => {
309
329
  const orgUser = await organizations.findOrgUserById(orgId, id);
310
- if (!orgUser) throw new NotFoundError("OrgUser", id);
330
+ if (!orgUser || orgUser.role !== "student") throw new NotFoundError("Student", id);
311
331
  const deletedUser = await organizations.deleteOrgUser(orgId, id);
312
- if (!deletedUser) throw new NotFoundError("OrgUser", id);
313
- const events = [];
314
- if (deletedUser.role === "student") events.push(organizationEvents.studentDeleted.make({
332
+ if (!deletedUser) throw new NotFoundError("Student", id);
333
+ await outbox.append([organizationEvents.studentDeleted.make({
315
334
  orgId,
316
335
  data: orgUser
317
- }));
318
- await outbox.append(events);
336
+ })]);
319
337
  return deletedUser;
320
338
  });
321
339
  this.logger.info("org user deleted", {
@@ -330,6 +348,9 @@ var OrganizationServiceImpl = class {
330
348
  async getById(id) {
331
349
  return this.repo.findById(id);
332
350
  }
351
+ orgIds() {
352
+ return this.repo.listIds();
353
+ }
333
354
  async sendInviteEmail(invite, token) {
334
355
  if (!this.mailer || !this.inviteUrls) throw new Error("invite delivery is not configured (mailer / invite urls missing)");
335
356
  const { email, role } = invite;
@@ -354,12 +375,17 @@ var OrganizationServiceImpl = class {
354
375
  async getBySlug(slug) {
355
376
  return this.repo.findBySlug(slug);
356
377
  }
378
+ staffOrgUserIds(orgId) {
379
+ return this.repo.findOrgUserIdsByRole(orgId, [...STAFF_ROLES]);
380
+ }
357
381
  listMembers(orgId, query) {
358
382
  return this.membersRepo.list(orgId, query);
359
383
  }
360
- async updateMemberRole(ctx, id, role) {
384
+ async updateMemberRole(ctx, actor, id, role) {
385
+ this.assertManagesUsers(actor, ctx.orgId);
386
+ this.assertNotOwner(role, ctx.orgId);
361
387
  const member = await this.membersRepo.findById(ctx.orgId, id);
362
- if (!member) return null;
388
+ if (!member) return this.promoteStudent(ctx, actor, id, role);
363
389
  if (member.role === "owner") {
364
390
  this.logger.warn("role change rejected: owner role immutable", {
365
391
  orgId: ctx.orgId,
@@ -374,7 +400,9 @@ var OrganizationServiceImpl = class {
374
400
  });
375
401
  throw new OrganizationRuleError("Only active members can have their role changed");
376
402
  }
377
- await this.orgAdmin().updateRole(ctx, member.userExternalId, role);
403
+ if (role === "student") return this.demoteModerator(ctx, actor, member, member.userExternalId);
404
+ const userExternalId = member.userExternalId;
405
+ await this.asActor(actor, id, false, () => this.orgAdmin().updateRole(ctx, userExternalId, role));
378
406
  const updated = await this.membersRepo.findById(ctx.orgId, id);
379
407
  this.logger.info("member role updated", {
380
408
  orgId: ctx.orgId,
@@ -383,7 +411,96 @@ var OrganizationServiceImpl = class {
383
411
  });
384
412
  return updated ? toMember(updated) : null;
385
413
  }
386
- async removeMember(ctx, id) {
414
+ async demoteModerator(ctx, actor, member, userExternalId) {
415
+ if (member.role !== "moderator") {
416
+ this.logger.warn("role change rejected: only a moderator becomes a student", {
417
+ orgId: ctx.orgId,
418
+ memberId: member.id
419
+ });
420
+ throw new OrganizationRuleError("Only a moderator can be made a student");
421
+ }
422
+ await this.asActor(actor, member.id, true, () => this.orgAdmin().removeMember(ctx, userExternalId));
423
+ const orgUser = await this.repo.findOrgUserById(ctx.orgId, member.id);
424
+ if (!orgUser) return null;
425
+ this.logger.info("moderator made a student", {
426
+ orgId: ctx.orgId,
427
+ memberId: member.id
428
+ });
429
+ return {
430
+ ...toMember(member),
431
+ role: orgUser.role,
432
+ status: orgUser.status
433
+ };
434
+ }
435
+ async promoteStudent(ctx, actor, id, role) {
436
+ const orgUser = await this.repo.findOrgUserById(ctx.orgId, id);
437
+ if (!orgUser) return null;
438
+ if (orgUser.role !== "student" || role === "student") {
439
+ this.logger.warn("role change rejected: not an active member", {
440
+ orgId: ctx.orgId,
441
+ memberId: id
442
+ });
443
+ throw new OrganizationRuleError("Only active members can have their role changed");
444
+ }
445
+ const person = await this.people.getUserById(orgUser.userId);
446
+ if (!person?.externalId) {
447
+ this.logger.warn("role change rejected: student has no account", {
448
+ orgId: ctx.orgId,
449
+ memberId: id
450
+ });
451
+ throw new OrganizationRuleError("That student has not joined yet");
452
+ }
453
+ const org = await this.repo.findById(ctx.orgId);
454
+ if (!org) return null;
455
+ const orgExternalId = org.externalId ?? org.id;
456
+ const userExternalId = person.externalId;
457
+ await this.asActor(actor, orgUser.id, false, async () => {
458
+ await this.orgAdmin().grantMembership(orgExternalId, userExternalId, role);
459
+ if (!await this.membersRepo.findById(ctx.orgId, id)) await this.orgAdmin().updateRole(ctx, userExternalId, role);
460
+ });
461
+ const updated = await this.membersRepo.findById(ctx.orgId, id);
462
+ if (!updated) {
463
+ this.logger.warn("role change rejected: membership not mirrored", {
464
+ orgId: ctx.orgId,
465
+ memberId: id
466
+ });
467
+ throw new OrganizationRuleError("The role change did not take effect");
468
+ }
469
+ this.logger.info("student promoted", {
470
+ orgId: ctx.orgId,
471
+ memberId: id,
472
+ role
473
+ });
474
+ return toMember(updated);
475
+ }
476
+ asActor(actor, orgUserId, demote, write) {
477
+ return this.memberWrites.run({
478
+ orgUserId,
479
+ actorOrgUserId: actor.id,
480
+ demote
481
+ }, write);
482
+ }
483
+ memberWrite(orgUserId) {
484
+ const write = this.memberWrites.getStore();
485
+ return write?.orgUserId === orgUserId ? write : void 0;
486
+ }
487
+ assertManagesUsers(actor, orgId) {
488
+ if (capability(actor.role, "manage_users") !== true) {
489
+ this.logger.warn("member write rejected: actor cannot manage users", {
490
+ orgId,
491
+ actorOrgUserId: actor.id
492
+ });
493
+ throw new ForbiddenError("Only an owner or admin can manage members");
494
+ }
495
+ }
496
+ assertNotOwner(role, orgId) {
497
+ if (role === "owner") {
498
+ this.logger.warn("member write rejected: owner cannot be granted", { orgId });
499
+ throw new OrganizationRuleError("Ownership cannot be granted");
500
+ }
501
+ }
502
+ async removeMember(ctx, actor, id) {
503
+ this.assertManagesUsers(actor, ctx.orgId);
387
504
  const member = await this.membersRepo.findById(ctx.orgId, id);
388
505
  if (!member) return false;
389
506
  if (member.role === "owner") {