@headless-lms/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/dist/assets/events.d.ts +24 -18
  2. package/dist/assets/file-urls.d.ts +11 -7
  3. package/dist/assets/file-urls.js +14 -4
  4. package/dist/assets/file-urls.js.map +1 -1
  5. package/dist/assets/index.d.ts +5 -4
  6. package/dist/assets/index.js +4 -3
  7. package/dist/assets/jobs.d.ts +15 -0
  8. package/dist/assets/jobs.js +30 -0
  9. package/dist/assets/jobs.js.map +1 -0
  10. package/dist/assets/ports.d.ts +37 -17
  11. package/dist/assets/service.d.ts +30 -15
  12. package/dist/assets/service.js +229 -59
  13. package/dist/assets/service.js.map +1 -1
  14. package/dist/automations/actions.js +2 -63
  15. package/dist/automations/actions.js.map +1 -1
  16. package/dist/automations/catalog.d.ts +1 -2
  17. package/dist/automations/catalog.js +137 -65
  18. package/dist/automations/catalog.js.map +1 -1
  19. package/dist/automations/community-derivations.js +20 -20
  20. package/dist/automations/community-derivations.js.map +1 -1
  21. package/dist/automations/community-mail.d.ts +36 -20
  22. package/dist/automations/community-mail.js +32 -24
  23. package/dist/automations/community-mail.js.map +1 -1
  24. package/dist/automations/event-permissions.d.ts +12 -0
  25. package/dist/automations/event-permissions.js +38 -0
  26. package/dist/automations/event-permissions.js.map +1 -0
  27. package/dist/automations/events.d.ts +0 -10
  28. package/dist/automations/index.d.ts +12 -4
  29. package/dist/automations/index.js +9 -1
  30. package/dist/automations/notifications/applicable.d.ts +8 -0
  31. package/dist/automations/notifications/applicable.js +12 -0
  32. package/dist/automations/notifications/applicable.js.map +1 -0
  33. package/dist/automations/notifications/debounce.d.ts +25 -0
  34. package/dist/automations/notifications/debounce.js +93 -0
  35. package/dist/automations/notifications/debounce.js.map +1 -0
  36. package/dist/automations/notifications/defaults.d.ts +8 -0
  37. package/dist/automations/notifications/defaults.js +346 -0
  38. package/dist/automations/notifications/defaults.js.map +1 -0
  39. package/dist/automations/notifications/delivery.d.ts +28 -0
  40. package/dist/automations/notifications/delivery.js +88 -0
  41. package/dist/automations/notifications/delivery.js.map +1 -0
  42. package/dist/automations/notifications/processor.d.ts +64 -0
  43. package/dist/automations/notifications/processor.js +270 -0
  44. package/dist/automations/notifications/processor.js.map +1 -0
  45. package/dist/automations/notifications/rules/content.d.ts +6 -0
  46. package/dist/automations/notifications/rules/content.js +96 -0
  47. package/dist/automations/notifications/rules/content.js.map +1 -0
  48. package/dist/automations/notifications/rules/discussion.d.ts +7 -0
  49. package/dist/automations/notifications/rules/discussion.js +678 -0
  50. package/dist/automations/notifications/rules/discussion.js.map +1 -0
  51. package/dist/automations/notifications/rules/entitlements.d.ts +6 -0
  52. package/dist/automations/notifications/rules/entitlements.js +276 -0
  53. package/dist/automations/notifications/rules/entitlements.js.map +1 -0
  54. package/dist/automations/notifications/rules/events.d.ts +9 -0
  55. package/dist/automations/notifications/rules/events.js +27 -0
  56. package/dist/automations/notifications/rules/events.js.map +1 -0
  57. package/dist/automations/notifications/rules/index.d.ts +6 -0
  58. package/dist/automations/notifications/rules/index.js +15 -0
  59. package/dist/automations/notifications/rules/index.js.map +1 -0
  60. package/dist/automations/notifications/rules/organizations.d.ts +6 -0
  61. package/dist/automations/notifications/rules/organizations.js +35 -0
  62. package/dist/automations/notifications/rules/organizations.js.map +1 -0
  63. package/dist/automations/notifications/rules/recipients.d.ts +32 -0
  64. package/dist/automations/notifications/rules/recipients.js +98 -0
  65. package/dist/automations/notifications/rules/recipients.js.map +1 -0
  66. package/dist/automations/notifications/service.d.ts +29 -0
  67. package/dist/automations/notifications/service.js +45 -0
  68. package/dist/automations/notifications/service.js.map +1 -0
  69. package/dist/automations/notifications/types.d.ts +7 -0
  70. package/dist/automations/notifications/types.js +560 -0
  71. package/dist/automations/notifications/types.js.map +1 -0
  72. package/dist/automations/ports.d.ts +134 -21
  73. package/dist/automations/service.d.ts +30 -30
  74. package/dist/automations/service.js +111 -144
  75. package/dist/automations/service.js.map +1 -1
  76. package/dist/content/event-sight.d.ts +11 -0
  77. package/dist/content/event-sight.js +11 -0
  78. package/dist/content/event-sight.js.map +1 -0
  79. package/dist/content/events.d.ts +437 -46
  80. package/dist/content/events.js +45 -1
  81. package/dist/content/events.js.map +1 -1
  82. package/dist/content/index.d.ts +8 -4
  83. package/dist/content/index.js +5 -1
  84. package/dist/content/jobs.d.ts +16 -0
  85. package/dist/content/jobs.js +37 -0
  86. package/dist/content/jobs.js.map +1 -0
  87. package/dist/content/model.d.ts +2 -2
  88. package/dist/content/notification-categories.d.ts +37 -0
  89. package/dist/content/notification-categories.js +73 -0
  90. package/dist/content/notification-categories.js.map +1 -0
  91. package/dist/content/ports.d.ts +132 -68
  92. package/dist/content/service.d.ts +100 -68
  93. package/dist/content/service.js +703 -146
  94. package/dist/content/service.js.map +1 -1
  95. package/dist/content/titles.d.ts +25 -0
  96. package/dist/content/titles.js +82 -0
  97. package/dist/content/titles.js.map +1 -0
  98. package/dist/content/types.d.ts +2 -2
  99. package/dist/discussion/authors.d.ts +4 -2
  100. package/dist/discussion/authors.js +9 -5
  101. package/dist/discussion/authors.js.map +1 -1
  102. package/dist/discussion/chats.d.ts +63 -39
  103. package/dist/discussion/chats.js +455 -190
  104. package/dist/discussion/chats.js.map +1 -1
  105. package/dist/discussion/digests.d.ts +24 -0
  106. package/dist/discussion/digests.js +104 -0
  107. package/dist/discussion/digests.js.map +1 -0
  108. package/dist/discussion/events.d.ts +227 -23
  109. package/dist/discussion/events.js +114 -19
  110. package/dist/discussion/events.js.map +1 -1
  111. package/dist/discussion/index.d.ts +12 -4
  112. package/dist/discussion/index.js +9 -2
  113. package/dist/discussion/jobs.d.ts +16 -0
  114. package/dist/discussion/jobs.js +40 -0
  115. package/dist/discussion/jobs.js.map +1 -0
  116. package/dist/discussion/live.d.ts +106 -0
  117. package/dist/discussion/live.js +642 -0
  118. package/dist/discussion/live.js.map +1 -0
  119. package/dist/discussion/mentions.d.ts +11 -0
  120. package/dist/discussion/mentions.js +35 -0
  121. package/dist/discussion/mentions.js.map +1 -0
  122. package/dist/discussion/ports.d.ts +279 -114
  123. package/dist/discussion/ports.js.map +1 -1
  124. package/dist/discussion/posts.d.ts +60 -44
  125. package/dist/discussion/posts.js +453 -287
  126. package/dist/discussion/posts.js.map +1 -1
  127. package/dist/discussion/rules.d.ts +18 -0
  128. package/dist/discussion/rules.js +22 -0
  129. package/dist/discussion/rules.js.map +1 -0
  130. package/dist/discussion/service.d.ts +50 -35
  131. package/dist/discussion/service.js +333 -205
  132. package/dist/discussion/service.js.map +1 -1
  133. package/dist/discussion/subscriptions.d.ts +37 -0
  134. package/dist/discussion/subscriptions.js +115 -0
  135. package/dist/discussion/subscriptions.js.map +1 -0
  136. package/dist/discussion/titles.d.ts +28 -0
  137. package/dist/discussion/titles.js +98 -0
  138. package/dist/discussion/titles.js.map +1 -0
  139. package/dist/entitlements/events.d.ts +20 -20
  140. package/dist/entitlements/index.d.ts +5 -4
  141. package/dist/entitlements/index.js +3 -2
  142. package/dist/entitlements/jobs.d.ts +12 -0
  143. package/dist/entitlements/jobs.js +18 -0
  144. package/dist/entitlements/jobs.js.map +1 -0
  145. package/dist/entitlements/model.d.ts +2 -2
  146. package/dist/entitlements/ports.d.ts +68 -38
  147. package/dist/entitlements/rules.d.ts +3 -6
  148. package/dist/entitlements/rules.js +5 -10
  149. package/dist/entitlements/rules.js.map +1 -1
  150. package/dist/entitlements/service.d.ts +43 -28
  151. package/dist/entitlements/service.js +387 -160
  152. package/dist/entitlements/service.js.map +1 -1
  153. package/dist/identity/devices.d.ts +30 -0
  154. package/dist/identity/devices.js +109 -0
  155. package/dist/identity/devices.js.map +1 -0
  156. package/dist/identity/index.d.ts +6 -5
  157. package/dist/identity/index.js +4 -3
  158. package/dist/identity/model.d.ts +2 -2
  159. package/dist/identity/notification-settings.d.ts +28 -0
  160. package/dist/identity/notification-settings.js +114 -0
  161. package/dist/identity/notification-settings.js.map +1 -0
  162. package/dist/identity/ports.d.ts +80 -56
  163. package/dist/identity/service.d.ts +8 -7
  164. package/dist/identity/service.js +17 -13
  165. package/dist/identity/service.js.map +1 -1
  166. package/dist/integrations/ports.d.ts +9 -8
  167. package/dist/integrations/service.d.ts +9 -8
  168. package/dist/integrations/service.js +33 -16
  169. package/dist/integrations/service.js.map +1 -1
  170. package/dist/organizations/default-roles.d.ts +22 -0
  171. package/dist/organizations/default-roles.js +89 -0
  172. package/dist/organizations/default-roles.js.map +1 -0
  173. package/dist/organizations/effective-permissions.d.ts +45 -0
  174. package/dist/organizations/effective-permissions.js +113 -0
  175. package/dist/organizations/effective-permissions.js.map +1 -0
  176. package/dist/organizations/events.d.ts +320 -181
  177. package/dist/organizations/events.js +90 -18
  178. package/dist/organizations/events.js.map +1 -1
  179. package/dist/organizations/index.d.ts +15 -10
  180. package/dist/organizations/index.js +8 -3
  181. package/dist/organizations/members.d.ts +66 -8
  182. package/dist/organizations/members.js.map +1 -1
  183. package/dist/organizations/notification-catalog.d.ts +31 -0
  184. package/dist/organizations/notification-catalog.js +32 -0
  185. package/dist/organizations/notification-catalog.js.map +1 -0
  186. package/dist/organizations/notification-categories.d.ts +34 -0
  187. package/dist/organizations/notification-categories.js +66 -0
  188. package/dist/organizations/notification-categories.js.map +1 -0
  189. package/dist/organizations/org-roles.d.ts +33 -0
  190. package/dist/organizations/org-roles.js +49 -0
  191. package/dist/organizations/org-roles.js.map +1 -0
  192. package/dist/organizations/permissions.d.ts +311 -0
  193. package/dist/organizations/permissions.js +327 -0
  194. package/dist/organizations/permissions.js.map +1 -0
  195. package/dist/organizations/ports.d.ts +187 -65
  196. package/dist/organizations/service.d.ts +104 -35
  197. package/dist/organizations/service.js +1247 -323
  198. package/dist/organizations/service.js.map +1 -1
  199. package/dist/organizations/testing.d.ts +18 -0
  200. package/dist/organizations/testing.js +37 -0
  201. package/dist/organizations/testing.js.map +1 -0
  202. package/dist/organizations/types.d.ts +5 -16
  203. package/dist/progress/index.d.ts +2 -2
  204. package/dist/progress/ports.d.ts +11 -3
  205. package/dist/progress/service.d.ts +6 -5
  206. package/dist/progress/service.js +23 -8
  207. package/dist/progress/service.js.map +1 -1
  208. package/dist/reporting/admin/index.d.ts +6 -4
  209. package/dist/reporting/admin/model.d.ts +36 -3
  210. package/dist/reporting/admin/ports.d.ts +39 -14
  211. package/dist/reporting/admin/service.d.ts +32 -11
  212. package/dist/reporting/admin/service.js +102 -18
  213. package/dist/reporting/admin/service.js.map +1 -1
  214. package/dist/reporting/events/index.d.ts +27 -0
  215. package/dist/reporting/events/index.js +20 -0
  216. package/dist/reporting/events/index.js.map +1 -0
  217. package/dist/reporting/member/calendar.d.ts +11 -0
  218. package/dist/reporting/member/calendar.js +75 -0
  219. package/dist/reporting/member/calendar.js.map +1 -0
  220. package/dist/reporting/member/index.d.ts +3 -2
  221. package/dist/reporting/member/model.d.ts +80 -26
  222. package/dist/reporting/member/ports.d.ts +50 -28
  223. package/dist/reporting/member/service.d.ts +81 -35
  224. package/dist/reporting/member/service.js +438 -145
  225. package/dist/reporting/member/service.js.map +1 -1
  226. package/dist/shared/actor.d.ts +22 -3
  227. package/dist/shared/errors.d.ts +15 -2
  228. package/dist/shared/errors.js +21 -2
  229. package/dist/shared/errors.js.map +1 -1
  230. package/dist/shared/id.d.ts +7 -0
  231. package/dist/shared/id.js +8 -1
  232. package/dist/shared/id.js.map +1 -1
  233. package/dist/shared/logger.d.ts +2 -1
  234. package/dist/shared/logger.js +10 -1
  235. package/dist/shared/logger.js.map +1 -1
  236. package/dist/shared/mailer.d.ts +11 -1
  237. package/dist/shared/mailer.js +32 -5
  238. package/dist/shared/mailer.js.map +1 -1
  239. package/dist/shared/notification-categories.d.ts +16 -0
  240. package/dist/shared/notification-categories.js +94 -0
  241. package/dist/shared/notification-categories.js.map +1 -0
  242. package/dist/shared/notification-settings.d.ts +14 -0
  243. package/dist/shared/notification-settings.js +61 -0
  244. package/dist/shared/notification-settings.js.map +1 -0
  245. package/dist/shared/ports.d.ts +68 -2
  246. package/dist/shared/settings.d.ts +2 -1
  247. package/dist/shared/settings.js +1 -0
  248. package/dist/shared/settings.js.map +1 -1
  249. package/dist/types/automations.d.ts +8 -2
  250. package/dist/types/content.d.ts +2 -2
  251. package/dist/types/discussion.d.ts +4 -5
  252. package/dist/types/email-templates.d.ts +119 -6
  253. package/dist/types/entitlements.d.ts +2 -2
  254. package/dist/types/identity.d.ts +14 -17
  255. package/dist/types/index.d.ts +13 -12
  256. package/dist/types/index.js +2 -1
  257. package/dist/types/notifications.d.ts +157 -0
  258. package/dist/types/notifications.js +1 -0
  259. package/dist/types/organizations.d.ts +3 -2
  260. package/dist/types/ports.d.ts +8 -1
  261. package/dist/types/ports.js +7 -1
  262. package/dist/types/ports.js.map +1 -0
  263. package/dist/types/schemas/assets.d.ts +5 -5
  264. package/dist/types/schemas/assets.js +4 -3
  265. package/dist/types/schemas/assets.js.map +1 -1
  266. package/dist/types/schemas/automations.d.ts +1 -10
  267. package/dist/types/schemas/automations.js +1 -3
  268. package/dist/types/schemas/automations.js.map +1 -1
  269. package/dist/types/schemas/content.d.ts +596 -11
  270. package/dist/types/schemas/content.js +225 -9
  271. package/dist/types/schemas/content.js.map +1 -1
  272. package/dist/types/schemas/discussion.d.ts +362 -66
  273. package/dist/types/schemas/discussion.js +123 -15
  274. package/dist/types/schemas/discussion.js.map +1 -1
  275. package/dist/types/schemas/entitlements.d.ts +14 -1
  276. package/dist/types/schemas/entitlements.js +11 -4
  277. package/dist/types/schemas/entitlements.js.map +1 -1
  278. package/dist/types/schemas/identity.d.ts +1 -85
  279. package/dist/types/schemas/identity.js +1 -42
  280. package/dist/types/schemas/identity.js.map +1 -1
  281. package/dist/types/schemas/index.d.ts +8 -7
  282. package/dist/types/schemas/index.js +8 -7
  283. package/dist/types/schemas/notifications.d.ts +96 -0
  284. package/dist/types/schemas/notifications.js +57 -0
  285. package/dist/types/schemas/notifications.js.map +1 -0
  286. package/dist/types/schemas/organizations.d.ts +96 -60
  287. package/dist/types/schemas/organizations.js +78 -27
  288. package/dist/types/schemas/organizations.js.map +1 -1
  289. package/dist/types/schemas/progress.d.ts +0 -1
  290. package/dist/types/schemas/progress.js +0 -1
  291. package/dist/types/schemas/progress.js.map +1 -1
  292. package/package.json +1 -1
  293. package/dist/automations/community-notifications.d.ts +0 -16
  294. package/dist/automations/community-notifications.js +0 -84
  295. package/dist/automations/community-notifications.js.map +0 -1
  296. package/dist/automations/seeds.d.ts +0 -17
  297. package/dist/automations/seeds.js +0 -235
  298. package/dist/automations/seeds.js.map +0 -1
  299. package/dist/identity/notifications.d.ts +0 -27
  300. package/dist/identity/notifications.js +0 -101
  301. package/dist/identity/notifications.js.map +0 -1
  302. package/dist/organizations/access-scopes.d.ts +0 -19
  303. package/dist/organizations/access-scopes.js +0 -89
  304. package/dist/organizations/access-scopes.js.map +0 -1
  305. package/dist/organizations/roles.d.ts +0 -19
  306. package/dist/organizations/roles.js +0 -82
  307. package/dist/organizations/roles.js.map +0 -1
@@ -1,61 +1,127 @@
1
- import { genId } from "../shared/id.js";
2
- import { ForbiddenError, NotFoundError } from "../shared/errors.js";
1
+ import { levelSchema } from "../types/schemas/organizations.js";
3
2
  import { noopLogger } from "../shared/logger.js";
4
- import { STAFF_ROLES, STUDENT_ROLE, capability } from "./roles.js";
3
+ import { ForbiddenError, NotFoundError } from "../shared/errors.js";
4
+ import { genId, prefixId } from "../shared/id.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
+ import { ALL_PERMISSIONS, fitsLevels, permissionAbout, permissionList, withImplied } from "./permissions.js";
9
+ import { DEFAULT_ROLES, MEMBER_ROLE_KEY, OWNER_ROLE_KEY, validateRoleSet } from "./default-roles.js";
10
+ import { catalogOf, isSystemRole, resolveOrgRoles, resolveRole } from "./org-roles.js";
11
+ import { ResolvedPermissions, resolvePermissions, systemPermissions } from "./effective-permissions.js";
9
12
  //#region src/organizations/service.ts
10
13
  const INVITE_TTL_MS = 10080 * 60 * 1e3;
11
- function toMember(r) {
14
+ const SYSTEM_ID = "system";
15
+ function snapshot(role) {
16
+ return {
17
+ key: role.key,
18
+ name: role.name,
19
+ description: role.description,
20
+ base: role.base,
21
+ levels: role.levels,
22
+ team: role.team,
23
+ permissions: role.permissions
24
+ };
25
+ }
26
+ function lockKeys(orgId, roleKeys, orgUserIds) {
27
+ return [...roleKeys.map((key) => `role:${orgId}:${key}`), ...orgUserIds.map((id) => `org-user:${orgId}:${id}`)];
28
+ }
29
+ function roleView(role, holders) {
12
30
  return {
13
- id: r.id,
14
- firstName: r.firstName,
15
- lastName: r.lastName,
16
- email: r.email,
17
- image: r.image,
18
- role: r.role,
19
- status: r.status,
20
- joinedAt: r.joinedAt,
21
- invitedAt: r.invitedAt
31
+ ...role,
32
+ holders,
33
+ givable: role.key !== OWNER_ROLE_KEY
22
34
  };
23
35
  }
36
+ function keySummary(key, roles) {
37
+ return {
38
+ id: key.id,
39
+ name: key.name,
40
+ start: key.start,
41
+ role: key.roleKey ? {
42
+ key: key.roleKey,
43
+ name: roles.get(key.roleKey)?.name ?? null
44
+ } : null,
45
+ createdAt: key.createdAt,
46
+ lastUsedAt: key.lastUsedAt,
47
+ expiresAt: key.expiresAt
48
+ };
49
+ }
50
+ function limitScopes(limit, roles) {
51
+ if (!limit) return;
52
+ if ("scopes" in limit) return limit.scopes;
53
+ return roles.get(limit.roleKey)?.permissions ?? [];
54
+ }
24
55
  var OrganizationServiceImpl = class {
25
56
  repo;
57
+ roles;
26
58
  membersRepo;
27
59
  people;
60
+ images;
28
61
  uow;
29
62
  orgAdmin;
63
+ sessions;
64
+ apiKeysPort;
65
+ accountsPort;
66
+ platform;
67
+ grants;
68
+ catalog;
30
69
  logger;
31
70
  mailer;
32
71
  inviteUrls;
33
- memberWrites = new AsyncLocalStorage();
34
72
  constructor(params) {
35
73
  this.repo = params.repo;
74
+ this.roles = params.roles;
36
75
  this.membersRepo = params.membersRepo;
37
76
  this.people = params.people;
77
+ this.images = params.images;
38
78
  this.uow = params.uow;
39
79
  this.orgAdmin = params.orgAdmin;
80
+ this.sessions = params.sessions;
81
+ this.apiKeysPort = params.apiKeys;
82
+ this.accountsPort = params.accounts;
83
+ this.platform = params.platform && {
84
+ ...params.platform,
85
+ email: params.platform.email.toLowerCase()
86
+ };
87
+ this.grants = params.grants;
88
+ const defaults = params.defaultRoles ?? DEFAULT_ROLES;
89
+ validateRoleSet(defaults);
90
+ this.catalog = catalogOf(defaults);
40
91
  this.logger = params.logger ?? noopLogger;
41
92
  this.mailer = params.mailer;
42
93
  this.inviteUrls = params.inviteUrls;
43
94
  }
44
- async createOrganization(input) {
45
- const created = await this.uow.run(async ({ organizations, outbox }) => {
95
+ createOrganization(input, headers) {
96
+ return this.orgAdmin().createOrganization(headers, input);
97
+ }
98
+ async provisionOrganization(input) {
99
+ const created = await this.uow.run(async ({ organizations, roles, outbox }) => {
46
100
  const id = genId("organization");
47
- const created = await organizations.create({
101
+ const org = await organizations.create({
48
102
  id,
49
103
  externalId: input.externalId ?? id,
50
- name: input.name ?? `My Org`,
104
+ name: input.name ?? "My Org",
51
105
  slug: input.slug ?? "",
52
106
  ownerId: input.ownerId
53
107
  });
108
+ const owner = await organizations.createOrgUser({
109
+ orgId: org.id,
110
+ userId: input.ownerId,
111
+ status: "active"
112
+ });
113
+ await roles.setOrgRole(org.id, owner.id, OWNER_ROLE_KEY, SYSTEM_ID);
54
114
  await outbox.append([organizationEvents.organizationCreated.make({
55
- orgId: created.id,
56
- data: created
115
+ orgId: org.id,
116
+ data: org
117
+ }), organizationEvents.orgUserLinked.make({
118
+ orgId: org.id,
119
+ data: {
120
+ ...owner,
121
+ orgRole: { key: OWNER_ROLE_KEY }
122
+ }
57
123
  })]);
58
- return created;
124
+ return org;
59
125
  });
60
126
  this.logger.info("organization created", {
61
127
  orgId: created.id,
@@ -63,21 +129,40 @@ var OrganizationServiceImpl = class {
63
129
  });
64
130
  return created;
65
131
  }
66
- async updateOrganization(id, input) {
67
- const updatedOrg = await this.uow.run(async ({ organizations, outbox }) => {
68
- const updated = await organizations.update(id, {
132
+ async updateOrganization(input, actor) {
133
+ actor.permissions.require("organizations:write");
134
+ const orgId = actor.orgId;
135
+ const org = await this.repo.findById(orgId);
136
+ if (!org) throw new NotFoundError("Organization", orgId);
137
+ if (input.slug !== void 0 && input.slug !== org.slug) {
138
+ const taken = await this.repo.findBySlug(input.slug);
139
+ if (taken && taken.id !== orgId) {
140
+ this.logger.warn("organization update refused: slug taken", {
141
+ orgId,
142
+ slug: input.slug
143
+ });
144
+ throw new OrganizationRuleError("That slug is already taken");
145
+ }
146
+ }
147
+ await this.orgAdmin().updateOrganization(org.externalId ?? org.id, input);
148
+ const updated = await this.uow.run(async ({ organizations, outbox }) => {
149
+ const next = await organizations.update(orgId, {
69
150
  name: input.name,
70
151
  slug: input.slug
71
152
  });
72
- if (!updated) throw new NotFoundError("Organization", id);
153
+ if (!next) throw new NotFoundError("Organization", orgId);
73
154
  await outbox.append([organizationEvents.organizationUpdated.make({
74
- orgId: id,
75
- data: updated
155
+ orgId,
156
+ data: next,
157
+ metadata: { actorId: actor.id }
76
158
  })]);
77
- return updated;
159
+ return next;
160
+ });
161
+ this.logger.info("organization updated", {
162
+ orgId,
163
+ actorId: actor.id
78
164
  });
79
- this.logger.info("organization updated", { updatedOrg });
80
- return updatedOrg;
165
+ return updated;
81
166
  }
82
167
  async deleteOrganization(id) {
83
168
  const deleted = await this.uow.run(async ({ organizations, outbox }) => {
@@ -91,164 +176,782 @@ var OrganizationServiceImpl = class {
91
176
  this.logger.info("organization deleted", { orgId: deleted.id });
92
177
  return deleted;
93
178
  }
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
- });
179
+ async addOrgUser({ orgId, userId }) {
180
+ const existing = await this.repo.findOrgUser(orgId, userId);
181
+ if (existing) return existing;
99
182
  const orgUser = await this.uow.run(async ({ organizations, outbox }) => {
100
183
  const created = await organizations.createOrgUser({
101
184
  orgId,
102
- role,
103
185
  userId,
104
186
  status: "active"
105
187
  });
106
188
  await outbox.append([organizationEvents.orgUserLinked.make({
107
189
  orgId,
108
- data: created
190
+ data: {
191
+ ...created,
192
+ orgRole: { key: MEMBER_ROLE_KEY }
193
+ }
109
194
  })]);
110
195
  return created;
111
196
  });
112
197
  this.logger.info("user added to organization", {
113
198
  orgId,
114
199
  userId,
115
- role
200
+ orgUserId: orgUser.id
116
201
  });
117
202
  return orgUser;
118
203
  }
119
204
  async removeOrgUser(orgId, userId) {
120
- const deleted = await this.uow.run(async ({ organizations, outbox }) => {
121
- const orgUser = await organizations.findOrgUser(orgId, 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({
205
+ const orgUser = await this.repo.findOrgUser(orgId, userId);
206
+ if (!orgUser) return null;
207
+ return this.deleteOrgUserRow(orgId, orgUser);
208
+ }
209
+ getByExternalId(externalId) {
210
+ return this.repo.findByExternalId(externalId);
211
+ }
212
+ getBySlug(slug) {
213
+ return this.repo.findBySlug(slug);
214
+ }
215
+ getById(id) {
216
+ return this.repo.findById(id);
217
+ }
218
+ async portalIdentity(id) {
219
+ const org = await this.repo.findById(id);
220
+ if (!org) throw new NotFoundError("Organization", id);
221
+ return {
222
+ id: org.id,
223
+ name: org.name,
224
+ slug: org.slug
225
+ };
226
+ }
227
+ getOrgUser(orgId, userId) {
228
+ return this.repo.findOrgUser(orgId, userId);
229
+ }
230
+ getOrgUserById(orgId, id) {
231
+ return this.repo.findOrgUserById(orgId, id);
232
+ }
233
+ async getOrgUsersByIds(orgId, ids) {
234
+ const unique = [...new Set(ids)];
235
+ return unique.length > 0 ? this.repo.findOrgUsersByIds(orgId, unique) : [];
236
+ }
237
+ getOrgUsersForUser(userId) {
238
+ return this.repo.findOrgUsersByUser(userId);
239
+ }
240
+ orgIds() {
241
+ return this.repo.listIds();
242
+ }
243
+ async actorFor(orgId, orgUserId, limit) {
244
+ const facts = await this.roles.actorFacts(orgId, orgUserId);
245
+ if (!facts || facts.status !== "active") return null;
246
+ const roles = this.roleMap(facts.storedRoles);
247
+ const tokenScopes = limitScopes(limit, roles);
248
+ return {
249
+ id: orgUserId,
250
+ orgId,
251
+ permissions: resolvePermissions({
252
+ assignments: facts.assignments,
253
+ roles,
254
+ ...tokenScopes !== void 0 && { tokenScopes }
255
+ })
256
+ };
257
+ }
258
+ async resolveCaller(authOrgId, authUserId, limit) {
259
+ const facts = await this.roles.callerFacts(authOrgId, authUserId);
260
+ if (!facts) return null;
261
+ const orgUser = facts.orgUser;
262
+ if (orgUser?.status !== "active") return {
263
+ orgId: facts.orgId,
264
+ userId: facts.userId,
265
+ orgUserId: orgUser?.orgUserId ?? null,
266
+ actor: null
267
+ };
268
+ const roles = this.roleMap(orgUser.storedRoles);
269
+ const tokenScopes = limitScopes(limit, roles);
270
+ return {
271
+ orgId: facts.orgId,
272
+ userId: facts.userId,
273
+ orgUserId: orgUser.orgUserId,
274
+ actor: {
275
+ id: orgUser.orgUserId,
276
+ orgId: facts.orgId,
277
+ permissions: resolvePermissions({
278
+ assignments: orgUser.assignments,
279
+ roles,
280
+ ...tokenScopes !== void 0 && { tokenScopes }
281
+ })
282
+ }
283
+ };
284
+ }
285
+ systemActor(orgId) {
286
+ return {
287
+ id: SYSTEM_ID,
288
+ orgId,
289
+ permissions: systemPermissions()
290
+ };
291
+ }
292
+ async permissionsOfMany(orgId, orgUserIds) {
293
+ const unique = [...new Set(orgUserIds)];
294
+ const out = /* @__PURE__ */ new Map();
295
+ if (unique.length === 0) return out;
296
+ const [stored, assignments] = await Promise.all([this.roles.storedRoles(orgId), this.roles.assignmentsOfMany(orgId, unique)]);
297
+ const roles = this.roleMap(stored);
298
+ for (const id of unique) out.set(id, resolvePermissions({
299
+ assignments: assignments.get(id) ?? [],
300
+ roles
301
+ }));
302
+ return out;
303
+ }
304
+ async holders(orgId, permission, on) {
305
+ const stored = await this.roles.storedRoles(orgId);
306
+ const keys = resolveOrgRoles(this.catalog, stored).filter((role) => withImplied(role.permissions).has(permission)).map((role) => role.key);
307
+ const candidates = await this.roles.holdersOf(orgId, keys, on);
308
+ if (candidates.length === 0) return [];
309
+ const permissions = await this.permissionsOfMany(orgId, candidates);
310
+ return candidates.filter((id) => permissions.get(id)?.has(permission, on) ?? false);
311
+ }
312
+ permissionList() {
313
+ return permissionList();
314
+ }
315
+ viewerPermissions(actor) {
316
+ const role = actor.permissions.roleOn();
317
+ if (actor.permissions instanceof ResolvedPermissions) return {
318
+ orgRole: {
319
+ key: role.key,
320
+ name: role.name
321
+ },
322
+ isOwner: actor.permissions.isOwner(),
323
+ org: actor.permissions.orgPermissions(),
324
+ content: actor.permissions.contentPermissions()
325
+ };
326
+ return {
327
+ orgRole: {
328
+ key: role.key,
329
+ name: role.name
330
+ },
331
+ isOwner: actor.permissions.isOwner(),
332
+ org: ALL_PERMISSIONS.filter((p) => actor.permissions.has(p)),
333
+ content: {}
334
+ };
335
+ }
336
+ async auditRoleKeys() {
337
+ const usage = await this.roles.roleKeyUsage();
338
+ const storedByOrg = /* @__PURE__ */ new Map();
339
+ let dangling = 0;
340
+ for (const row of usage) {
341
+ if (!storedByOrg.has(row.orgId)) storedByOrg.set(row.orgId, await this.roles.storedRoles(row.orgId));
342
+ const role = this.resolveIn(storedByOrg.get(row.orgId), row.roleKey);
343
+ if (!role) {
344
+ dangling += row.count;
345
+ this.logger.warn("assignments name a role the set no longer has", {
346
+ orgId: row.orgId,
347
+ roleKey: row.roleKey,
348
+ level: row.level,
349
+ count: row.count
350
+ });
351
+ } else if (!role.levels.includes(row.level)) {
352
+ dangling += row.count;
353
+ this.logger.warn("assignments sit at a level their role no longer lists", {
354
+ orgId: row.orgId,
355
+ roleKey: row.roleKey,
356
+ level: row.level,
357
+ count: row.count
358
+ });
359
+ }
360
+ }
361
+ return dangling;
362
+ }
363
+ async listRoles(actor) {
364
+ actor.permissions.require("roles:read");
365
+ const [stored, counts] = await Promise.all([this.roles.storedRoles(actor.orgId), this.roles.holderCounts(actor.orgId)]);
366
+ return resolveOrgRoles(this.catalog, stored).map((role) => roleView(role, counts[role.key] ?? 0));
367
+ }
368
+ async getRole(key, actor) {
369
+ actor.permissions.require("roles:read");
370
+ return roleView(await this.findRole(actor.orgId, key), await this.roles.countHolders(actor.orgId, key));
371
+ }
372
+ async updateRole(key, input, actor) {
373
+ actor.permissions.require("roles:write");
374
+ const orgId = actor.orgId;
375
+ const changesAccess = input.levels !== void 0 || input.team !== void 0 || input.permissions !== void 0;
376
+ const result = await this.uow.run(async (scope) => {
377
+ const { roles, outbox } = scope;
378
+ await roles.lock(lockKeys(orgId, [key], [actor.id]));
379
+ const editor = await this.relockedActor(scope, actor);
380
+ editor.permissions.require("roles:write");
381
+ const stored = await roles.storedRoles(orgId);
382
+ const current = stored.find((row) => row.key === key);
383
+ const before = this.resolveIn(stored, key);
384
+ if (!before) throw new NotFoundError("Role", key);
385
+ if (changesAccess && isSystemRole(key)) throw new OrganizationRuleError(`${before.name} can only be renamed`);
386
+ if (input.name !== void 0) this.assertUniqueName(stored, key, input.name);
387
+ const next = {
388
+ key,
389
+ name: input.name ?? current?.name ?? null,
390
+ description: input.description ?? current?.description ?? null,
391
+ base: current?.base ?? before.base,
392
+ levels: input.levels ? this.parseLevels(input.levels) : current?.levels ?? null,
393
+ permissions: {
394
+ ...current?.permissions ?? {},
395
+ ...input.permissions ?? {}
396
+ },
397
+ team: input.team ?? current?.team ?? null
398
+ };
399
+ const after = resolveRole(key, this.catalog, next);
400
+ if (!after) throw new NotFoundError("Role", key);
401
+ if (changesAccess) {
402
+ this.assertFits(next);
403
+ this.assertHolds(editor, [...before.permissions, ...after.permissions]);
404
+ await this.assertLevelsNotInUse(roles, orgId, before, after);
405
+ }
406
+ await roles.saveStoredRole(orgId, next);
407
+ await outbox.append([organizationEvents.roleUpdated.make({
408
+ orgId,
409
+ data: {
410
+ key,
411
+ before: snapshot(before),
412
+ after: snapshot(after)
413
+ },
414
+ metadata: { actorId: actor.id }
415
+ })]);
416
+ return after;
417
+ });
418
+ this.logger.info("role updated", {
419
+ orgId,
420
+ roleKey: key,
421
+ actorId: actor.id
422
+ });
423
+ return roleView(result, await this.roles.countHolders(orgId, key));
424
+ }
425
+ async createRole(input, actor) {
426
+ actor.permissions.require("roles:write");
427
+ const orgId = actor.orgId;
428
+ if (!this.catalog.get(input.base) || isSystemRole(input.base)) throw new OrganizationRuleError("A role is added from one of the deployment roles");
429
+ const key = prefixId("role");
430
+ const created = await this.uow.run(async (scope) => {
431
+ const { roles, outbox } = scope;
432
+ await roles.lock(lockKeys(orgId, [key, `name:${input.name.trim().toLowerCase()}`], [actor.id]));
433
+ const editor = await this.relockedActor(scope, actor);
434
+ editor.permissions.require("roles:write");
435
+ const stored = await roles.storedRoles(orgId);
436
+ this.assertUniqueName(stored, key, input.name);
437
+ const row = {
438
+ key,
439
+ name: input.name,
440
+ description: input.description ?? null,
441
+ base: input.base,
442
+ levels: input.levels ? this.parseLevels(input.levels) : null,
443
+ permissions: input.permissions ?? {},
444
+ team: input.team ?? null
445
+ };
446
+ this.assertFits(row);
447
+ const role = resolveRole(key, this.catalog, row);
448
+ this.assertHolds(editor, role.permissions);
449
+ await roles.saveStoredRole(orgId, row);
450
+ await outbox.append([organizationEvents.roleCreated.make({
451
+ orgId,
452
+ data: { role: snapshot(role) },
453
+ metadata: { actorId: actor.id }
454
+ })]);
455
+ return role;
456
+ });
457
+ this.logger.info("role created", {
458
+ orgId,
459
+ roleKey: key,
460
+ actorId: actor.id
461
+ });
462
+ return roleView(created, 0);
463
+ }
464
+ async resetRole(key, actor) {
465
+ actor.permissions.require("roles:write");
466
+ const orgId = actor.orgId;
467
+ if (!this.catalog.has(key)) throw new OrganizationRuleError("Only a deployment role can be reset");
468
+ const result = await this.uow.run(async (scope) => {
469
+ const { roles, outbox } = scope;
470
+ await roles.lock(lockKeys(orgId, [key], [actor.id]));
471
+ const editor = await this.relockedActor(scope, actor);
472
+ editor.permissions.require("roles:write");
473
+ const stored = await roles.storedRoles(orgId);
474
+ const before = this.resolveIn(stored, key);
475
+ const after = resolveRole(key, this.catalog, void 0);
476
+ if (!before || !after) throw new NotFoundError("Role", key);
477
+ this.assertUniqueName(stored.filter((row) => row.key !== key), key, after.name);
478
+ this.assertHolds(editor, [...before.permissions, ...after.permissions]);
479
+ await this.assertLevelsNotInUse(roles, orgId, before, after);
480
+ await roles.deleteStoredRole(orgId, key);
481
+ await outbox.append([organizationEvents.roleUpdated.make({
482
+ orgId,
483
+ data: {
484
+ key,
485
+ before: snapshot(before),
486
+ after: snapshot(after)
487
+ },
488
+ metadata: { actorId: actor.id }
489
+ })]);
490
+ return after;
491
+ });
492
+ this.logger.info("role reset", {
493
+ orgId,
494
+ roleKey: key,
495
+ actorId: actor.id
496
+ });
497
+ return roleView(result, await this.roles.countHolders(orgId, key));
498
+ }
499
+ async deleteRole(key, actor) {
500
+ actor.permissions.require("roles:delete");
501
+ const orgId = actor.orgId;
502
+ if (this.catalog.has(key)) throw new OrganizationRuleError("A deployment role can be renamed and reset, not deleted");
503
+ const keys = await this.keysWithRole(orgId, key);
504
+ if (keys > 0) {
505
+ this.logger.warn("role deletion refused: role is on api keys", {
506
+ orgId,
507
+ roleKey: key,
508
+ keys
509
+ });
510
+ throw new OrganizationRuleError(`This role is on ${keys} API ${keys === 1 ? "key" : "keys"}; revoke ${keys === 1 ? "it" : "them"} first`);
511
+ }
512
+ await this.uow.run(async (scope) => {
513
+ const { roles, outbox } = scope;
514
+ await roles.lock(lockKeys(orgId, [key], [actor.id]));
515
+ (await this.relockedActor(scope, actor)).permissions.require("roles:delete");
516
+ const holders = await roles.countHolders(orgId, key);
517
+ if (holders > 0) {
518
+ this.logger.warn("role deletion refused: role is held", {
128
519
  orgId,
129
- data: demoted,
130
- metadata: { actorOrgUserId: write.actorOrgUserId }
131
- })]);
132
- return demoted;
520
+ roleKey: key,
521
+ holders
522
+ });
523
+ throw new OrganizationRuleError(`This role is held by ${holders} ${holders === 1 ? "person" : "people"}`);
133
524
  }
134
- if (orgUser.role === "student") return orgUser;
135
- const deletedUser = await organizations.deleteOrgUser(orgId, orgUser.id);
136
- if (!deletedUser) throw new NotFoundError("Member", orgUser.id);
137
- await outbox.append([organizationEvents.orgUserDeleted.make({
525
+ if (!await roles.deleteStoredRole(orgId, key)) throw new NotFoundError("Role", key);
526
+ await outbox.append([organizationEvents.roleDeleted.make({
138
527
  orgId,
139
- data: deletedUser
528
+ data: { key },
529
+ metadata: { actorId: actor.id }
140
530
  })]);
141
- return deletedUser;
142
531
  });
143
- this.logger.info("org user removed", {
532
+ this.logger.info("role deleted", {
144
533
  orgId,
145
- userId,
146
- orgUserId: deleted.id
534
+ roleKey: key,
535
+ actorId: actor.id
147
536
  });
148
- return deleted;
149
537
  }
150
- async updateOrgUser(orgId, userId, patch) {
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({
538
+ async listTeam(query, actor) {
539
+ actor.permissions.require("people:read");
540
+ const orgId = actor.orgId;
541
+ const [rows, stored] = await Promise.all([this.membersRepo.teamRows(orgId, MEMBER_ROLE_KEY), this.roles.storedRoles(orgId)]);
542
+ const roles = this.roleMap(stored);
543
+ const nameOf = (key) => roles.get(key)?.name ?? key;
544
+ let members = rows.map((row) => this.toMember(row, nameOf));
545
+ if (query.roleKey) members = members.filter((m) => m.orgRole.key === query.roleKey || m.items.some((i) => i.roleKey === query.roleKey));
546
+ if (query.status) members = members.filter((m) => m.status === query.status);
547
+ const q = query.search?.trim().toLowerCase();
548
+ if (q) members = members.filter((m) => [
549
+ m.firstName,
550
+ m.lastName,
551
+ m.email
552
+ ].some((v) => v?.toLowerCase().includes(q)));
553
+ const sort = query.sort;
554
+ const desc = sort?.startsWith("-") ?? false;
555
+ const key = desc ? sort.slice(1) : sort;
556
+ const byName = (a, b) => a === b ? 0 : a === null ? 1 : b === null ? -1 : a.localeCompare(b);
557
+ members.sort((a, b) => {
558
+ const cmp = key === "email" ? a.email.localeCompare(b.email) : key === "role" ? a.orgRole.name.localeCompare(b.orgRole.name) : key === "lastName" ? byName(a.lastName, b.lastName) || byName(a.firstName, b.firstName) : byName(a.firstName, b.firstName) || byName(a.lastName, b.lastName);
559
+ return desc ? -cmp : cmp;
560
+ });
561
+ const start = (query.page - 1) * query.pageSize;
562
+ return {
563
+ rows: members.slice(start, start + query.pageSize),
564
+ total: members.length,
565
+ page: query.page,
566
+ pageSize: query.pageSize
567
+ };
568
+ }
569
+ async listAssignments(orgUserId, actor) {
570
+ actor.permissions.require("people:read");
571
+ return this.assignmentViews(actor.orgId, orgUserId);
572
+ }
573
+ async listAssignmentsOn(contentId, actor) {
574
+ actor.permissions.require("people:read");
575
+ const [rows, stored] = await Promise.all([this.roles.assignmentsOn(actor.orgId, contentId), this.roles.storedRoles(actor.orgId)]);
576
+ const roles = this.roleMap(stored);
577
+ return rows.map((row) => ({
578
+ orgUserId: row.orgUserId,
579
+ roleKey: row.roleKey,
580
+ roleName: roles.get(row.roleKey)?.name ?? row.roleKey,
581
+ contentId: row.contentId,
582
+ contentType: row.contentType
583
+ }));
584
+ }
585
+ async setOrgRole(orgUserId, roleKey, actor) {
586
+ actor.permissions.require("people:write");
587
+ const orgId = actor.orgId;
588
+ this.assertNotSelf(actor, orgUserId);
589
+ const seen = (await this.roles.assignmentsOf(orgId, orgUserId)).find((row) => row.contentId === null);
590
+ const target = await this.repo.findOrgUserById(orgId, orgUserId);
591
+ const invitee = target?.status === "invited" ? await this.people.getUserById(target.userId) : null;
592
+ await this.uow.run(async (scope) => {
593
+ const { roles, organizations, outbox } = scope;
594
+ await roles.lock(lockKeys(orgId, [roleKey, seen?.roleKey ?? "member"], [orgUserId, actor.id]));
595
+ const editor = await this.relockedActor(scope, actor);
596
+ editor.permissions.require("people:write");
597
+ const orgUser = await organizations.findOrgUserById(orgId, orgUserId);
598
+ if (!orgUser) throw new NotFoundError("Person", orgUserId);
599
+ const stored = await roles.storedRoles(orgId);
600
+ const next = this.resolveIn(stored, roleKey);
601
+ if (!next) throw new NotFoundError("Role", roleKey);
602
+ if (roleKey === "owner") throw new OrganizationRuleError("Ownership moves only by transfer");
603
+ if (!next.levels.includes("org")) throw new OrganizationRuleError(`${next.name} can't be given on the organization`);
604
+ const current = (await roles.assignmentsOf(orgId, orgUserId)).find((row) => row.contentId === null);
605
+ if (current?.roleKey === "owner") {
606
+ this.logger.warn("org role change refused: target is the owner", {
159
607
  orgId,
160
- data: next,
161
- ...actorOrgUserId !== void 0 && { metadata: { actorOrgUserId } }
162
- })]);
608
+ orgUserId
609
+ });
610
+ throw new OrganizationRuleError("The owner's role changes only by transfer");
163
611
  }
164
- return next;
612
+ if (orgUser.status === "invited") {
613
+ const person = invitee?.id === orgUser.userId ? invitee : null;
614
+ const pending = person ? await organizations.findPendingInvite(orgId, person.email) : null;
615
+ if (!pending) throw new NotFoundError("Invite", orgUserId);
616
+ const invited = this.resolveIn(stored, pending.roleKey);
617
+ this.assertHolds(editor, [...invited?.permissions ?? [], ...next.permissions]);
618
+ const editorUser = await organizations.findOrgUserById(orgId, actor.id);
619
+ if (!editorUser) throw new ForbiddenError("You are no longer an active member of this organization");
620
+ await organizations.setInviteRole(orgId, pending.id, roleKey, editorUser.userId);
621
+ this.logger.info("pending invite role changed", {
622
+ orgId,
623
+ orgUserId,
624
+ inviteId: pending.id,
625
+ roleKey,
626
+ actorId: actor.id
627
+ });
628
+ return;
629
+ }
630
+ const previousKey = current?.roleKey ?? "member";
631
+ const previous = this.resolveIn(stored, previousKey);
632
+ this.assertHolds(editor, [...previous?.permissions ?? [], ...next.permissions]);
633
+ await roles.setOrgRole(orgId, orgUserId, roleKey === "member" ? null : roleKey, actor.id);
634
+ if (previousKey !== roleKey) await outbox.append([organizationEvents.roleAssigned.make({
635
+ orgId,
636
+ data: {
637
+ orgUserId,
638
+ roleKey,
639
+ contentId: null,
640
+ contentType: null,
641
+ previousRoleKey: previousKey
642
+ },
643
+ metadata: { actorId: actor.id }
644
+ })]);
645
+ this.logger.info("org role set", {
646
+ orgId,
647
+ orgUserId,
648
+ roleKey,
649
+ actorId: actor.id
650
+ });
651
+ });
652
+ return this.assignmentViews(orgId, orgUserId);
653
+ }
654
+ async assignRole(orgUserId, contentId, roleKey, actor) {
655
+ actor.permissions.require("people:write");
656
+ const orgId = actor.orgId;
657
+ this.assertNotSelf(actor, orgUserId);
658
+ const seen = (await this.roles.assignmentsOf(orgId, orgUserId)).find((row) => row.contentId === contentId);
659
+ await this.uow.run(async (scope) => {
660
+ const { roles, organizations, outbox } = scope;
661
+ await roles.lock(lockKeys(orgId, [roleKey, ...seen ? [seen.roleKey] : []], [orgUserId, actor.id]));
662
+ const editor = await this.relockedActor(scope, actor);
663
+ editor.permissions.require("people:write");
664
+ if (!await organizations.findOrgUserById(orgId, orgUserId)) throw new NotFoundError("Person", orgUserId);
665
+ const contentType = await roles.contentTypeOf(orgId, contentId);
666
+ if (!contentType) throw new NotFoundError("Content", contentId);
667
+ const stored = await roles.storedRoles(orgId);
668
+ const next = this.resolveIn(stored, roleKey);
669
+ if (!next) throw new NotFoundError("Role", roleKey);
670
+ if (isSystemRole(roleKey) || !next.levels.includes(contentType)) {
671
+ this.logger.warn("role assignment refused: level not listed", {
672
+ orgId,
673
+ roleKey,
674
+ contentType
675
+ });
676
+ throw new OrganizationRuleError(`${next.name} can't be given on a ${contentType}`);
677
+ }
678
+ const current = (await roles.assignmentsOf(orgId, orgUserId)).find((row) => row.contentId === contentId);
679
+ const previous = current ? this.resolveIn(stored, current.roleKey) : null;
680
+ this.assertHoldsOn(editor, [...this.itemPermissions(previous, contentType), ...this.itemPermissions(next, contentType)], contentId);
681
+ await roles.setItemRole(orgId, orgUserId, contentId, contentType, roleKey, actor.id);
682
+ if (current?.roleKey !== roleKey) await outbox.append([organizationEvents.roleAssigned.make({
683
+ orgId,
684
+ data: {
685
+ orgUserId,
686
+ roleKey,
687
+ contentId,
688
+ contentType,
689
+ previousRoleKey: current?.roleKey ?? null
690
+ },
691
+ metadata: { actorId: actor.id }
692
+ })]);
165
693
  });
166
- this.logger.info("org user updated", {
694
+ this.logger.info("item role given", {
167
695
  orgId,
168
- userId,
169
- orgUserId: updated.id,
170
- patch
696
+ orgUserId,
697
+ contentId,
698
+ roleKey,
699
+ actorId: actor.id
700
+ });
701
+ return this.assignmentViews(orgId, orgUserId);
702
+ }
703
+ async unassignRole(orgUserId, contentId, actor) {
704
+ actor.permissions.require("people:write");
705
+ const orgId = actor.orgId;
706
+ this.assertNotSelf(actor, orgUserId);
707
+ const seen = (await this.roles.assignmentsOf(orgId, orgUserId)).find((row) => row.contentId === contentId);
708
+ await this.uow.run(async (scope) => {
709
+ const { roles, outbox } = scope;
710
+ await roles.lock(lockKeys(orgId, seen ? [seen.roleKey] : [], [orgUserId, actor.id]));
711
+ const current = (await roles.assignmentsOf(orgId, orgUserId)).find((row) => row.contentId === contentId);
712
+ if (!current || !current.contentType) throw new NotFoundError("Role assignment", contentId);
713
+ const editor = await this.relockedActor(scope, actor);
714
+ editor.permissions.require("people:write");
715
+ const stored = await roles.storedRoles(orgId);
716
+ const role = this.resolveIn(stored, current.roleKey);
717
+ this.assertHoldsOn(editor, this.itemPermissions(role, current.contentType), contentId);
718
+ const removed = await roles.removeItemRole(orgId, orgUserId, contentId);
719
+ if (removed) await outbox.append([organizationEvents.roleUnassigned.make({
720
+ orgId,
721
+ data: {
722
+ orgUserId,
723
+ roleKey: removed.roleKey,
724
+ contentId,
725
+ contentType: current.contentType
726
+ },
727
+ metadata: { actorId: actor.id }
728
+ })]);
729
+ });
730
+ this.logger.info("item role removed", {
731
+ orgId,
732
+ orgUserId,
733
+ contentId,
734
+ actorId: actor.id
735
+ });
736
+ return this.assignmentViews(orgId, orgUserId);
737
+ }
738
+ async removePerson(orgUserId, actor) {
739
+ actor.permissions.require("people:delete");
740
+ const orgId = actor.orgId;
741
+ this.assertNotSelf(actor, orgUserId);
742
+ const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);
743
+ if (!orgUser) throw new NotFoundError("Person", orgUserId);
744
+ const person = await this.people.getUserById(orgUser.userId);
745
+ await this.uow.run(async (scope) => {
746
+ const { roles } = scope;
747
+ await roles.lock(lockKeys(orgId, [], [orgUserId, actor.id]));
748
+ const editor = await this.relockedActor(scope, actor);
749
+ editor.permissions.require("people:delete");
750
+ const held = await roles.assignmentsOf(orgId, orgUserId);
751
+ if (held.some((row) => row.roleKey === "owner")) {
752
+ this.logger.warn("removal refused: target is the owner", {
753
+ orgId,
754
+ orgUserId
755
+ });
756
+ throw new OrganizationRuleError("The owner cannot be removed");
757
+ }
758
+ const stored = await roles.storedRoles(orgId);
759
+ for (const row of held) {
760
+ const role = this.resolveIn(stored, row.roleKey);
761
+ if (!role) continue;
762
+ if (row.contentId === null) this.assertHolds(editor, role.permissions);
763
+ else if (row.contentType) this.assertHoldsOn(editor, this.itemPermissions(role, row.contentType), row.contentId);
764
+ }
765
+ await this.deleteOrgUserIn(scope, orgId, orgUser, held, actor.id);
766
+ });
767
+ this.logger.info("person removed", {
768
+ orgId,
769
+ orgUserId,
770
+ actorId: actor.id
771
+ });
772
+ await this.cleanUpAfterRemoval(orgId, orgUserId, person);
773
+ }
774
+ async transferOwnership(input, actor) {
775
+ if (!actor.permissions.isOwner()) {
776
+ this.logger.warn("ownership transfer refused: caller is not the owner", {
777
+ orgId: actor.orgId,
778
+ actorId: actor.id
779
+ });
780
+ throw new ForbiddenError("Only the owner can transfer ownership");
781
+ }
782
+ const orgId = actor.orgId;
783
+ const previousRoleKey = input.previousOwnerRoleKey ?? "member";
784
+ if (input.newOwnerOrgUserId === actor.id) throw new OrganizationRuleError("You already own this organization");
785
+ const stored = await this.roles.storedRoles(orgId);
786
+ const previousRole = this.resolveIn(stored, previousRoleKey);
787
+ if (!previousRole || previousRoleKey === "owner" || !previousRole.levels.includes("org")) throw new OrganizationRuleError("The previous owner needs a role given on the organization");
788
+ const [org, newOwner, previousOwner] = await Promise.all([
789
+ this.repo.findById(orgId),
790
+ this.repo.findOrgUserById(orgId, input.newOwnerOrgUserId),
791
+ this.repo.findOrgUserById(orgId, actor.id)
792
+ ]);
793
+ if (!org || !previousOwner) throw new NotFoundError("Organization", orgId);
794
+ if (!newOwner || newOwner.status !== "active") throw new OrganizationRuleError("The new owner must be an active member of the organization");
795
+ const [newPerson, previousPerson] = await Promise.all([this.people.getUserById(newOwner.userId), this.people.getUserById(previousOwner.userId)]);
796
+ if (!newPerson?.externalId || !previousPerson?.externalId) throw new OrganizationRuleError("Both people need an account");
797
+ const orgExternalId = org.externalId ?? org.id;
798
+ await this.orgAdmin().setMembershipOwner(orgExternalId, newPerson.externalId, true);
799
+ await this.orgAdmin().setMembershipOwner(orgExternalId, previousPerson.externalId, false);
800
+ await this.uow.run(async ({ roles, outbox }) => {
801
+ await roles.lock(lockKeys(orgId, [OWNER_ROLE_KEY, previousRoleKey], [newOwner.id, previousOwner.id]));
802
+ if (await roles.ownerOrgUserId(orgId) !== previousOwner.id) throw new ForbiddenError("Only the owner can transfer ownership");
803
+ await roles.setOrgRole(orgId, newOwner.id, null, actor.id);
804
+ await roles.setOrgRole(orgId, previousOwner.id, previousRoleKey === "member" ? null : previousRoleKey, actor.id);
805
+ await roles.setOrgRole(orgId, newOwner.id, OWNER_ROLE_KEY, actor.id);
806
+ await outbox.append([organizationEvents.ownershipTransferred.make({
807
+ orgId,
808
+ data: {
809
+ previousOwnerOrgUserId: previousOwner.id,
810
+ newOwnerOrgUserId: newOwner.id
811
+ },
812
+ metadata: { actorId: actor.id }
813
+ })]);
814
+ });
815
+ this.logger.info("ownership transferred", {
816
+ orgId,
817
+ previousOwnerOrgUserId: previousOwner.id,
818
+ newOwnerOrgUserId: newOwner.id
171
819
  });
172
- return updated;
173
820
  }
174
- async createInvite(actor, input) {
175
- const { orgId, email, role, inviterUserId, firstName, lastName } = input;
176
- this.assertManagesUsers(actor, orgId);
177
- this.assertNotOwner(role, orgId);
821
+ async createInvite(input, actor, inviterUserId) {
822
+ actor.permissions.require("people:write");
823
+ const orgId = actor.orgId;
824
+ const roleKey = input.roleKey ?? "member";
825
+ const email = input.email;
826
+ if (roleKey === "owner") {
827
+ this.logger.warn("invite refused: ownership cannot be granted", { orgId });
828
+ throw new OrganizationRuleError("Ownership moves only by transfer");
829
+ }
830
+ const stored = await this.roles.storedRoles(orgId);
831
+ const role = this.resolveIn(stored, roleKey);
832
+ if (!role) throw new NotFoundError("Role", roleKey);
833
+ if (!role.levels.includes("org")) throw new OrganizationRuleError(`${role.name} can't be given on the organization`);
834
+ this.assertHolds(actor, role.permissions);
835
+ const known = await this.people.getUserByEmail(email);
836
+ const existing = known ? await this.repo.findOrgUser(orgId, known.id) : null;
837
+ if (existing?.id === actor.id) {
838
+ this.logger.warn("invite refused: inviting yourself", {
839
+ orgId,
840
+ actorId: actor.id
841
+ });
842
+ throw new ForbiddenError("You can't change your own roles");
843
+ }
844
+ let contributed = role.permissions;
845
+ if (existing) {
846
+ const current = (await this.roles.assignmentsOf(orgId, existing.id)).find((row) => row.contentId === null);
847
+ if (current?.roleKey === "owner") {
848
+ this.logger.warn("invite refused: target is the owner", { orgId });
849
+ throw new OrganizationRuleError("The owner cannot be invited");
850
+ }
851
+ const currentRole = this.resolveIn(stored, current?.roleKey ?? "member");
852
+ contributed = [...contributed, ...currentRole?.permissions ?? []];
853
+ }
854
+ this.assertHolds(actor, contributed);
855
+ const person = known ?? await this.people.createUser({
856
+ email,
857
+ ...input.firstName !== void 0 && { firstName: input.firstName },
858
+ ...input.lastName !== void 0 && { lastName: input.lastName }
859
+ });
178
860
  const sendEmail = input.sendEmail ?? true;
179
861
  const { token, tokenHash } = generateInviteToken();
180
862
  const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
181
- const person = role === "student" ? await this.people.getUserByEmail(email) ?? await this.people.createUser({
182
- email,
183
- ...firstName !== void 0 && { firstName },
184
- ...lastName !== void 0 && { lastName }
185
- }) : null;
186
863
  const invite = await this.uow.run(async ({ organizations, outbox }) => {
187
864
  const events = [];
188
- if (person) {
865
+ if (!existing) {
189
866
  const orgUser = await organizations.createOrgUser({
190
867
  orgId,
191
868
  userId: person.id,
192
- role: STUDENT_ROLE,
193
869
  status: "invited"
194
870
  });
195
- events.push(organizationEvents.studentCreated.make({
871
+ events.push(roleKey === "member" ? organizationEvents.studentCreated.make({
196
872
  orgId,
197
873
  data: orgUser
874
+ }) : organizationEvents.orgUserLinked.make({
875
+ orgId,
876
+ data: {
877
+ ...orgUser,
878
+ orgRole: { key: MEMBER_ROLE_KEY }
879
+ }
198
880
  }));
199
881
  }
200
882
  const row = await organizations.upsertPendingInvite(orgId, {
201
883
  email,
202
- role,
884
+ roleKey,
203
885
  invitedBy: inviterUserId,
204
886
  tokenHash,
205
887
  expiresAt
206
888
  });
207
889
  events.push(organizationEvents.inviteCreated.make({
208
890
  orgId,
209
- data: row
891
+ data: row,
892
+ metadata: { actorId: actor.id }
210
893
  }));
211
894
  await outbox.append(events);
212
895
  return row;
213
896
  });
214
897
  if (sendEmail) try {
215
- await this.sendInviteEmail(invite, token);
898
+ await this.sendInviteEmail(invite, token, role);
216
899
  } catch (err) {
217
900
  this.logger.error("failed to send invite email", {
218
901
  orgId,
219
902
  inviteId: invite.id,
220
- role: invite.role,
903
+ roleKey,
221
904
  err: err instanceof Error ? err : new Error(String(err))
222
905
  });
223
906
  }
224
907
  this.logger.info("invite created", {
225
908
  orgId,
226
909
  inviteId: invite.id,
227
- role,
228
- sendEmail
910
+ roleKey,
911
+ sendEmail,
912
+ actorId: actor.id
229
913
  });
230
914
  return invite;
231
915
  }
232
- async resendStudentInvite(actor, input) {
233
- const { orgId, orgUserId, inviterUserId } = input;
234
- this.assertManagesUsers(actor, orgId);
235
- const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);
236
- if (!orgUser || orgUser.role !== "student") throw new NotFoundError("Student", orgUserId);
237
- if (orgUser.status === "active") throw new OrganizationRuleError("That student has already accepted their invitation");
238
- const person = await this.people.getUserById(orgUser.userId);
239
- if (!person) throw new NotFoundError("User", orgUser.userId);
240
- const pending = await this.repo.findPendingInvite(orgId, person.email);
241
- if (!pending) throw new NotFoundError("Invite", person.email);
916
+ async resendInvite(orgUserId, actor, inviterUserId) {
917
+ actor.permissions.require("people:write");
918
+ const orgId = actor.orgId;
919
+ const { invite, role } = await this.pendingInviteOf(orgId, orgUserId);
920
+ this.assertHolds(actor, role.permissions);
242
921
  const { token, tokenHash } = generateInviteToken();
243
922
  const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
244
- const invite = await this.repo.setInviteToken(orgId, pending.id, tokenHash, expiresAt);
245
- if (!invite) throw new NotFoundError("Invite", pending.id);
246
- await this.sendInviteEmail(invite, token);
247
- this.logger.info("student invite resent", {
923
+ const refreshed = await this.repo.setInviteToken(orgId, invite.id, tokenHash, expiresAt, inviterUserId);
924
+ if (!refreshed) throw new NotFoundError("Invite", invite.id);
925
+ await this.sendInviteEmail(refreshed, token, role);
926
+ this.logger.info("invite resent", {
927
+ orgId,
928
+ orgUserId,
929
+ inviteId: invite.id,
930
+ actorId: actor.id
931
+ });
932
+ }
933
+ async cancelInvite(orgUserId, actor) {
934
+ actor.permissions.require("people:write");
935
+ const orgId = actor.orgId;
936
+ const { invite, role, orgUser } = await this.pendingInviteOf(orgId, orgUserId);
937
+ this.assertHolds(actor, role.permissions);
938
+ const held = await this.roles.assignmentsOf(orgId, orgUserId);
939
+ const holdsGrant = this.grants ? await this.grants().holdsAnyGrant(orgId, orgUserId) : true;
940
+ await this.uow.run(async (scope) => {
941
+ const canceled = await scope.organizations.setInviteStatus(orgId, invite.id, "canceled");
942
+ if (!canceled) throw new NotFoundError("Invite", invite.id);
943
+ await scope.outbox.append([organizationEvents.inviteCanceled.make({
944
+ orgId,
945
+ data: canceled,
946
+ metadata: { actorId: actor.id }
947
+ })]);
948
+ if (orgUser.status === "invited" && held.length === 0 && !holdsGrant) await this.deleteOrgUserIn(scope, orgId, orgUser, held, actor.id);
949
+ });
950
+ this.logger.info("invite canceled", {
248
951
  orgId,
249
952
  orgUserId,
250
953
  inviteId: invite.id,
251
- inviterUserId
954
+ actorId: actor.id
252
955
  });
253
956
  }
254
957
  async peekInvite(token) {
@@ -257,7 +960,17 @@ var OrganizationServiceImpl = class {
257
960
  if (invite.expiresAt && invite.expiresAt < /* @__PURE__ */ new Date()) return null;
258
961
  return invite;
259
962
  }
260
- async acceptInvite(input) {
963
+ async describeInvite(token) {
964
+ const invite = await this.peekInvite(token);
965
+ if (!invite) throw new NotFoundError("Invite", "token");
966
+ const person = await this.people.getUserByEmail(invite.email);
967
+ return {
968
+ email: invite.email,
969
+ firstName: person?.firstName ?? null,
970
+ lastName: person?.lastName ?? null
971
+ };
972
+ }
973
+ async acceptInvite(input, headers) {
261
974
  const invite = await this.peekInvite(input.token);
262
975
  if (!invite) {
263
976
  this.logger.warn("invite accept refused: token invalid or expired");
@@ -270,256 +983,467 @@ var OrganizationServiceImpl = class {
270
983
  });
271
984
  throw new InviteError("emails must match");
272
985
  }
273
- const org = await this.repo.findById(invite.orgId);
986
+ const orgId = invite.orgId;
987
+ const org = await this.repo.findById(orgId);
274
988
  if (!org) throw new InviteError("Org not found");
275
- const orgUser = invite.role === "student" ? await this.acceptStudentInvite(invite, input.userId) : await this.acceptStaffInvite(invite, org, input.userId);
276
- this.logger.info("invite accepted", {
277
- orgId: invite.orgId,
278
- userId: input.userId
279
- });
280
- return orgUser;
281
- }
282
- async acceptStudentInvite(invite, userId) {
283
- return this.uow.run(async ({ organizations, outbox }) => {
284
- const existing = await organizations.findOrgUser(invite.orgId, userId);
285
- if (!existing) throw new NotFoundError("Member", userId);
286
- const activated = await organizations.updateOrgUser(invite.orgId, existing.id, { status: "active" });
287
- if (!activated) throw new NotFoundError("Member", existing.id);
288
- const acceptedInvite = await organizations.setInviteStatus(invite.orgId, invite.id, "accepted");
989
+ const person = await this.people.getUserByExternalId(input.authUserId);
990
+ if (!person?.externalId) throw new NotFoundError("User", input.authUserId);
991
+ const orgUser = await this.repo.findOrgUser(orgId, person.id);
992
+ if (!orgUser) throw new NotFoundError("Person", person.id);
993
+ const inviter = await this.repo.findOrgUser(orgId, invite.invitedBy);
994
+ if (!inviter) {
995
+ this.logger.warn("invite accept refused: inviter left", {
996
+ orgId,
997
+ inviteId: invite.id
998
+ });
999
+ throw new InviteError("This invitation is no longer valid; ask for a new one");
1000
+ }
1001
+ await this.uow.run((scope) => this.checkAcceptance(scope, invite, orgUser, inviter.id));
1002
+ await this.orgAdmin().grantMembership(org.externalId ?? org.id, person.externalId);
1003
+ const accepted = await this.uow.run(async (scope) => {
1004
+ const { organizations, roles, outbox } = scope;
1005
+ const seen = (await roles.assignmentsOf(orgId, orgUser.id)).find((row) => row.contentId === null);
1006
+ await roles.lock(lockKeys(orgId, [invite.roleKey, seen?.roleKey ?? "member"], [orgUser.id, inviter.id]));
1007
+ const previousKey = await this.checkAcceptance(scope, invite, orgUser, inviter.id);
1008
+ const activated = orgUser.status === "active" ? orgUser : await organizations.updateOrgUser(orgId, orgUser.id, { status: "active" });
1009
+ if (!activated) throw new NotFoundError("Person", orgUser.id);
1010
+ const acceptedInvite = await organizations.setInviteStatus(orgId, invite.id, "accepted");
289
1011
  if (!acceptedInvite) throw new NotFoundError("Invite", invite.id);
290
- await outbox.append([organizationEvents.inviteAccepted.make({
291
- orgId: invite.orgId,
1012
+ await roles.setOrgRole(orgId, orgUser.id, invite.roleKey === "member" ? null : invite.roleKey, inviter.id);
1013
+ const events = [organizationEvents.inviteAccepted.make({
1014
+ orgId,
292
1015
  data: acceptedInvite
293
- }), organizationEvents.studentLinked.make({
294
- orgId: invite.orgId,
295
- data: activated
296
- })]);
1016
+ })];
1017
+ if (orgUser.status !== "active") {
1018
+ events.push(organizationEvents.orgUserUpdated.make({
1019
+ orgId,
1020
+ data: {
1021
+ ...activated,
1022
+ orgRole: { key: invite.roleKey }
1023
+ }
1024
+ }));
1025
+ if (invite.roleKey === "member") events.push(organizationEvents.studentLinked.make({
1026
+ orgId,
1027
+ data: activated
1028
+ }));
1029
+ }
1030
+ if (previousKey !== invite.roleKey) events.push(organizationEvents.roleAssigned.make({
1031
+ orgId,
1032
+ data: {
1033
+ orgUserId: orgUser.id,
1034
+ roleKey: invite.roleKey,
1035
+ contentId: null,
1036
+ contentType: null,
1037
+ previousRoleKey: previousKey
1038
+ },
1039
+ metadata: { actorId: inviter.id }
1040
+ }));
1041
+ await outbox.append(events);
297
1042
  return activated;
298
1043
  });
1044
+ await this.sessions().stampActiveOrganization(headers, orgId);
1045
+ this.logger.info("invite accepted", {
1046
+ orgId,
1047
+ inviteId: invite.id,
1048
+ orgUserId: orgUser.id
1049
+ });
1050
+ return accepted;
299
1051
  }
300
- async acceptStaffInvite(invite, org, userId) {
301
- const person = await this.people.getUserById(userId);
302
- if (!person?.externalId) throw new NotFoundError("User", userId);
303
- await this.orgAdmin().grantMembership(org.externalId ?? org.id, person.externalId, invite.role);
304
- return this.uow.run(async ({ organizations, outbox }) => {
305
- const orgUser = await organizations.findOrgUser(invite.orgId, userId);
306
- if (!orgUser) throw new NotFoundError("Member", userId);
307
- const acceptedInvite = await organizations.setInviteStatus(invite.orgId, invite.id, "accepted");
308
- if (!acceptedInvite) throw new NotFoundError("Invite", invite.id);
309
- await outbox.append([organizationEvents.inviteAccepted.make({
310
- orgId: invite.orgId,
311
- data: acceptedInvite
312
- })]);
313
- return orgUser;
1052
+ async checkAcceptance(scope, invite, orgUser, inviterOrgUserId) {
1053
+ const orgId = invite.orgId;
1054
+ const stored = await scope.roles.storedRoles(orgId);
1055
+ const role = this.resolveIn(stored, invite.roleKey);
1056
+ if (!role || invite.roleKey === "owner") throw new InviteError("The invited role no longer exists");
1057
+ const current = (await scope.roles.assignmentsOf(orgId, orgUser.id)).find((row) => row.contentId === null);
1058
+ if (current?.roleKey === "owner") throw new InviteError("The owner's role changes only by transfer");
1059
+ const currentRole = this.resolveIn(stored, current?.roleKey ?? "member");
1060
+ const facts = await scope.roles.actorFacts(orgId, inviterOrgUserId);
1061
+ const inviter = facts && facts.status === "active" ? resolvePermissions({
1062
+ assignments: facts.assignments,
1063
+ roles: this.roleMap(facts.storedRoles)
1064
+ }) : null;
1065
+ const needed = [
1066
+ "people:write",
1067
+ ...role.permissions,
1068
+ ...currentRole?.permissions ?? []
1069
+ ];
1070
+ const missing = inviter ? needed.filter((p) => !inviter.has(p)) : needed;
1071
+ if (missing.length > 0) {
1072
+ this.logger.warn("invite accept refused: inviter no longer holds the role", {
1073
+ orgId,
1074
+ inviteId: invite.id,
1075
+ inviterOrgUserId,
1076
+ missing
1077
+ });
1078
+ throw new InviteError("This invitation is no longer valid; ask for a new one");
1079
+ }
1080
+ return current?.roleKey ?? "member";
1081
+ }
1082
+ async listApiKeys(actor) {
1083
+ actor.permissions.require("api_keys:read");
1084
+ const { orgExternalId, userExternalId } = await this.externalIdsOf(actor);
1085
+ const [keys, stored] = await Promise.all([this.apiKeys().list(orgExternalId, userExternalId), this.roles.storedRoles(actor.orgId)]);
1086
+ const roles = this.roleMap(stored);
1087
+ return keys.map((key) => keySummary(key, roles));
1088
+ }
1089
+ async listApiKeyRoles(actor) {
1090
+ actor.permissions.require("api_keys:write");
1091
+ const stored = await this.roles.storedRoles(actor.orgId);
1092
+ return resolveOrgRoles(this.catalog, stored).filter((role) => role.permissions.every((permission) => actor.permissions.anywhere(permission))).map((role) => ({
1093
+ key: role.key,
1094
+ name: role.name,
1095
+ description: role.description
1096
+ }));
1097
+ }
1098
+ async createApiKey(input, actor) {
1099
+ actor.permissions.require("api_keys:write");
1100
+ const roles = this.roleMap(await this.roles.storedRoles(actor.orgId));
1101
+ const role = roles.get(input.role);
1102
+ if (!role) throw new NotFoundError("Role", input.role);
1103
+ const missing = role.permissions.filter((permission) => !actor.permissions.anywhere(permission));
1104
+ if (missing.length > 0) {
1105
+ this.logger.warn("api key refused: role permissions not held", {
1106
+ orgId: actor.orgId,
1107
+ actorId: actor.id,
1108
+ roleKey: role.key,
1109
+ missing
1110
+ });
1111
+ throw new ForbiddenError(`You don't hold ${missing.join(", ")}`);
1112
+ }
1113
+ const { orgExternalId, userExternalId } = await this.externalIdsOf(actor);
1114
+ const created = await this.apiKeys().create(orgExternalId, userExternalId, {
1115
+ name: input.name,
1116
+ roleKey: role.key,
1117
+ expiresInDays: input.expiresInDays
1118
+ });
1119
+ this.logger.info("api key created", {
1120
+ orgId: actor.orgId,
1121
+ actorId: actor.id,
1122
+ keyId: created.id,
1123
+ roleKey: role.key
314
1124
  });
1125
+ return {
1126
+ ...keySummary(created, roles),
1127
+ key: created.key
1128
+ };
315
1129
  }
316
- getOrgUserById(orgId, id) {
317
- return this.repo.findOrgUserById(orgId, id);
1130
+ async deleteApiKey(keyId, actor) {
1131
+ actor.permissions.require("api_keys:delete");
1132
+ const { orgExternalId, userExternalId } = await this.externalIdsOf(actor);
1133
+ await this.apiKeys().revoke(orgExternalId, userExternalId, keyId);
1134
+ this.logger.info("api key revoked", {
1135
+ orgId: actor.orgId,
1136
+ actorId: actor.id,
1137
+ keyId
1138
+ });
318
1139
  }
319
- async getOrgUser(orgId, userId) {
320
- return this.repo.findOrgUser(orgId, userId);
1140
+ async listConnectedApps(actor) {
1141
+ const { orgExternalId, userExternalId } = await this.externalIdsOf(actor);
1142
+ return this.apiKeys().listApps(orgExternalId, userExternalId);
321
1143
  }
322
- async deleteOrgUser(orgId, actor, id) {
323
- this.assertManagesUsers(actor, orgId);
324
- const deleted = await this.uow.run(async ({ organizations, outbox }) => {
325
- const orgUser = await organizations.findOrgUserById(orgId, id);
326
- if (!orgUser || orgUser.role !== "student") throw new NotFoundError("Student", id);
327
- const deletedUser = await organizations.deleteOrgUser(orgId, id);
328
- if (!deletedUser) throw new NotFoundError("Student", id);
329
- await outbox.append([organizationEvents.studentDeleted.make({
330
- orgId,
331
- data: orgUser
332
- })]);
333
- return deletedUser;
1144
+ async revokeConnectedApp(clientId, actor) {
1145
+ const { orgExternalId, userExternalId } = await this.externalIdsOf(actor);
1146
+ await this.apiKeys().revokeApp(orgExternalId, userExternalId, clientId);
1147
+ this.logger.info("connected app revoked", {
1148
+ orgId: actor.orgId,
1149
+ actorId: actor.id,
1150
+ clientId
334
1151
  });
335
- this.logger.info("org user deleted", {
336
- orgId,
337
- orgUserId: id
1152
+ }
1153
+ async createPlatformOrganization(input) {
1154
+ const ownerExternalId = await this.platformAccountId();
1155
+ const created = await this.orgAdmin().createOrganizationFor(ownerExternalId, {
1156
+ name: input.name,
1157
+ slug: input.slug
338
1158
  });
339
- return deleted;
1159
+ const org = await this.repo.findByExternalId(created.id);
1160
+ if (!org) throw new NotFoundError("Organization", created.id);
1161
+ const apiKey = await this.platformKey(org, ownerExternalId, input.keyName ?? "Platform");
1162
+ this.logger.info("platform organization created", {
1163
+ orgId: org.id,
1164
+ slug: org.slug
1165
+ });
1166
+ return {
1167
+ organization: {
1168
+ id: org.id,
1169
+ name: org.name,
1170
+ slug: org.slug,
1171
+ createdAt: org.createdAt
1172
+ },
1173
+ apiKey
1174
+ };
340
1175
  }
341
- async getOrgUsersForUser(userId) {
342
- return this.repo.findOrgUsersByUser(userId);
1176
+ async createPlatformApiKey(orgId, name) {
1177
+ const account = this.platformAccount();
1178
+ const [org, person] = await Promise.all([this.repo.findById(orgId), this.people.getUserByEmail(account.email)]);
1179
+ const orgUser = org && person ? await this.repo.findOrgUser(org.id, person.id) : null;
1180
+ if (!org || !person?.externalId || orgUser?.status !== "active") throw new NotFoundError("Organization", orgId);
1181
+ const apiKey = await this.platformKey(org, person.externalId, name);
1182
+ this.logger.info("platform api key created", {
1183
+ orgId: org.id,
1184
+ keyId: apiKey.id
1185
+ });
1186
+ return apiKey;
343
1187
  }
344
- async getById(id) {
345
- return this.repo.findById(id);
1188
+ isPlatformAccount(email) {
1189
+ return this.platform !== void 0 && email.toLowerCase() === this.platform.email;
1190
+ }
1191
+ async platformKey(org, ownerExternalId, name) {
1192
+ const [created, stored] = await Promise.all([this.apiKeys().create(org.externalId ?? org.id, ownerExternalId, {
1193
+ name,
1194
+ roleKey: OWNER_ROLE_KEY
1195
+ }), this.roles.storedRoles(org.id)]);
1196
+ return {
1197
+ ...keySummary(created, this.roleMap(stored)),
1198
+ key: created.key
1199
+ };
1200
+ }
1201
+ async platformAccountId() {
1202
+ const account = this.platformAccount();
1203
+ const person = await this.people.getUserByEmail(account.email);
1204
+ if (person?.externalId) return person.externalId;
1205
+ const { externalId } = await this.accounts().createAccount(account);
1206
+ this.logger.info("platform account created", { userId: externalId });
1207
+ return externalId;
1208
+ }
1209
+ platformAccount() {
1210
+ if (!this.platform) throw new Error("the platform account is not configured");
1211
+ return this.platform;
1212
+ }
1213
+ accounts() {
1214
+ if (!this.accountsPort) throw new Error("accounts are not configured");
1215
+ return this.accountsPort();
1216
+ }
1217
+ async keysWithRole(orgId, roleKey) {
1218
+ if (!this.apiKeysPort) return 0;
1219
+ const org = await this.repo.findById(orgId);
1220
+ return org ? this.apiKeysPort().countWithRole(org.externalId ?? org.id, roleKey) : 0;
1221
+ }
1222
+ async cleanUpAfterRemoval(orgId, orgUserId, person) {
1223
+ const steps = [];
1224
+ const org = await this.repo.findById(orgId);
1225
+ if (person?.externalId && org) {
1226
+ const orgExternalId = org.externalId ?? org.id;
1227
+ const userExternalId = person.externalId;
1228
+ steps.push(["membership", () => this.orgAdmin().removeMembership(orgExternalId, userExternalId)]);
1229
+ steps.push(["api keys and tokens", () => this.apiKeys().revokeAllFor(orgExternalId, userExternalId)]);
1230
+ }
1231
+ steps.push(["profile photo", () => this.images.syncPublicImage(orgId, orgUserId, null)]);
1232
+ if (person) steps.push(["pending invite", async () => {
1233
+ const pending = await this.repo.findPendingInvite(orgId, person.email);
1234
+ if (pending) await this.repo.setInviteStatus(orgId, pending.id, "canceled");
1235
+ }]);
1236
+ for (const [step, run] of steps) for (let attempt = 1; attempt <= 3; attempt++) try {
1237
+ await run();
1238
+ break;
1239
+ } catch (err) {
1240
+ this.logger.error("cleanup after removal failed", {
1241
+ orgId,
1242
+ orgUserId,
1243
+ step,
1244
+ attempt,
1245
+ err: err instanceof Error ? err : new Error(String(err))
1246
+ });
1247
+ }
1248
+ }
1249
+ apiKeys() {
1250
+ if (!this.apiKeysPort) throw new Error("api keys are not configured");
1251
+ return this.apiKeysPort();
346
1252
  }
347
- async sendInviteEmail(invite, token) {
1253
+ async externalIdsOf(actor) {
1254
+ const [org, orgUser] = await Promise.all([this.repo.findById(actor.orgId), this.repo.findOrgUserById(actor.orgId, actor.id)]);
1255
+ if (!org || !orgUser) throw new NotFoundError("Person", actor.id);
1256
+ const person = await this.people.getUserById(orgUser.userId);
1257
+ if (!person?.externalId) throw new NotFoundError("User", orgUser.userId);
1258
+ return {
1259
+ orgExternalId: org.externalId ?? org.id,
1260
+ userExternalId: person.externalId
1261
+ };
1262
+ }
1263
+ async pendingInviteOf(orgId, orgUserId) {
1264
+ const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);
1265
+ if (!orgUser) throw new NotFoundError("Person", orgUserId);
1266
+ const person = await this.people.getUserById(orgUser.userId);
1267
+ const invite = person ? await this.repo.findPendingInvite(orgId, person.email) : null;
1268
+ if (!invite) throw new NotFoundError("Invite", orgUserId);
1269
+ const stored = await this.roles.storedRoles(orgId);
1270
+ const role = this.resolveIn(stored, invite.roleKey);
1271
+ if (!role) throw new NotFoundError("Role", invite.roleKey);
1272
+ return {
1273
+ invite,
1274
+ role,
1275
+ orgUser
1276
+ };
1277
+ }
1278
+ async deleteOrgUserRow(orgId, orgUser) {
1279
+ const held = await this.roles.assignmentsOf(orgId, orgUser.id);
1280
+ return this.uow.run((scope) => this.deleteOrgUserIn(scope, orgId, orgUser, held, SYSTEM_ID));
1281
+ }
1282
+ async deleteOrgUserIn(scope, orgId, orgUser, held, actorId) {
1283
+ const orgRoleKey = held.find((row) => row.contentId === null)?.roleKey ?? "member";
1284
+ const deleted = await scope.organizations.deleteOrgUser(orgId, orgUser.id);
1285
+ if (!deleted) return null;
1286
+ const events = [organizationEvents.orgUserDeleted.make({
1287
+ orgId,
1288
+ data: {
1289
+ ...deleted,
1290
+ orgRole: { key: orgRoleKey }
1291
+ },
1292
+ metadata: { actorId }
1293
+ })];
1294
+ if (orgRoleKey === "member") events.push(organizationEvents.studentDeleted.make({
1295
+ orgId,
1296
+ data: deleted
1297
+ }));
1298
+ await scope.outbox.append(events);
1299
+ return deleted;
1300
+ }
1301
+ async sendInviteEmail(invite, token, role) {
348
1302
  if (!this.mailer || !this.inviteUrls) throw new Error("invite delivery is not configured (mailer / invite urls missing)");
349
- const { email, role } = invite;
350
- const isStudent = role === STUDENT_ROLE;
351
- const inviteUrl = `${isStudent ? `${this.inviteUrls.studentPortalUrl}/welcome` : `${this.inviteUrls.adminAppUrl}/invite`}?${(isStudent ? new URLSearchParams({ token }) : new URLSearchParams({
1303
+ const learner = role.permissions.length === 0;
1304
+ const inviteUrl = `${learner ? `${this.inviteUrls.studentPortalUrl}/welcome` : `${this.inviteUrls.adminAppUrl}/invite`}?${(learner ? new URLSearchParams({ token }) : new URLSearchParams({
352
1305
  token,
353
- email
1306
+ email: invite.email
354
1307
  })).toString()}`;
355
- if (role === "student") await this.mailer.send(email, "studentInvite", {
1308
+ if (learner) await this.mailer.send(invite.email, "studentInvite", {
356
1309
  inviteUrl,
357
- studentName: email
1310
+ studentName: invite.email
358
1311
  });
359
- else await this.mailer.send(email, "memberInvite", {
1312
+ else await this.mailer.send(invite.email, "memberInvite", {
360
1313
  inviteUrl,
361
1314
  inviterName: "Your team",
362
- role
1315
+ role: role.name
363
1316
  });
364
1317
  }
365
- async getByExternalId(externalId) {
366
- return this.repo.findByExternalId(externalId);
1318
+ toMember(row, nameOf) {
1319
+ const orgRoleKey = row.status === "invited" && row.inviteRoleKey ? row.inviteRoleKey : row.orgRoleKey ?? "member";
1320
+ return {
1321
+ id: row.id,
1322
+ firstName: row.firstName,
1323
+ lastName: row.lastName,
1324
+ email: row.email,
1325
+ image: row.image,
1326
+ orgRole: {
1327
+ key: orgRoleKey,
1328
+ name: nameOf(orgRoleKey)
1329
+ },
1330
+ isOwner: row.orgRoleKey === OWNER_ROLE_KEY,
1331
+ items: row.items.map((item) => ({
1332
+ ...item,
1333
+ roleName: nameOf(item.roleKey)
1334
+ })),
1335
+ status: row.status,
1336
+ joinedAt: row.status === "active" ? row.joinedAt.toISOString() : null,
1337
+ invitedAt: row.invitedAt ? row.invitedAt.toISOString() : null
1338
+ };
367
1339
  }
368
- async getBySlug(slug) {
369
- return this.repo.findBySlug(slug);
1340
+ async assignmentViews(orgId, orgUserId) {
1341
+ const [rows, stored] = await Promise.all([this.roles.assignmentsOf(orgId, orgUserId), this.roles.storedRoles(orgId)]);
1342
+ const roles = this.roleMap(stored);
1343
+ const views = rows.map((row) => ({
1344
+ orgUserId,
1345
+ roleKey: row.roleKey,
1346
+ roleName: roles.get(row.roleKey)?.name ?? row.roleKey,
1347
+ contentId: row.contentId,
1348
+ contentType: row.contentType
1349
+ }));
1350
+ if (!rows.some((row) => row.contentId === null)) views.unshift({
1351
+ orgUserId,
1352
+ roleKey: MEMBER_ROLE_KEY,
1353
+ roleName: roles.get("member")?.name ?? "Member",
1354
+ contentId: null,
1355
+ contentType: null
1356
+ });
1357
+ return views;
370
1358
  }
371
- staffOrgUserIds(orgId) {
372
- return this.repo.findOrgUserIdsByRole(orgId, [...STAFF_ROLES]);
373
- }
374
- listMembers(orgId, query) {
375
- return this.membersRepo.list(orgId, query);
376
- }
377
- async updateMemberRole(ctx, actor, id, role) {
378
- this.assertManagesUsers(actor, ctx.orgId);
379
- this.assertNotOwner(role, ctx.orgId);
380
- const member = await this.membersRepo.findById(ctx.orgId, id);
381
- if (!member) return this.promoteStudent(ctx, actor, id, role);
382
- if (member.role === "owner") {
383
- this.logger.warn("role change rejected: owner role immutable", {
384
- orgId: ctx.orgId,
385
- memberId: id
386
- });
387
- throw new OrganizationRuleError("The owner role cannot be reassigned");
388
- }
389
- if (member.kind !== "member" || !member.userExternalId) {
390
- this.logger.warn("role change rejected: not an active member", {
391
- orgId: ctx.orgId,
392
- memberId: id
393
- });
394
- throw new OrganizationRuleError("Only active members can have their role changed");
395
- }
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));
399
- const updated = await this.membersRepo.findById(ctx.orgId, id);
400
- this.logger.info("member role updated", {
401
- orgId: ctx.orgId,
402
- memberId: id,
403
- role
404
- });
405
- return updated ? toMember(updated) : null;
406
- }
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
1359
+ roleMap(stored) {
1360
+ return new Map(resolveOrgRoles(this.catalog, stored).map((role) => [role.key, role]));
1361
+ }
1362
+ resolveIn(stored, key) {
1363
+ return resolveRole(key, this.catalog, stored.find((row) => row.key === key));
1364
+ }
1365
+ async findRole(orgId, key) {
1366
+ const role = this.resolveIn(await this.roles.storedRoles(orgId), key);
1367
+ if (!role) throw new NotFoundError("Role", key);
1368
+ return role;
1369
+ }
1370
+ async relockedActor(scope, actor) {
1371
+ if (actor.id === SYSTEM_ID) return actor;
1372
+ const facts = await scope.roles.actorFacts(actor.orgId, actor.id);
1373
+ if (!facts || facts.status !== "active") throw new ForbiddenError("You are no longer an active member of this organization");
1374
+ const fresh = resolvePermissions({
1375
+ assignments: facts.assignments,
1376
+ roles: this.roleMap(facts.storedRoles)
421
1377
  });
422
1378
  return {
423
- ...toMember(member),
424
- role: orgUser.role,
425
- status: orgUser.status
1379
+ ...actor,
1380
+ permissions: {
1381
+ has: (p, on) => fresh.has(p, on) && actor.permissions.has(p, on),
1382
+ require: (p, on) => {
1383
+ if (!(fresh.has(p, on) && actor.permissions.has(p, on))) throw new ForbiddenError(`You need ${p}`);
1384
+ },
1385
+ anywhere: (p) => fresh.anywhere(p) && actor.permissions.anywhere(p),
1386
+ where: (p) => actor.permissions.where(p),
1387
+ roleOn: (on) => fresh.roleOn(on),
1388
+ isOwner: () => fresh.isOwner() && actor.permissions.isOwner()
1389
+ }
426
1390
  };
427
1391
  }
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
1392
+ itemPermissions(role, type) {
1393
+ if (!role || !role.levels.includes(type)) return [];
1394
+ return role.permissions.filter((p) => permissionAbout(p) === type);
1395
+ }
1396
+ assertHolds(actor, permissions) {
1397
+ const missing = [...new Set(permissions)].filter((p) => !actor.permissions.has(p));
1398
+ if (missing.length > 0) {
1399
+ this.logger.warn("refused: editor lacks permissions", {
1400
+ orgId: actor.orgId,
1401
+ actorId: actor.id,
1402
+ missing
435
1403
  });
436
- throw new OrganizationRuleError("Only active members can have their role changed");
1404
+ throw new ForbiddenError(`You need ${missing.join(", ")}`);
437
1405
  }
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
1406
+ }
1407
+ assertHoldsOn(actor, permissions, contentId) {
1408
+ const missing = [...new Set(permissions)].filter((p) => !actor.permissions.has(p, contentId));
1409
+ if (missing.length > 0) {
1410
+ this.logger.warn("refused: editor lacks permissions on the item", {
1411
+ orgId: actor.orgId,
1412
+ actorId: actor.id,
1413
+ contentId,
1414
+ missing
443
1415
  });
444
- throw new OrganizationRuleError("That student has not joined yet");
1416
+ throw new ForbiddenError(`You need ${missing.join(", ")} there`);
445
1417
  }
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
1418
+ }
1419
+ assertNotSelf(actor, orgUserId) {
1420
+ if (actor.id === orgUserId) {
1421
+ this.logger.warn("refused: changing own roles", {
1422
+ orgId: actor.orgId,
1423
+ orgUserId
459
1424
  });
460
- throw new OrganizationRuleError("The role change did not take effect");
1425
+ throw new ForbiddenError("You can't change your own roles");
461
1426
  }
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
1427
  }
476
- memberWrite(orgUserId) {
477
- const write = this.memberWrites.getStore();
478
- return write?.orgUserId === orgUserId ? write : void 0;
1428
+ assertUniqueName(stored, key, name) {
1429
+ const wanted = name.trim().toLowerCase();
1430
+ if (wanted.length === 0) throw new OrganizationRuleError("A role needs a name");
1431
+ if (resolveOrgRoles(this.catalog, stored).some((role) => role.key !== key && role.name.trim().toLowerCase() === wanted)) throw new OrganizationRuleError(`A role named ${name} already exists`);
479
1432
  }
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
- }
1433
+ parseLevels(levels) {
1434
+ if (levels.length === 0) throw new OrganizationRuleError("A role needs at least one level");
1435
+ for (const level of levels) if (!levelSchema.safeParse(level).success) throw new OrganizationRuleError(`Unknown level ${String(level)}`);
1436
+ return [...new Set(levels)];
488
1437
  }
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
- }
1438
+ assertFits(row) {
1439
+ const base = this.catalog.get(row.base);
1440
+ const levels = row.levels ?? base?.levels ?? [];
1441
+ const misfits = Object.entries(row.permissions).filter(([, held]) => held).map(([p]) => p).filter((p) => !fitsLevels(p, levels));
1442
+ if (misfits.length > 0) throw new OrganizationRuleError(`${misfits.join(", ")} ${misfits.length === 1 ? "doesn't" : "don't"} fit the role's levels`);
494
1443
  }
495
- async removeMember(ctx, actor, id) {
496
- this.assertManagesUsers(actor, ctx.orgId);
497
- const member = await this.membersRepo.findById(ctx.orgId, id);
498
- if (!member) return false;
499
- if (member.role === "owner") {
500
- this.logger.warn("member removal rejected: owner cannot be removed", {
501
- orgId: ctx.orgId,
502
- memberId: id
503
- });
504
- throw new OrganizationRuleError("The owner cannot be removed");
505
- }
506
- if (member.kind === "member" && member.userExternalId) await this.orgAdmin().removeMember(ctx, member.userExternalId);
507
- else if (member.kind === "invite" && member.inviteId) {
508
- const inviteId = member.inviteId;
509
- await this.uow.run(async ({ organizations, outbox }) => {
510
- const canceled = await organizations.setInviteStatus(ctx.orgId, inviteId, "canceled");
511
- if (!canceled) throw new NotFoundError("Invite", inviteId);
512
- await outbox.append([organizationEvents.inviteCanceled.make({
513
- orgId: ctx.orgId,
514
- data: canceled
515
- })]);
516
- });
517
- }
518
- this.logger.info("member removed", {
519
- orgId: ctx.orgId,
520
- memberId: id
521
- });
522
- return true;
1444
+ async assertLevelsNotInUse(roles, orgId, before, after) {
1445
+ const removed = before.levels.filter((level) => !after.levels.includes(level));
1446
+ for (const level of removed) if (await roles.countHolders(orgId, before.key, level) > 0) throw new OrganizationRuleError(`${before.name} is given on ${level === "org" ? "the organization" : `a ${level}`}; remove those first`);
523
1447
  }
524
1448
  };
525
1449
  //#endregion