@headless-lms/core 0.2.0 → 0.3.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 (225) hide show
  1. package/dist/assets/events.d.ts +39 -21
  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 +5 -4
  8. package/dist/assets/index.js +3 -2
  9. package/dist/assets/model.d.ts +2 -2
  10. package/dist/assets/ports.d.ts +45 -15
  11. package/dist/assets/service.d.ts +39 -7
  12. package/dist/assets/service.js +291 -38
  13. package/dist/assets/service.js.map +1 -1
  14. package/dist/automations/actions.d.ts +13 -24
  15. package/dist/automations/actions.js +168 -43
  16. package/dist/automations/actions.js.map +1 -1
  17. package/dist/automations/catalog.d.ts +2 -1
  18. package/dist/automations/catalog.js +229 -13
  19. package/dist/automations/catalog.js.map +1 -1
  20. package/dist/automations/community-derivations.d.ts +14 -0
  21. package/dist/automations/community-derivations.js +439 -0
  22. package/dist/automations/community-derivations.js.map +1 -0
  23. package/dist/automations/community-mail.d.ts +112 -0
  24. package/dist/automations/community-mail.js +90 -0
  25. package/dist/automations/community-mail.js.map +1 -0
  26. package/dist/automations/community-notifications.d.ts +16 -0
  27. package/dist/automations/community-notifications.js +84 -0
  28. package/dist/automations/community-notifications.js.map +1 -0
  29. package/dist/automations/events.d.ts +28 -0
  30. package/dist/automations/index.d.ts +4 -2
  31. package/dist/automations/index.js +3 -2
  32. package/dist/automations/model.d.ts +10 -3
  33. package/dist/automations/model.js +15 -2
  34. package/dist/automations/model.js.map +1 -1
  35. package/dist/automations/ports.d.ts +5 -1
  36. package/dist/automations/schedule.d.ts +6 -0
  37. package/dist/automations/schedule.js +41 -0
  38. package/dist/automations/schedule.js.map +1 -0
  39. package/dist/automations/seeds.d.ts +17 -0
  40. package/dist/automations/seeds.js +235 -0
  41. package/dist/automations/seeds.js.map +1 -0
  42. package/dist/automations/service.d.ts +22 -3
  43. package/dist/automations/service.js +306 -43
  44. package/dist/automations/service.js.map +1 -1
  45. package/dist/content/events.d.ts +631 -19
  46. package/dist/content/events.js +44 -1
  47. package/dist/content/events.js.map +1 -1
  48. package/dist/content/index.d.ts +4 -4
  49. package/dist/content/model.d.ts +2 -2
  50. package/dist/content/ports.d.ts +71 -18
  51. package/dist/content/service.d.ts +57 -18
  52. package/dist/content/service.js +502 -23
  53. package/dist/content/service.js.map +1 -1
  54. package/dist/content/types.d.ts +2 -2
  55. package/dist/discussion/authors.d.ts +10 -0
  56. package/dist/discussion/authors.js +23 -0
  57. package/dist/discussion/authors.js.map +1 -0
  58. package/dist/discussion/chats.d.ts +104 -0
  59. package/dist/discussion/chats.js +798 -0
  60. package/dist/discussion/chats.js.map +1 -0
  61. package/dist/discussion/events.d.ts +619 -56
  62. package/dist/discussion/events.js +106 -3
  63. package/dist/discussion/events.js.map +1 -1
  64. package/dist/discussion/index.d.ts +7 -3
  65. package/dist/discussion/index.js +4 -1
  66. package/dist/discussion/ports.d.ts +288 -27
  67. package/dist/discussion/ports.js +6 -1
  68. package/dist/discussion/ports.js.map +1 -0
  69. package/dist/discussion/posts.d.ts +103 -0
  70. package/dist/discussion/posts.js +723 -0
  71. package/dist/discussion/posts.js.map +1 -0
  72. package/dist/discussion/service.d.ts +32 -10
  73. package/dist/discussion/service.js +293 -78
  74. package/dist/discussion/service.js.map +1 -1
  75. package/dist/entitlements/events.d.ts +178 -11
  76. package/dist/entitlements/events.js +28 -1
  77. package/dist/entitlements/events.js.map +1 -1
  78. package/dist/entitlements/index.d.ts +5 -4
  79. package/dist/entitlements/index.js +2 -1
  80. package/dist/entitlements/model.d.ts +3 -2
  81. package/dist/entitlements/ports.d.ts +124 -11
  82. package/dist/entitlements/rules.d.ts +12 -0
  83. package/dist/entitlements/rules.js +17 -0
  84. package/dist/entitlements/rules.js.map +1 -0
  85. package/dist/entitlements/service.d.ts +54 -3
  86. package/dist/entitlements/service.js +472 -2
  87. package/dist/entitlements/service.js.map +1 -1
  88. package/dist/identity/access.d.ts +6 -0
  89. package/dist/identity/access.js +7 -0
  90. package/dist/identity/access.js.map +1 -0
  91. package/dist/identity/index.d.ts +6 -4
  92. package/dist/identity/index.js +3 -1
  93. package/dist/identity/model.d.ts +2 -1
  94. package/dist/identity/notifications.d.ts +27 -0
  95. package/dist/identity/notifications.js +101 -0
  96. package/dist/identity/notifications.js.map +1 -0
  97. package/dist/identity/ports.d.ts +73 -3
  98. package/dist/identity/service.d.ts +14 -3
  99. package/dist/identity/service.js +62 -5
  100. package/dist/identity/service.js.map +1 -1
  101. package/dist/integrations/model.js +1 -1
  102. package/dist/integrations/model.js.map +1 -1
  103. package/dist/integrations/service.js +2 -2
  104. package/dist/integrations/service.js.map +1 -1
  105. package/dist/organizations/access-scopes.d.ts +19 -0
  106. package/dist/organizations/access-scopes.js +89 -0
  107. package/dist/organizations/access-scopes.js.map +1 -0
  108. package/dist/organizations/events.d.ts +74 -32
  109. package/dist/organizations/events.js +6 -0
  110. package/dist/organizations/events.js.map +1 -1
  111. package/dist/organizations/index.d.ts +7 -6
  112. package/dist/organizations/index.js +4 -3
  113. package/dist/organizations/members.d.ts +8 -5
  114. package/dist/organizations/members.js.map +1 -1
  115. package/dist/organizations/ports.d.ts +14 -11
  116. package/dist/organizations/roles.d.ts +6 -10
  117. package/dist/organizations/roles.js +19 -7
  118. package/dist/organizations/roles.js.map +1 -1
  119. package/dist/organizations/service.d.ts +20 -11
  120. package/dist/organizations/service.js +157 -47
  121. package/dist/organizations/service.js.map +1 -1
  122. package/dist/organizations/types.d.ts +1 -10
  123. package/dist/progress/index.d.ts +2 -2
  124. package/dist/progress/ports.d.ts +6 -1
  125. package/dist/progress/service.d.ts +3 -1
  126. package/dist/progress/service.js +6 -2
  127. package/dist/progress/service.js.map +1 -1
  128. package/dist/reporting/admin/index.d.ts +4 -0
  129. package/dist/reporting/admin/index.js +2 -0
  130. package/dist/reporting/admin/model.d.ts +108 -0
  131. package/dist/reporting/admin/ports.d.ts +27 -0
  132. package/dist/reporting/admin/service.d.ts +22 -0
  133. package/dist/reporting/admin/service.js +38 -0
  134. package/dist/reporting/admin/service.js.map +1 -0
  135. package/dist/reporting/member/index.d.ts +5 -0
  136. package/dist/reporting/member/index.js +2 -0
  137. package/dist/reporting/member/model.d.ts +124 -0
  138. package/dist/reporting/member/ports.d.ts +45 -0
  139. package/dist/reporting/member/service.d.ts +73 -0
  140. package/dist/reporting/member/service.js +286 -0
  141. package/dist/reporting/member/service.js.map +1 -0
  142. package/dist/shared/actor.d.ts +9 -0
  143. package/dist/shared/errors.d.ts +7 -1
  144. package/dist/shared/errors.js +10 -1
  145. package/dist/shared/errors.js.map +1 -1
  146. package/dist/shared/id.d.ts +11 -0
  147. package/dist/shared/id.js +12 -1
  148. package/dist/shared/id.js.map +1 -1
  149. package/dist/shared/mailer.d.ts +4 -1
  150. package/dist/shared/mailer.js.map +1 -1
  151. package/dist/shared/network.d.ts +10 -0
  152. package/dist/shared/network.js +211 -0
  153. package/dist/shared/network.js.map +1 -0
  154. package/dist/types/assets.d.ts +2 -2
  155. package/dist/types/automations.d.ts +10 -2
  156. package/dist/types/content.d.ts +2 -2
  157. package/dist/types/discussion.d.ts +11 -3
  158. package/dist/types/email-templates.d.ts +121 -0
  159. package/dist/types/entitlements.d.ts +2 -2
  160. package/dist/types/identity.d.ts +48 -2
  161. package/dist/types/index.d.ts +9 -9
  162. package/dist/types/ports.d.ts +6 -0
  163. package/dist/types/schemas/assets.d.ts +65 -37
  164. package/dist/types/schemas/assets.js +17 -12
  165. package/dist/types/schemas/assets.js.map +1 -1
  166. package/dist/types/schemas/automations.d.ts +18 -1
  167. package/dist/types/schemas/automations.js +15 -6
  168. package/dist/types/schemas/automations.js.map +1 -1
  169. package/dist/types/schemas/content.d.ts +335 -3
  170. package/dist/types/schemas/content.js +139 -3
  171. package/dist/types/schemas/content.js.map +1 -1
  172. package/dist/types/schemas/discussion.d.ts +370 -3
  173. package/dist/types/schemas/discussion.js +199 -4
  174. package/dist/types/schemas/discussion.js.map +1 -1
  175. package/dist/types/schemas/entitlements.d.ts +36 -2
  176. package/dist/types/schemas/entitlements.js +23 -1
  177. package/dist/types/schemas/entitlements.js.map +1 -1
  178. package/dist/types/schemas/identity.d.ts +111 -1
  179. package/dist/types/schemas/identity.js +55 -2
  180. package/dist/types/schemas/identity.js.map +1 -1
  181. package/dist/types/schemas/index.d.ts +8 -8
  182. package/dist/types/schemas/index.js +8 -8
  183. package/dist/types/schemas/organizations.d.ts +8 -0
  184. package/dist/types/schemas/organizations.js +2 -0
  185. package/dist/types/schemas/organizations.js.map +1 -1
  186. package/dist/types/schemas/shared.d.ts +3 -1
  187. package/dist/types/schemas/shared.js +5 -1
  188. package/dist/types/schemas/shared.js.map +1 -1
  189. package/package.json +2 -2
  190. package/dist/reporting/courses/index.d.ts +0 -4
  191. package/dist/reporting/courses/index.js +0 -2
  192. package/dist/reporting/courses/model.d.ts +0 -24
  193. package/dist/reporting/courses/ports.d.ts +0 -13
  194. package/dist/reporting/courses/service.d.ts +0 -19
  195. package/dist/reporting/courses/service.js +0 -20
  196. package/dist/reporting/courses/service.js.map +0 -1
  197. package/dist/reporting/dashboard/index.d.ts +0 -4
  198. package/dist/reporting/dashboard/index.js +0 -2
  199. package/dist/reporting/dashboard/model.d.ts +0 -14
  200. package/dist/reporting/dashboard/ports.d.ts +0 -13
  201. package/dist/reporting/dashboard/service.d.ts +0 -19
  202. package/dist/reporting/dashboard/service.js +0 -20
  203. package/dist/reporting/dashboard/service.js.map +0 -1
  204. package/dist/reporting/learn/index.d.ts +0 -5
  205. package/dist/reporting/learn/index.js +0 -2
  206. package/dist/reporting/learn/model.d.ts +0 -23
  207. package/dist/reporting/learn/ports.d.ts +0 -43
  208. package/dist/reporting/learn/ports.js +0 -1
  209. package/dist/reporting/learn/service.d.ts +0 -49
  210. package/dist/reporting/learn/service.js +0 -116
  211. package/dist/reporting/learn/service.js.map +0 -1
  212. package/dist/reporting/students/index.d.ts +0 -4
  213. package/dist/reporting/students/index.js +0 -2
  214. package/dist/reporting/students/model.d.ts +0 -51
  215. package/dist/reporting/students/model.js +0 -1
  216. package/dist/reporting/students/ports.d.ts +0 -15
  217. package/dist/reporting/students/ports.js +0 -1
  218. package/dist/reporting/students/service.d.ts +0 -20
  219. package/dist/reporting/students/service.js +0 -23
  220. package/dist/reporting/students/service.js.map +0 -1
  221. /package/dist/reporting/{courses → admin}/model.js +0 -0
  222. /package/dist/reporting/{courses → admin}/ports.js +0 -0
  223. /package/dist/reporting/{dashboard → member}/model.js +0 -0
  224. /package/dist/reporting/{dashboard → member}/ports.js +0 -0
  225. /package/dist/{reporting/learn/model.js → shared/actor.js} +0 -0
@@ -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,30 @@ 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>;
50
52
  getOrgUser(orgId: string, userId: string): Promise<OrgUser | null>;
51
- deleteOrgUser(orgId: string, id: string): Promise<OrgUser>;
53
+ deleteOrgUser(orgId: string, actor: Actor, id: string): Promise<OrgUser>;
52
54
  getOrgUsersForUser(userId: string): Promise<OrgUser[]>;
53
55
  getById(id: string): Promise<Organization | null>;
54
56
  private sendInviteEmail;
55
57
  getByExternalId(externalId: string): Promise<Organization | null>;
56
58
  getBySlug(slug: string): Promise<Organization | null>;
59
+ staffOrgUserIds(orgId: string): Promise<string[]>;
57
60
  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>;
61
+ updateMemberRole(ctx: MemberWriteContext, actor: Actor, id: string, role: Role): Promise<MemberRoleChange | null>;
62
+ private demoteModerator;
63
+ private promoteStudent;
64
+ private asActor;
65
+ private memberWrite;
66
+ private assertManagesUsers;
67
+ private assertNotOwner;
68
+ removeMember(ctx: MemberWriteContext, actor: Actor, id: string): Promise<boolean>;
60
69
  }
61
70
  //#endregion
62
71
  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,23 @@ var OrganizationServiceImpl = class {
301
313
  return orgUser;
302
314
  });
303
315
  }
316
+ getOrgUserById(orgId, id) {
317
+ return this.repo.findOrgUserById(orgId, id);
318
+ }
304
319
  async getOrgUser(orgId, userId) {
305
320
  return this.repo.findOrgUser(orgId, userId);
306
321
  }
307
- async deleteOrgUser(orgId, id) {
322
+ async deleteOrgUser(orgId, actor, id) {
323
+ this.assertManagesUsers(actor, orgId);
308
324
  const deleted = await this.uow.run(async ({ organizations, outbox }) => {
309
325
  const orgUser = await organizations.findOrgUserById(orgId, id);
310
- if (!orgUser) throw new NotFoundError("OrgUser", id);
326
+ if (!orgUser || orgUser.role !== "student") throw new NotFoundError("Student", id);
311
327
  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({
328
+ if (!deletedUser) throw new NotFoundError("Student", id);
329
+ await outbox.append([organizationEvents.studentDeleted.make({
315
330
  orgId,
316
331
  data: orgUser
317
- }));
318
- await outbox.append(events);
332
+ })]);
319
333
  return deletedUser;
320
334
  });
321
335
  this.logger.info("org user deleted", {
@@ -354,12 +368,17 @@ var OrganizationServiceImpl = class {
354
368
  async getBySlug(slug) {
355
369
  return this.repo.findBySlug(slug);
356
370
  }
371
+ staffOrgUserIds(orgId) {
372
+ return this.repo.findOrgUserIdsByRole(orgId, [...STAFF_ROLES]);
373
+ }
357
374
  listMembers(orgId, query) {
358
375
  return this.membersRepo.list(orgId, query);
359
376
  }
360
- async updateMemberRole(ctx, id, role) {
377
+ async updateMemberRole(ctx, actor, id, role) {
378
+ this.assertManagesUsers(actor, ctx.orgId);
379
+ this.assertNotOwner(role, ctx.orgId);
361
380
  const member = await this.membersRepo.findById(ctx.orgId, id);
362
- if (!member) return null;
381
+ if (!member) return this.promoteStudent(ctx, actor, id, role);
363
382
  if (member.role === "owner") {
364
383
  this.logger.warn("role change rejected: owner role immutable", {
365
384
  orgId: ctx.orgId,
@@ -374,7 +393,9 @@ var OrganizationServiceImpl = class {
374
393
  });
375
394
  throw new OrganizationRuleError("Only active members can have their role changed");
376
395
  }
377
- await this.orgAdmin().updateRole(ctx, member.userExternalId, role);
396
+ if (role === "student") return this.demoteModerator(ctx, actor, member, member.userExternalId);
397
+ const userExternalId = member.userExternalId;
398
+ await this.asActor(actor, id, false, () => this.orgAdmin().updateRole(ctx, userExternalId, role));
378
399
  const updated = await this.membersRepo.findById(ctx.orgId, id);
379
400
  this.logger.info("member role updated", {
380
401
  orgId: ctx.orgId,
@@ -383,7 +404,96 @@ var OrganizationServiceImpl = class {
383
404
  });
384
405
  return updated ? toMember(updated) : null;
385
406
  }
386
- async removeMember(ctx, id) {
407
+ async demoteModerator(ctx, actor, member, userExternalId) {
408
+ if (member.role !== "moderator") {
409
+ this.logger.warn("role change rejected: only a moderator becomes a student", {
410
+ orgId: ctx.orgId,
411
+ memberId: member.id
412
+ });
413
+ throw new OrganizationRuleError("Only a moderator can be made a student");
414
+ }
415
+ await this.asActor(actor, member.id, true, () => this.orgAdmin().removeMember(ctx, userExternalId));
416
+ const orgUser = await this.repo.findOrgUserById(ctx.orgId, member.id);
417
+ if (!orgUser) return null;
418
+ this.logger.info("moderator made a student", {
419
+ orgId: ctx.orgId,
420
+ memberId: member.id
421
+ });
422
+ return {
423
+ ...toMember(member),
424
+ role: orgUser.role,
425
+ status: orgUser.status
426
+ };
427
+ }
428
+ async promoteStudent(ctx, actor, id, role) {
429
+ const orgUser = await this.repo.findOrgUserById(ctx.orgId, id);
430
+ if (!orgUser) return null;
431
+ if (orgUser.role !== "student" || role === "student") {
432
+ this.logger.warn("role change rejected: not an active member", {
433
+ orgId: ctx.orgId,
434
+ memberId: id
435
+ });
436
+ throw new OrganizationRuleError("Only active members can have their role changed");
437
+ }
438
+ const person = await this.people.getUserById(orgUser.userId);
439
+ if (!person?.externalId) {
440
+ this.logger.warn("role change rejected: student has no account", {
441
+ orgId: ctx.orgId,
442
+ memberId: id
443
+ });
444
+ throw new OrganizationRuleError("That student has not joined yet");
445
+ }
446
+ const org = await this.repo.findById(ctx.orgId);
447
+ if (!org) return null;
448
+ const orgExternalId = org.externalId ?? org.id;
449
+ const userExternalId = person.externalId;
450
+ await this.asActor(actor, orgUser.id, false, async () => {
451
+ await this.orgAdmin().grantMembership(orgExternalId, userExternalId, role);
452
+ if (!await this.membersRepo.findById(ctx.orgId, id)) await this.orgAdmin().updateRole(ctx, userExternalId, role);
453
+ });
454
+ const updated = await this.membersRepo.findById(ctx.orgId, id);
455
+ if (!updated) {
456
+ this.logger.warn("role change rejected: membership not mirrored", {
457
+ orgId: ctx.orgId,
458
+ memberId: id
459
+ });
460
+ throw new OrganizationRuleError("The role change did not take effect");
461
+ }
462
+ this.logger.info("student promoted", {
463
+ orgId: ctx.orgId,
464
+ memberId: id,
465
+ role
466
+ });
467
+ return toMember(updated);
468
+ }
469
+ asActor(actor, orgUserId, demote, write) {
470
+ return this.memberWrites.run({
471
+ orgUserId,
472
+ actorOrgUserId: actor.id,
473
+ demote
474
+ }, write);
475
+ }
476
+ memberWrite(orgUserId) {
477
+ const write = this.memberWrites.getStore();
478
+ return write?.orgUserId === orgUserId ? write : void 0;
479
+ }
480
+ assertManagesUsers(actor, orgId) {
481
+ if (capability(actor.role, "manage_users") !== true) {
482
+ this.logger.warn("member write rejected: actor cannot manage users", {
483
+ orgId,
484
+ actorOrgUserId: actor.id
485
+ });
486
+ throw new ForbiddenError("Only an owner or admin can manage members");
487
+ }
488
+ }
489
+ assertNotOwner(role, orgId) {
490
+ if (role === "owner") {
491
+ this.logger.warn("member write rejected: owner cannot be granted", { orgId });
492
+ throw new OrganizationRuleError("Ownership cannot be granted");
493
+ }
494
+ }
495
+ async removeMember(ctx, actor, id) {
496
+ this.assertManagesUsers(actor, ctx.orgId);
387
497
  const member = await this.membersRepo.findById(ctx.orgId, id);
388
498
  if (!member) return false;
389
499
  if (member.role === "owner") {