@headless-lms/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/dist/assets/events.d.ts +42 -24
  2. package/dist/assets/events.js +1 -1
  3. package/dist/assets/events.js.map +1 -1
  4. package/dist/assets/file-urls.d.ts +20 -0
  5. package/dist/assets/file-urls.js +25 -0
  6. package/dist/assets/file-urls.js.map +1 -0
  7. package/dist/assets/index.d.ts +6 -4
  8. package/dist/assets/index.js +4 -2
  9. package/dist/assets/jobs.d.ts +15 -0
  10. package/dist/assets/jobs.js +30 -0
  11. package/dist/assets/jobs.js.map +1 -0
  12. package/dist/assets/model.d.ts +2 -2
  13. package/dist/assets/ports.d.ts +46 -15
  14. package/dist/assets/service.d.ts +38 -7
  15. package/dist/assets/service.js +287 -38
  16. package/dist/assets/service.js.map +1 -1
  17. package/dist/automations/actions.d.ts +13 -24
  18. package/dist/automations/actions.js +106 -42
  19. package/dist/automations/actions.js.map +1 -1
  20. package/dist/automations/catalog.js +226 -3
  21. package/dist/automations/catalog.js.map +1 -1
  22. package/dist/automations/community-derivations.d.ts +14 -0
  23. package/dist/automations/community-derivations.js +439 -0
  24. package/dist/automations/community-derivations.js.map +1 -0
  25. package/dist/automations/community-mail.d.ts +98 -0
  26. package/dist/automations/community-mail.js +70 -0
  27. package/dist/automations/community-mail.js.map +1 -0
  28. package/dist/automations/events.d.ts +18 -0
  29. package/dist/automations/index.d.ts +13 -4
  30. package/dist/automations/index.js +10 -2
  31. package/dist/automations/model.d.ts +10 -3
  32. package/dist/automations/model.js +15 -2
  33. package/dist/automations/model.js.map +1 -1
  34. package/dist/automations/notifications/debounce.d.ts +25 -0
  35. package/dist/automations/notifications/debounce.js +93 -0
  36. package/dist/automations/notifications/debounce.js.map +1 -0
  37. package/dist/automations/notifications/defaults.d.ts +8 -0
  38. package/dist/automations/notifications/defaults.js +316 -0
  39. package/dist/automations/notifications/defaults.js.map +1 -0
  40. package/dist/automations/notifications/delivery.d.ts +28 -0
  41. package/dist/automations/notifications/delivery.js +88 -0
  42. package/dist/automations/notifications/delivery.js.map +1 -0
  43. package/dist/automations/notifications/processor.d.ts +64 -0
  44. package/dist/automations/notifications/processor.js +270 -0
  45. package/dist/automations/notifications/processor.js.map +1 -0
  46. package/dist/automations/notifications/rules/content.d.ts +6 -0
  47. package/dist/automations/notifications/rules/content.js +39 -0
  48. package/dist/automations/notifications/rules/content.js.map +1 -0
  49. package/dist/automations/notifications/rules/discussion.d.ts +7 -0
  50. package/dist/automations/notifications/rules/discussion.js +678 -0
  51. package/dist/automations/notifications/rules/discussion.js.map +1 -0
  52. package/dist/automations/notifications/rules/entitlements.d.ts +6 -0
  53. package/dist/automations/notifications/rules/entitlements.js +249 -0
  54. package/dist/automations/notifications/rules/entitlements.js.map +1 -0
  55. package/dist/automations/notifications/rules/index.d.ts +6 -0
  56. package/dist/automations/notifications/rules/index.js +15 -0
  57. package/dist/automations/notifications/rules/index.js.map +1 -0
  58. package/dist/automations/notifications/rules/organizations.d.ts +6 -0
  59. package/dist/automations/notifications/rules/organizations.js +28 -0
  60. package/dist/automations/notifications/rules/organizations.js.map +1 -0
  61. package/dist/automations/notifications/rules/recipients.d.ts +32 -0
  62. package/dist/automations/notifications/rules/recipients.js +99 -0
  63. package/dist/automations/notifications/rules/recipients.js.map +1 -0
  64. package/dist/automations/notifications/service.d.ts +27 -0
  65. package/dist/automations/notifications/service.js +45 -0
  66. package/dist/automations/notifications/service.js.map +1 -0
  67. package/dist/automations/notifications/types.d.ts +7 -0
  68. package/dist/automations/notifications/types.js +473 -0
  69. package/dist/automations/notifications/types.js.map +1 -0
  70. package/dist/automations/ports.d.ts +121 -3
  71. package/dist/automations/schedule.d.ts +6 -0
  72. package/dist/automations/schedule.js +41 -0
  73. package/dist/automations/schedule.js.map +1 -0
  74. package/dist/automations/service.d.ts +20 -4
  75. package/dist/automations/service.js +246 -45
  76. package/dist/automations/service.js.map +1 -1
  77. package/dist/content/events.d.ts +712 -50
  78. package/dist/content/events.js +55 -1
  79. package/dist/content/events.js.map +1 -1
  80. package/dist/content/index.d.ts +6 -4
  81. package/dist/content/index.js +3 -1
  82. package/dist/content/model.d.ts +2 -2
  83. package/dist/content/notification-categories.d.ts +36 -0
  84. package/dist/content/notification-categories.js +64 -0
  85. package/dist/content/notification-categories.js.map +1 -0
  86. package/dist/content/ports.d.ts +90 -18
  87. package/dist/content/service.d.ts +57 -18
  88. package/dist/content/service.js +520 -25
  89. package/dist/content/service.js.map +1 -1
  90. package/dist/content/titles.d.ts +22 -0
  91. package/dist/content/titles.js +69 -0
  92. package/dist/content/titles.js.map +1 -0
  93. package/dist/content/types.d.ts +2 -2
  94. package/dist/discussion/authors.d.ts +10 -0
  95. package/dist/discussion/authors.js +23 -0
  96. package/dist/discussion/authors.js.map +1 -0
  97. package/dist/discussion/chats.d.ts +109 -0
  98. package/dist/discussion/chats.js +831 -0
  99. package/dist/discussion/chats.js.map +1 -0
  100. package/dist/discussion/digests.d.ts +24 -0
  101. package/dist/discussion/digests.js +104 -0
  102. package/dist/discussion/digests.js.map +1 -0
  103. package/dist/discussion/events.d.ts +785 -56
  104. package/dist/discussion/events.js +165 -5
  105. package/dist/discussion/events.js.map +1 -1
  106. package/dist/discussion/index.d.ts +12 -3
  107. package/dist/discussion/index.js +9 -1
  108. package/dist/discussion/jobs.d.ts +16 -0
  109. package/dist/discussion/jobs.js +40 -0
  110. package/dist/discussion/jobs.js.map +1 -0
  111. package/dist/discussion/mentions.d.ts +11 -0
  112. package/dist/discussion/mentions.js +35 -0
  113. package/dist/discussion/mentions.js.map +1 -0
  114. package/dist/discussion/ports.d.ts +372 -28
  115. package/dist/discussion/ports.js +6 -1
  116. package/dist/discussion/ports.js.map +1 -0
  117. package/dist/discussion/posts.d.ts +113 -0
  118. package/dist/discussion/posts.js +775 -0
  119. package/dist/discussion/posts.js.map +1 -0
  120. package/dist/discussion/service.d.ts +42 -11
  121. package/dist/discussion/service.js +364 -85
  122. package/dist/discussion/service.js.map +1 -1
  123. package/dist/discussion/subscriptions.d.ts +37 -0
  124. package/dist/discussion/subscriptions.js +99 -0
  125. package/dist/discussion/subscriptions.js.map +1 -0
  126. package/dist/discussion/titles.d.ts +26 -0
  127. package/dist/discussion/titles.js +84 -0
  128. package/dist/discussion/titles.js.map +1 -0
  129. package/dist/entitlements/events.d.ts +178 -11
  130. package/dist/entitlements/events.js +28 -1
  131. package/dist/entitlements/events.js.map +1 -1
  132. package/dist/entitlements/index.d.ts +6 -4
  133. package/dist/entitlements/index.js +3 -1
  134. package/dist/entitlements/jobs.d.ts +12 -0
  135. package/dist/entitlements/jobs.js +18 -0
  136. package/dist/entitlements/jobs.js.map +1 -0
  137. package/dist/entitlements/model.d.ts +3 -2
  138. package/dist/entitlements/ports.d.ts +128 -11
  139. package/dist/entitlements/rules.d.ts +12 -0
  140. package/dist/entitlements/rules.js +17 -0
  141. package/dist/entitlements/rules.js.map +1 -0
  142. package/dist/entitlements/service.d.ts +57 -3
  143. package/dist/entitlements/service.js +492 -2
  144. package/dist/entitlements/service.js.map +1 -1
  145. package/dist/identity/access.d.ts +6 -0
  146. package/dist/identity/access.js +7 -0
  147. package/dist/identity/access.js.map +1 -0
  148. package/dist/identity/devices.d.ts +30 -0
  149. package/dist/identity/devices.js +107 -0
  150. package/dist/identity/devices.js.map +1 -0
  151. package/dist/identity/events.d.ts +2 -2
  152. package/dist/identity/index.d.ts +7 -4
  153. package/dist/identity/index.js +4 -1
  154. package/dist/identity/model.d.ts +2 -1
  155. package/dist/identity/notification-settings.d.ts +28 -0
  156. package/dist/identity/notification-settings.js +113 -0
  157. package/dist/identity/notification-settings.js.map +1 -0
  158. package/dist/identity/ports.d.ts +97 -3
  159. package/dist/identity/service.d.ts +14 -3
  160. package/dist/identity/service.js +62 -5
  161. package/dist/identity/service.js.map +1 -1
  162. package/dist/integrations/events.d.ts +3 -3
  163. package/dist/integrations/model.js +1 -1
  164. package/dist/integrations/model.js.map +1 -1
  165. package/dist/integrations/service.js +2 -2
  166. package/dist/integrations/service.js.map +1 -1
  167. package/dist/organizations/access-scopes.d.ts +19 -0
  168. package/dist/organizations/access-scopes.js +89 -0
  169. package/dist/organizations/access-scopes.js.map +1 -0
  170. package/dist/organizations/events.d.ts +75 -33
  171. package/dist/organizations/events.js +6 -0
  172. package/dist/organizations/events.js.map +1 -1
  173. package/dist/organizations/index.d.ts +8 -6
  174. package/dist/organizations/index.js +5 -3
  175. package/dist/organizations/members.d.ts +8 -5
  176. package/dist/organizations/members.js.map +1 -1
  177. package/dist/organizations/notification-categories.d.ts +32 -0
  178. package/dist/organizations/notification-categories.js +51 -0
  179. package/dist/organizations/notification-categories.js.map +1 -0
  180. package/dist/organizations/ports.d.ts +17 -11
  181. package/dist/organizations/roles.d.ts +6 -10
  182. package/dist/organizations/roles.js +19 -7
  183. package/dist/organizations/roles.js.map +1 -1
  184. package/dist/organizations/service.d.ts +22 -11
  185. package/dist/organizations/service.js +164 -47
  186. package/dist/organizations/service.js.map +1 -1
  187. package/dist/organizations/types.d.ts +1 -10
  188. package/dist/progress/events.d.ts +14 -14
  189. package/dist/progress/index.d.ts +2 -2
  190. package/dist/progress/ports.d.ts +6 -1
  191. package/dist/progress/service.d.ts +3 -1
  192. package/dist/progress/service.js +6 -2
  193. package/dist/progress/service.js.map +1 -1
  194. package/dist/reporting/admin/index.d.ts +4 -0
  195. package/dist/reporting/admin/index.js +2 -0
  196. package/dist/reporting/admin/model.d.ts +108 -0
  197. package/dist/reporting/admin/ports.d.ts +27 -0
  198. package/dist/reporting/admin/service.d.ts +22 -0
  199. package/dist/reporting/admin/service.js +38 -0
  200. package/dist/reporting/admin/service.js.map +1 -0
  201. package/dist/reporting/member/index.d.ts +5 -0
  202. package/dist/reporting/member/index.js +2 -0
  203. package/dist/reporting/member/model.d.ts +157 -0
  204. package/dist/reporting/member/ports.d.ts +48 -0
  205. package/dist/reporting/member/service.d.ts +92 -0
  206. package/dist/reporting/member/service.js +364 -0
  207. package/dist/reporting/member/service.js.map +1 -0
  208. package/dist/shared/actor.d.ts +9 -0
  209. package/dist/shared/errors.d.ts +19 -1
  210. package/dist/shared/errors.js +27 -1
  211. package/dist/shared/errors.js.map +1 -1
  212. package/dist/shared/id.d.ts +14 -0
  213. package/dist/shared/id.js +15 -1
  214. package/dist/shared/id.js.map +1 -1
  215. package/dist/shared/mailer.d.ts +15 -2
  216. package/dist/shared/mailer.js +32 -5
  217. package/dist/shared/mailer.js.map +1 -1
  218. package/dist/shared/network.d.ts +10 -0
  219. package/dist/shared/network.js +211 -0
  220. package/dist/shared/network.js.map +1 -0
  221. package/dist/shared/notification-categories.d.ts +16 -0
  222. package/dist/shared/notification-categories.js +94 -0
  223. package/dist/shared/notification-categories.js.map +1 -0
  224. package/dist/shared/notification-settings.d.ts +14 -0
  225. package/dist/shared/notification-settings.js +61 -0
  226. package/dist/shared/notification-settings.js.map +1 -0
  227. package/dist/shared/ports.d.ts +48 -2
  228. package/dist/shared/settings.d.ts +2 -1
  229. package/dist/shared/settings.js +1 -0
  230. package/dist/shared/settings.js.map +1 -1
  231. package/dist/types/assets.d.ts +2 -2
  232. package/dist/types/automations.d.ts +15 -1
  233. package/dist/types/content.d.ts +2 -2
  234. package/dist/types/discussion.d.ts +11 -3
  235. package/dist/types/email-templates.d.ts +216 -1
  236. package/dist/types/entitlements.d.ts +2 -2
  237. package/dist/types/identity.d.ts +48 -2
  238. package/dist/types/index.d.ts +9 -8
  239. package/dist/types/notifications.d.ts +157 -0
  240. package/dist/types/ports.d.ts +6 -0
  241. package/dist/types/schemas/assets.d.ts +65 -37
  242. package/dist/types/schemas/assets.js +17 -12
  243. package/dist/types/schemas/assets.js.map +1 -1
  244. package/dist/types/schemas/automations.d.ts +9 -1
  245. package/dist/types/schemas/automations.js +13 -6
  246. package/dist/types/schemas/automations.js.map +1 -1
  247. package/dist/types/schemas/content.d.ts +396 -3
  248. package/dist/types/schemas/content.js +161 -3
  249. package/dist/types/schemas/content.js.map +1 -1
  250. package/dist/types/schemas/discussion.d.ts +443 -3
  251. package/dist/types/schemas/discussion.js +233 -4
  252. package/dist/types/schemas/discussion.js.map +1 -1
  253. package/dist/types/schemas/entitlements.d.ts +36 -2
  254. package/dist/types/schemas/entitlements.js +23 -1
  255. package/dist/types/schemas/entitlements.js.map +1 -1
  256. package/dist/types/schemas/identity.d.ts +27 -1
  257. package/dist/types/schemas/identity.js +14 -2
  258. package/dist/types/schemas/identity.js.map +1 -1
  259. package/dist/types/schemas/index.d.ts +9 -8
  260. package/dist/types/schemas/index.js +9 -8
  261. package/dist/types/schemas/notifications.d.ts +95 -0
  262. package/dist/types/schemas/notifications.js +56 -0
  263. package/dist/types/schemas/notifications.js.map +1 -0
  264. package/dist/types/schemas/organizations.d.ts +8 -0
  265. package/dist/types/schemas/organizations.js +2 -0
  266. package/dist/types/schemas/organizations.js.map +1 -1
  267. package/dist/types/schemas/progress.d.ts +3 -3
  268. package/dist/types/schemas/shared.d.ts +3 -1
  269. package/dist/types/schemas/shared.js +5 -1
  270. package/dist/types/schemas/shared.js.map +1 -1
  271. package/package.json +2 -2
  272. package/dist/reporting/courses/index.d.ts +0 -4
  273. package/dist/reporting/courses/index.js +0 -2
  274. package/dist/reporting/courses/model.d.ts +0 -24
  275. package/dist/reporting/courses/ports.d.ts +0 -13
  276. package/dist/reporting/courses/service.d.ts +0 -19
  277. package/dist/reporting/courses/service.js +0 -20
  278. package/dist/reporting/courses/service.js.map +0 -1
  279. package/dist/reporting/dashboard/index.d.ts +0 -4
  280. package/dist/reporting/dashboard/index.js +0 -2
  281. package/dist/reporting/dashboard/model.d.ts +0 -14
  282. package/dist/reporting/dashboard/ports.d.ts +0 -13
  283. package/dist/reporting/dashboard/service.d.ts +0 -19
  284. package/dist/reporting/dashboard/service.js +0 -20
  285. package/dist/reporting/dashboard/service.js.map +0 -1
  286. package/dist/reporting/learn/index.d.ts +0 -5
  287. package/dist/reporting/learn/index.js +0 -2
  288. package/dist/reporting/learn/model.d.ts +0 -23
  289. package/dist/reporting/learn/ports.d.ts +0 -43
  290. package/dist/reporting/learn/service.d.ts +0 -49
  291. package/dist/reporting/learn/service.js +0 -116
  292. package/dist/reporting/learn/service.js.map +0 -1
  293. package/dist/reporting/students/index.d.ts +0 -4
  294. package/dist/reporting/students/index.js +0 -2
  295. package/dist/reporting/students/model.d.ts +0 -51
  296. package/dist/reporting/students/model.js +0 -1
  297. package/dist/reporting/students/ports.d.ts +0 -15
  298. package/dist/reporting/students/ports.js +0 -1
  299. package/dist/reporting/students/service.d.ts +0 -20
  300. package/dist/reporting/students/service.js +0 -23
  301. package/dist/reporting/students/service.js.map +0 -1
  302. /package/dist/reporting/{courses → admin}/model.js +0 -0
  303. /package/dist/reporting/{courses → admin}/ports.js +0 -0
  304. /package/dist/reporting/{dashboard → member}/model.js +0 -0
  305. /package/dist/reporting/{dashboard → member}/ports.js +0 -0
  306. /package/dist/{reporting/learn/model.js → shared/actor.js} +0 -0
  307. /package/dist/{reporting/learn/ports.js → types/notifications.js} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","names":[],"sources":["../../src/organizations/service.ts"],"sourcesContent":["// organizations context — service implementation (inbound port).\nimport type {\n MemberRecord,\n MembersRepository,\n MemberWriteContext,\n OrgAdmin,\n OrganizationService,\n OrganizationsRepository,\n OrganizationsUnitOfWork,\n} from './ports.js';\nimport type { Invite, Organization, OrgUser } from './model.js';\nimport { type Role, STUDENT_ROLE } from './roles.js';\nimport {\n InviteError,\n type Member,\n type MembersQuery,\n OrganizationRuleError,\n type Page,\n} from './members.js';\nimport type {\n AcceptInviteInput,\n AddOrgUserInput,\n CreateInviteInput,\n CreateOrganizationInput,\n ResendStudentInviteInput,\n UpdateOrganizationInput,\n UpdateOrgUserInput,\n UpdateStudentInput,\n} from './types.js';\nimport { organizationEvents, type OrganizationEvent } from './events.js';\nimport type { Logger, NewDomainEvent } from '../shared/ports.js';\nimport { noopLogger } from '../shared/logger.js';\nimport type { Mailer } from '../shared/mailer.js';\nimport { generateInviteToken, hashInviteToken } from '../shared/invite-token.js';\nimport { NotFoundError } from '../shared/errors.js';\nimport type { IdentityService } from '../identity/index.js';\nimport { genId } from '../shared/id.js';\n\nconst INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\ntype NewOrganizationEvent = NewDomainEvent<OrganizationEvent>;\n\nexport interface InviteUrls {\n studentPortalUrl: string;\n adminAppUrl: string;\n}\n\nfunction toMember(r: MemberRecord): Member {\n return {\n id: r.id,\n firstName: r.firstName,\n lastName: r.lastName,\n email: r.email,\n image: r.image,\n role: r.role,\n status: r.status,\n joinedAt: r.joinedAt,\n invitedAt: r.invitedAt,\n };\n}\n\nexport type OrganizationServiceParams = {\n repo: OrganizationsRepository;\n membersRepo: MembersRepository;\n people: IdentityService;\n uow: OrganizationsUnitOfWork;\n orgAdmin: () => OrgAdmin;\n logger?: Logger;\n mailer?: Pick<Mailer, 'send'>;\n inviteUrls?: InviteUrls;\n};\n\nexport class OrganizationServiceImpl implements OrganizationService {\n private readonly repo: OrganizationsRepository;\n private readonly membersRepo: MembersRepository;\n private readonly people: IdentityService;\n private readonly uow: OrganizationsUnitOfWork;\n private readonly orgAdmin: () => OrgAdmin;\n private readonly logger: Logger;\n private readonly mailer?: Pick<Mailer, 'send'>;\n private readonly inviteUrls?: InviteUrls;\n\n constructor(params: OrganizationServiceParams) {\n this.repo = params.repo;\n this.membersRepo = params.membersRepo;\n this.people = params.people;\n this.uow = params.uow;\n this.orgAdmin = params.orgAdmin;\n this.logger = params.logger ?? noopLogger;\n this.mailer = params.mailer;\n this.inviteUrls = params.inviteUrls;\n }\n\n async createOrganization(input: CreateOrganizationInput): Promise<Organization> {\n const created = await this.uow.run(async ({ organizations, outbox }) => {\n const id = genId('organization');\n\n const created = await organizations.create({\n id,\n // The auth provider adopts this id for its own org row, so the org is\n // its own external id — a lookup by either id lands on the same row.\n externalId: input.externalId ?? id,\n name: input.name ?? `My Org`,\n slug: input.slug ?? '',\n ownerId: input.ownerId,\n });\n await outbox.append([\n organizationEvents.organizationCreated.make({\n orgId: created.id,\n data: created,\n }),\n ]);\n return created;\n });\n this.logger.info('organization created', { orgId: created.id, slug: created.slug });\n return created;\n }\n\n async updateOrganization(id: string, input: UpdateOrganizationInput): Promise<Organization> {\n const updatedOrg = await this.uow.run(async ({ organizations, outbox }) => {\n const updated = await organizations.update(id, {\n name: input.name,\n slug: input.slug,\n });\n if (!updated) {\n throw new NotFoundError('Organization', id);\n }\n await outbox.append([\n organizationEvents.organizationUpdated.make({ orgId: id, data: updated }),\n ]);\n return updated;\n });\n\n this.logger.info('organization updated', { updatedOrg });\n return updatedOrg!;\n }\n\n async deleteOrganization(id: string): Promise<Organization> {\n const deleted = await this.uow.run(async ({ organizations, outbox }) => {\n const deleted = await organizations.delete(id);\n\n await outbox.append([\n organizationEvents.organizationDeleted.make({\n orgId: deleted.id,\n data: deleted,\n }),\n ]);\n return deleted;\n });\n this.logger.info('organization deleted', { orgId: deleted.id });\n return deleted;\n }\n\n async addOrgUser({ orgId, userId, role }: AddOrgUserInput): Promise<OrgUser> {\n const orgUser = await this.uow.run(async ({ organizations, outbox }) => {\n const created = await organizations.createOrgUser({ orgId, role, userId, status: 'active' });\n await outbox.append([organizationEvents.orgUserLinked.make({ orgId, data: created })]);\n return created;\n });\n this.logger.info('user added to organization', { orgId, userId, role });\n return orgUser;\n }\n\n async removeOrgUser(orgId: string, userId: string): Promise<OrgUser> {\n const deleted = await this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUser(orgId, userId);\n if (!orgUser) {\n throw new NotFoundError('OrgUser', userId);\n }\n const deletedUser = await organizations.deleteOrgUser(orgId, orgUser.id);\n if (!deletedUser) {\n throw new NotFoundError('OrgUser', orgUser.id);\n }\n await outbox.append([\n organizationEvents.orgUserDeleted.make({\n orgId,\n data: deletedUser,\n }),\n ]);\n return deletedUser;\n });\n this.logger.info('org user removed', { orgId, userId, orgUserId: deleted.id });\n return deleted;\n }\n\n async updateOrgUser(orgId: string, userId: string, patch: UpdateOrgUserInput): Promise<OrgUser> {\n const orgUser = await this.repo.findOrgUser(orgId, userId);\n if (!orgUser) {\n throw new NotFoundError('OrgUser', userId);\n }\n const updated = await this.repo.updateOrgUser(orgId, orgUser.id, patch);\n if (!updated) {\n throw new NotFoundError('OrgUser', orgUser.id);\n }\n this.logger.info('org user updated', { orgId, userId, orgUserId: updated.id, patch });\n return updated;\n }\n\n async createInvite(input: CreateInviteInput): Promise<Invite> {\n const { orgId, email, role, inviterUserId, firstName, lastName } = input;\n const sendEmail = input.sendEmail ?? true;\n const { token, tokenHash } = generateInviteToken();\n const expiresAt = new Date(Date.now() + INVITE_TTL_MS);\n\n // A student belongs to the org from the moment an admin adds them, not from\n // the moment they click a link — so the person exists before the invite\n // does. Staff are excluded: their org_users row is mirrored from the auth\n // provider's member record, which does not exist until they join.\n const person =\n role === STUDENT_ROLE\n ? ((await this.people.getUserByEmail(email)) ??\n (await this.people.createUser({\n email,\n ...(firstName !== undefined && { firstName }),\n ...(lastName !== undefined && { lastName }),\n })))\n : null;\n\n const invite = await this.uow.run(async ({ organizations, outbox }) => {\n const events: NewOrganizationEvent[] = [];\n if (person) {\n const orgUser = await organizations.createOrgUser({\n orgId,\n userId: person.id,\n role: STUDENT_ROLE,\n status: 'invited',\n });\n events.push(organizationEvents.studentCreated.make({ orgId, data: orgUser }));\n }\n const row = await organizations.upsertPendingInvite(orgId, {\n email,\n role,\n invitedBy: inviterUserId,\n tokenHash,\n expiresAt,\n });\n events.push(organizationEvents.inviteCreated.make({ orgId, data: row }));\n await outbox.append(events);\n return row;\n });\n\n if (sendEmail) {\n // TODO make this workflow durable\n try {\n await this.sendInviteEmail(invite, token);\n } catch (err) {\n // A failed email must not abort invite creation: the token is already\n // minted and recorded, so the admin can fix transport and resend.\n this.logger.error('failed to send invite email', {\n email: invite.email,\n role: invite.role,\n err: err instanceof Error ? err : new Error(String(err)),\n });\n }\n }\n\n this.logger.info('invite created', { orgId, inviteId: invite.id, role, sendEmail });\n return invite;\n }\n\n async updateStudent(input: UpdateStudentInput): Promise<OrgUser> {\n const { orgId, orgUserId, firstName, lastName, email } = input;\n const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);\n if (!orgUser || orgUser.role !== STUDENT_ROLE) {\n throw new NotFoundError('Student', orgUserId);\n }\n const person = await this.people.getUserById(orgUser.userId);\n if (!person) {\n throw new NotFoundError('User', orgUser.userId);\n }\n\n const previousEmail = person.email;\n await this.people.updateUser(person.id, {\n ...(firstName !== undefined && { firstName }),\n ...(lastName !== undefined && { lastName }),\n ...(email !== undefined && { email }),\n });\n\n // The pending invite carries its own copy of the address and a token minted\n // against it. Once the address is corrected that invite points at the wrong\n // person, so it is cancelled rather than left live — the admin re-sends,\n // which mints a fresh one for the new address. A student who has already\n // accepted has no pending invite to worry about.\n const emailChanged = email !== undefined && email.toLowerCase() !== previousEmail.toLowerCase();\n if (emailChanged && orgUser.status !== 'active') {\n await this.repo.cancelPendingInvite(orgId, previousEmail);\n }\n\n this.logger.info('student updated', { orgId, orgUserId, emailChanged });\n return orgUser;\n }\n\n async resendStudentInvite(input: ResendStudentInviteInput): Promise<void> {\n const { orgId, orgUserId, inviterUserId } = input;\n const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);\n if (!orgUser || orgUser.role !== STUDENT_ROLE) {\n throw new NotFoundError('Student', orgUserId);\n }\n if (orgUser.status === 'active') {\n throw new OrganizationRuleError('That student has already accepted their invitation');\n }\n const person = await this.people.getUserById(orgUser.userId);\n if (!person) {\n throw new NotFoundError('User', orgUser.userId);\n }\n const pending = await this.repo.findPendingInvite(orgId, person.email);\n if (!pending) {\n throw new NotFoundError('Invite', person.email);\n }\n const { token, tokenHash } = generateInviteToken();\n const expiresAt = new Date(Date.now() + INVITE_TTL_MS);\n const invite = await this.repo.setInviteToken(orgId, pending.id, tokenHash, expiresAt);\n if (!invite) {\n throw new NotFoundError('Invite', pending.id);\n }\n await this.sendInviteEmail(invite, token);\n this.logger.info('student invite resent', {\n orgId,\n orgUserId,\n inviteId: invite.id,\n inviterUserId,\n });\n }\n\n async peekInvite(token: string): Promise<Invite | null> {\n const invite = await this.repo.findInviteByTokenHash(hashInviteToken(token));\n if (!invite || invite.status !== 'pending') {\n return null;\n }\n if (invite.expiresAt && invite.expiresAt < new Date()) {\n return null;\n }\n return invite;\n }\n\n async acceptInvite(input: AcceptInviteInput): Promise<OrgUser> {\n const invite = await this.peekInvite(input.token);\n if (!invite) {\n this.logger.warn('invite accept refused: token invalid or expired');\n throw new InviteError('invalid invite');\n }\n if (invite.email.toLowerCase() !== input.email.toLowerCase()) {\n this.logger.warn('invite accept refused: email mismatch', {\n orgId: invite.orgId,\n inviteId: invite.id,\n });\n throw new InviteError('emails must match');\n }\n const org = await this.repo.findById(invite.orgId);\n if (!org) {\n throw new InviteError('Org not found');\n }\n\n const orgUser =\n invite.role === STUDENT_ROLE\n ? await this.acceptStudentInvite(invite, input.userId)\n : await this.acceptStaffInvite(invite, org, input.userId);\n this.logger.info('invite accepted', { orgId: invite.orgId, userId: input.userId });\n return orgUser;\n }\n\n private async acceptStudentInvite(invite: Invite, userId: string): Promise<OrgUser> {\n return this.uow.run(async ({ organizations, outbox }) => {\n const existing = await organizations.findOrgUser(invite.orgId, userId);\n if (!existing) {\n throw new NotFoundError('OrgUser', userId);\n }\n const activated = await organizations.updateOrgUser(invite.orgId, existing.id, {\n status: 'active',\n });\n if (!activated) {\n throw new NotFoundError('OrgUser', existing.id);\n }\n const acceptedInvite = await organizations.setInviteStatus(\n invite.orgId,\n invite.id,\n 'accepted',\n );\n if (!acceptedInvite) {\n throw new NotFoundError('Invite', invite.id);\n }\n await outbox.append([\n organizationEvents.inviteAccepted.make({ orgId: invite.orgId, data: acceptedInvite }),\n organizationEvents.studentLinked.make({ orgId: invite.orgId, data: activated }),\n ]);\n return activated;\n });\n }\n\n private async acceptStaffInvite(\n invite: Invite,\n org: Organization,\n userId: string,\n ): Promise<OrgUser> {\n const person = await this.people.getUserById(userId);\n if (!person?.externalId) {\n throw new NotFoundError('User', userId);\n }\n await this.orgAdmin().grantMembership(org.externalId ?? org.id, person.externalId, invite.role);\n return this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUser(invite.orgId, userId);\n if (!orgUser) {\n throw new NotFoundError('OrgUser', userId);\n }\n const acceptedInvite = await organizations.setInviteStatus(\n invite.orgId,\n invite.id,\n 'accepted',\n );\n if (!acceptedInvite) {\n throw new NotFoundError('Invite', invite.id);\n }\n await outbox.append([\n organizationEvents.inviteAccepted.make({ orgId: invite.orgId, data: acceptedInvite }),\n ]);\n return orgUser;\n });\n }\n\n // By identity USER id — `(org_id, user_id)` is unique. Every caller (scope\n // resolution, the MCP principal, the learn routes) asks \"what is this person\n // in this org\", never \"what is this org_users row\".\n async getOrgUser(orgId: string, userId: string): Promise<OrgUser | null> {\n return this.repo.findOrgUser(orgId, userId);\n }\n\n async deleteOrgUser(orgId: string, id: string): Promise<OrgUser> {\n const deleted = await this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUserById(orgId, id);\n if (!orgUser) {\n throw new NotFoundError('OrgUser', id);\n }\n const deletedUser = await organizations.deleteOrgUser(orgId, id);\n if (!deletedUser) {\n throw new NotFoundError('OrgUser', id);\n }\n const events: NewOrganizationEvent[] = [];\n if (deletedUser.role === STUDENT_ROLE) {\n events.push(organizationEvents.studentDeleted.make({ orgId, data: orgUser }));\n }\n\n await outbox.append(events);\n return deletedUser;\n });\n this.logger.info('org user deleted', { orgId, orgUserId: id });\n return deleted;\n }\n\n async getOrgUsersForUser(userId: string): Promise<OrgUser[]> {\n return this.repo.findOrgUsersByUser(userId);\n }\n\n async getById(id: string): Promise<Organization | null> {\n return this.repo.findById(id);\n }\n\n private async sendInviteEmail(invite: Invite, token: string): Promise<void> {\n if (!this.mailer || !this.inviteUrls) {\n throw new Error('invite delivery is not configured (mailer / invite urls missing)');\n }\n const { email, role } = invite;\n const isStudent = role === STUDENT_ROLE;\n const base = isStudent\n ? `${this.inviteUrls.studentPortalUrl}/welcome`\n : `${this.inviteUrls.adminAppUrl}/invite`;\n // The portal resolves the token to the invited email, so the link carries\n // only the token. The admin app still reads it from the query.\n const query = isStudent\n ? new URLSearchParams({ token })\n : new URLSearchParams({ token, email });\n const inviteUrl = `${base}?${query.toString()}`;\n if (role === STUDENT_ROLE) {\n await this.mailer.send(email, 'studentInvite', { inviteUrl, studentName: email });\n } else {\n await this.mailer.send(email, 'memberInvite', {\n inviteUrl,\n inviterName: 'Your team',\n role,\n });\n }\n }\n\n async getByExternalId(externalId: string): Promise<Organization | null> {\n return this.repo.findByExternalId(externalId);\n }\n\n async getBySlug(slug: string): Promise<Organization | null> {\n return this.repo.findBySlug(slug);\n }\n\n listMembers(orgId: string, query: MembersQuery): Promise<Page<Member>> {\n return this.membersRepo.list(orgId, query);\n }\n\n async updateMemberRole(ctx: MemberWriteContext, id: string, role: Role): Promise<Member | null> {\n const member = await this.membersRepo.findById(ctx.orgId, id);\n if (!member) {\n return null;\n }\n if (member.role === 'owner') {\n this.logger.warn('role change rejected: owner role immutable', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('The owner role cannot be reassigned');\n }\n if (member.kind !== 'member' || !member.userExternalId) {\n this.logger.warn('role change rejected: not an active member', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('Only active members can have their role changed');\n }\n await this.orgAdmin().updateRole(ctx, member.userExternalId, role);\n const updated = await this.membersRepo.findById(ctx.orgId, id);\n this.logger.info('member role updated', { orgId: ctx.orgId, memberId: id, role });\n return updated ? toMember(updated) : null;\n }\n\n async removeMember(ctx: MemberWriteContext, id: string): Promise<boolean> {\n const member = await this.membersRepo.findById(ctx.orgId, id);\n if (!member) {\n return false;\n }\n if (member.role === 'owner') {\n this.logger.warn('member removal rejected: owner cannot be removed', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('The owner cannot be removed');\n }\n if (member.kind === 'member' && member.userExternalId) {\n await this.orgAdmin().removeMember(ctx, member.userExternalId);\n } else if (member.kind === 'invite' && member.inviteId) {\n const inviteId = member.inviteId;\n await this.uow.run(async ({ organizations, outbox }) => {\n const canceled = await organizations.setInviteStatus(ctx.orgId, inviteId, 'canceled');\n if (!canceled) {\n throw new NotFoundError('Invite', inviteId);\n }\n await outbox.append([\n organizationEvents.inviteCanceled.make({ orgId: ctx.orgId, data: canceled }),\n ]);\n });\n }\n this.logger.info('member removed', { orgId: ctx.orgId, memberId: id });\n return true;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"service.js","names":[],"sources":["../../src/organizations/service.ts"],"sourcesContent":["// organizations context — service implementation (inbound port).\nimport { AsyncLocalStorage } from 'node:async_hooks';\nimport type {\n MemberRecord,\n MembersRepository,\n MemberWriteContext,\n OrgAdmin,\n OrganizationService,\n OrganizationsRepository,\n OrganizationsUnitOfWork,\n} from './ports.js';\nimport type { Invite, Organization, OrgUser } from './model.js';\nimport { capability, type Role, STAFF_ROLES, STUDENT_ROLE } from './roles.js';\nimport {\n InviteError,\n type Member,\n type MemberRoleChange,\n type MembersQuery,\n OrganizationRuleError,\n type Page,\n} from './members.js';\nimport type {\n AcceptInviteInput,\n AddOrgUserInput,\n CreateInviteInput,\n CreateOrganizationInput,\n ResendStudentInviteInput,\n UpdateOrganizationInput,\n UpdateOrgUserInput,\n} from './types.js';\nimport { organizationEvents, type OrganizationEvent } from './events.js';\nimport type { Logger, NewDomainEvent } from '../shared/ports.js';\nimport { noopLogger } from '../shared/logger.js';\nimport type { Mailer } from '../shared/mailer.js';\nimport { generateInviteToken, hashInviteToken } from '../shared/invite-token.js';\nimport { ForbiddenError, NotFoundError } from '../shared/errors.js';\nimport type { Actor } from '../shared/actor.js';\nimport type { IdentityService } from '../identity/index.js';\nimport { genId } from '../shared/id.js';\n\nconst INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\ntype NewOrganizationEvent = NewDomainEvent<OrganizationEvent>;\n\ninterface MemberWrite {\n orgUserId: string;\n actorOrgUserId: string;\n demote: boolean;\n}\n\nexport interface InviteUrls {\n studentPortalUrl: string;\n adminAppUrl: string;\n}\n\nfunction toMember(r: MemberRecord): Member {\n return {\n id: r.id,\n firstName: r.firstName,\n lastName: r.lastName,\n email: r.email,\n image: r.image,\n role: r.role,\n status: r.status,\n joinedAt: r.joinedAt,\n invitedAt: r.invitedAt,\n };\n}\n\nexport type OrganizationServiceParams = {\n repo: OrganizationsRepository;\n membersRepo: MembersRepository;\n people: IdentityService;\n uow: OrganizationsUnitOfWork;\n orgAdmin: () => OrgAdmin;\n logger?: Logger;\n mailer?: Pick<Mailer, 'send'>;\n inviteUrls?: InviteUrls;\n};\n\nexport class OrganizationServiceImpl implements OrganizationService {\n private readonly repo: OrganizationsRepository;\n private readonly membersRepo: MembersRepository;\n private readonly people: IdentityService;\n private readonly uow: OrganizationsUnitOfWork;\n private readonly orgAdmin: () => OrgAdmin;\n private readonly logger: Logger;\n private readonly mailer?: Pick<Mailer, 'send'>;\n private readonly inviteUrls?: InviteUrls;\n private readonly memberWrites = new AsyncLocalStorage<MemberWrite>();\n\n constructor(params: OrganizationServiceParams) {\n this.repo = params.repo;\n this.membersRepo = params.membersRepo;\n this.people = params.people;\n this.uow = params.uow;\n this.orgAdmin = params.orgAdmin;\n this.logger = params.logger ?? noopLogger;\n this.mailer = params.mailer;\n this.inviteUrls = params.inviteUrls;\n }\n\n async createOrganization(input: CreateOrganizationInput): Promise<Organization> {\n const created = await this.uow.run(async ({ organizations, outbox }) => {\n const id = genId('organization');\n\n const created = await organizations.create({\n id,\n // The auth provider adopts this id for its own org row, so the org is\n // its own external id — a lookup by either id lands on the same row.\n externalId: input.externalId ?? id,\n name: input.name ?? `My Org`,\n slug: input.slug ?? '',\n ownerId: input.ownerId,\n });\n await outbox.append([\n organizationEvents.organizationCreated.make({\n orgId: created.id,\n data: created,\n }),\n ]);\n return created;\n });\n this.logger.info('organization created', { orgId: created.id, slug: created.slug });\n return created;\n }\n\n async updateOrganization(id: string, input: UpdateOrganizationInput): Promise<Organization> {\n const updatedOrg = await this.uow.run(async ({ organizations, outbox }) => {\n const updated = await organizations.update(id, {\n name: input.name,\n slug: input.slug,\n });\n if (!updated) {\n throw new NotFoundError('Organization', id);\n }\n await outbox.append([\n organizationEvents.organizationUpdated.make({ orgId: id, data: updated }),\n ]);\n return updated;\n });\n\n this.logger.info('organization updated', { updatedOrg });\n return updatedOrg!;\n }\n\n async deleteOrganization(id: string): Promise<Organization> {\n const deleted = await this.uow.run(async ({ organizations, outbox }) => {\n const deleted = await organizations.delete(id);\n\n await outbox.append([\n organizationEvents.organizationDeleted.make({\n orgId: deleted.id,\n data: deleted,\n }),\n ]);\n return deleted;\n });\n this.logger.info('organization deleted', { orgId: deleted.id });\n return deleted;\n }\n\n async addOrgUser({ orgId, userId, role }: AddOrgUserInput): Promise<OrgUser> {\n const existing = await this.repo.findOrgUser(orgId, userId);\n if (existing) {\n return this.updateOrgUser(orgId, userId, { role, status: 'active' });\n }\n const orgUser = await this.uow.run(async ({ organizations, outbox }) => {\n const created = await organizations.createOrgUser({ orgId, role, userId, status: 'active' });\n await outbox.append([organizationEvents.orgUserLinked.make({ orgId, data: created })]);\n return created;\n });\n this.logger.info('user added to organization', { orgId, userId, role });\n return orgUser;\n }\n\n async removeOrgUser(orgId: string, userId: string): Promise<OrgUser> {\n const deleted = await this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUser(orgId, userId);\n if (!orgUser) {\n throw new NotFoundError('Member', userId);\n }\n const write = this.memberWrite(orgUser.id);\n if (write?.demote) {\n const demoted = await organizations.updateOrgUser(orgId, orgUser.id, {\n role: STUDENT_ROLE,\n });\n if (!demoted) {\n throw new NotFoundError('Member', orgUser.id);\n }\n if (orgUser.role !== STUDENT_ROLE) {\n await outbox.append([\n organizationEvents.orgUserUpdated.make({\n orgId,\n data: demoted,\n metadata: { actorOrgUserId: write.actorOrgUserId },\n }),\n ]);\n }\n return demoted;\n }\n if (orgUser.role === STUDENT_ROLE) {\n return orgUser;\n }\n const deletedUser = await organizations.deleteOrgUser(orgId, orgUser.id);\n if (!deletedUser) {\n throw new NotFoundError('Member', orgUser.id);\n }\n await outbox.append([\n organizationEvents.orgUserDeleted.make({\n orgId,\n data: deletedUser,\n }),\n ]);\n return deletedUser;\n });\n this.logger.info('org user removed', { orgId, userId, orgUserId: deleted.id });\n return deleted;\n }\n\n async updateOrgUser(orgId: string, userId: string, patch: UpdateOrgUserInput): Promise<OrgUser> {\n const updated = await this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUser(orgId, userId);\n if (!orgUser) {\n throw new NotFoundError('Member', userId);\n }\n const next = await organizations.updateOrgUser(orgId, orgUser.id, patch);\n if (!next) {\n throw new NotFoundError('Member', orgUser.id);\n }\n if (patch.role !== undefined && patch.role !== orgUser.role) {\n const actorOrgUserId = this.memberWrite(orgUser.id)?.actorOrgUserId;\n await outbox.append([\n organizationEvents.orgUserUpdated.make({\n orgId,\n data: next,\n ...(actorOrgUserId !== undefined && { metadata: { actorOrgUserId } }),\n }),\n ]);\n }\n return next;\n });\n this.logger.info('org user updated', { orgId, userId, orgUserId: updated.id, patch });\n return updated;\n }\n\n async createInvite(actor: Actor, input: CreateInviteInput): Promise<Invite> {\n const { orgId, email, role, inviterUserId, firstName, lastName } = input;\n this.assertManagesUsers(actor, orgId);\n this.assertNotOwner(role, orgId);\n const sendEmail = input.sendEmail ?? true;\n const { token, tokenHash } = generateInviteToken();\n const expiresAt = new Date(Date.now() + INVITE_TTL_MS);\n\n // A student belongs to the org from the moment an admin adds them, not from\n // the moment they click a link — so the person exists before the invite\n // does. Staff are excluded: their org_users row is mirrored from the auth\n // provider's member record, which does not exist until they join.\n const person =\n role === STUDENT_ROLE\n ? ((await this.people.getUserByEmail(email)) ??\n (await this.people.createUser({\n email,\n ...(firstName !== undefined && { firstName }),\n ...(lastName !== undefined && { lastName }),\n })))\n : null;\n\n const invite = await this.uow.run(async ({ organizations, outbox }) => {\n const events: NewOrganizationEvent[] = [];\n if (person) {\n const orgUser = await organizations.createOrgUser({\n orgId,\n userId: person.id,\n role: STUDENT_ROLE,\n status: 'invited',\n });\n events.push(organizationEvents.studentCreated.make({ orgId, data: orgUser }));\n }\n const row = await organizations.upsertPendingInvite(orgId, {\n email,\n role,\n invitedBy: inviterUserId,\n tokenHash,\n expiresAt,\n });\n events.push(organizationEvents.inviteCreated.make({ orgId, data: row }));\n await outbox.append(events);\n return row;\n });\n\n if (sendEmail) {\n // TODO make this workflow durable\n try {\n await this.sendInviteEmail(invite, token);\n } catch (err) {\n // A failed email must not abort invite creation: the token is already\n // minted and recorded, so the admin can fix transport and resend.\n this.logger.error('failed to send invite email', {\n orgId,\n inviteId: invite.id,\n role: invite.role,\n err: err instanceof Error ? err : new Error(String(err)),\n });\n }\n }\n\n this.logger.info('invite created', { orgId, inviteId: invite.id, role, sendEmail });\n return invite;\n }\n\n async resendStudentInvite(actor: Actor, input: ResendStudentInviteInput): Promise<void> {\n const { orgId, orgUserId, inviterUserId } = input;\n this.assertManagesUsers(actor, orgId);\n const orgUser = await this.repo.findOrgUserById(orgId, orgUserId);\n if (!orgUser || orgUser.role !== STUDENT_ROLE) {\n throw new NotFoundError('Student', orgUserId);\n }\n if (orgUser.status === 'active') {\n throw new OrganizationRuleError('That student has already accepted their invitation');\n }\n const person = await this.people.getUserById(orgUser.userId);\n if (!person) {\n throw new NotFoundError('User', orgUser.userId);\n }\n const pending = await this.repo.findPendingInvite(orgId, person.email);\n if (!pending) {\n throw new NotFoundError('Invite', person.email);\n }\n const { token, tokenHash } = generateInviteToken();\n const expiresAt = new Date(Date.now() + INVITE_TTL_MS);\n const invite = await this.repo.setInviteToken(orgId, pending.id, tokenHash, expiresAt);\n if (!invite) {\n throw new NotFoundError('Invite', pending.id);\n }\n await this.sendInviteEmail(invite, token);\n this.logger.info('student invite resent', {\n orgId,\n orgUserId,\n inviteId: invite.id,\n inviterUserId,\n });\n }\n\n async peekInvite(token: string): Promise<Invite | null> {\n const invite = await this.repo.findInviteByTokenHash(hashInviteToken(token));\n if (!invite || invite.status !== 'pending') {\n return null;\n }\n if (invite.expiresAt && invite.expiresAt < new Date()) {\n return null;\n }\n return invite;\n }\n\n async acceptInvite(input: AcceptInviteInput): Promise<OrgUser> {\n const invite = await this.peekInvite(input.token);\n if (!invite) {\n this.logger.warn('invite accept refused: token invalid or expired');\n throw new InviteError('invalid invite');\n }\n if (invite.email.toLowerCase() !== input.email.toLowerCase()) {\n this.logger.warn('invite accept refused: email mismatch', {\n orgId: invite.orgId,\n inviteId: invite.id,\n });\n throw new InviteError('emails must match');\n }\n const org = await this.repo.findById(invite.orgId);\n if (!org) {\n throw new InviteError('Org not found');\n }\n\n const orgUser =\n invite.role === STUDENT_ROLE\n ? await this.acceptStudentInvite(invite, input.userId)\n : await this.acceptStaffInvite(invite, org, input.userId);\n this.logger.info('invite accepted', { orgId: invite.orgId, userId: input.userId });\n return orgUser;\n }\n\n private async acceptStudentInvite(invite: Invite, userId: string): Promise<OrgUser> {\n return this.uow.run(async ({ organizations, outbox }) => {\n const existing = await organizations.findOrgUser(invite.orgId, userId);\n if (!existing) {\n throw new NotFoundError('Member', userId);\n }\n const activated = await organizations.updateOrgUser(invite.orgId, existing.id, {\n status: 'active',\n });\n if (!activated) {\n throw new NotFoundError('Member', existing.id);\n }\n const acceptedInvite = await organizations.setInviteStatus(\n invite.orgId,\n invite.id,\n 'accepted',\n );\n if (!acceptedInvite) {\n throw new NotFoundError('Invite', invite.id);\n }\n await outbox.append([\n organizationEvents.inviteAccepted.make({ orgId: invite.orgId, data: acceptedInvite }),\n organizationEvents.studentLinked.make({ orgId: invite.orgId, data: activated }),\n ]);\n return activated;\n });\n }\n\n private async acceptStaffInvite(\n invite: Invite,\n org: Organization,\n userId: string,\n ): Promise<OrgUser> {\n const person = await this.people.getUserById(userId);\n if (!person?.externalId) {\n throw new NotFoundError('User', userId);\n }\n await this.orgAdmin().grantMembership(org.externalId ?? org.id, person.externalId, invite.role);\n return this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUser(invite.orgId, userId);\n if (!orgUser) {\n throw new NotFoundError('Member', userId);\n }\n const acceptedInvite = await organizations.setInviteStatus(\n invite.orgId,\n invite.id,\n 'accepted',\n );\n if (!acceptedInvite) {\n throw new NotFoundError('Invite', invite.id);\n }\n await outbox.append([\n organizationEvents.inviteAccepted.make({ orgId: invite.orgId, data: acceptedInvite }),\n ]);\n return orgUser;\n });\n }\n\n // By identity USER id — `(org_id, user_id)` is unique. Every caller (scope\n // resolution, the MCP principal, the member routes) asks \"what is this person\n // in this org\", never \"what is this org_users row\".\n getOrgUserById(orgId: string, id: string): Promise<OrgUser | null> {\n return this.repo.findOrgUserById(orgId, id);\n }\n\n async getOrgUsersByIds(orgId: string, ids: string[]): Promise<OrgUser[]> {\n const unique = [...new Set(ids)];\n return unique.length > 0 ? this.repo.findOrgUsersByIds(orgId, unique) : [];\n }\n\n async getOrgUser(orgId: string, userId: string): Promise<OrgUser | null> {\n return this.repo.findOrgUser(orgId, userId);\n }\n\n async deleteOrgUser(orgId: string, actor: Actor, id: string): Promise<OrgUser> {\n this.assertManagesUsers(actor, orgId);\n const deleted = await this.uow.run(async ({ organizations, outbox }) => {\n const orgUser = await organizations.findOrgUserById(orgId, id);\n if (!orgUser || orgUser.role !== STUDENT_ROLE) {\n throw new NotFoundError('Student', id);\n }\n const deletedUser = await organizations.deleteOrgUser(orgId, id);\n if (!deletedUser) {\n throw new NotFoundError('Student', id);\n }\n await outbox.append([organizationEvents.studentDeleted.make({ orgId, data: orgUser })]);\n return deletedUser;\n });\n this.logger.info('org user deleted', { orgId, orgUserId: id });\n return deleted;\n }\n\n async getOrgUsersForUser(userId: string): Promise<OrgUser[]> {\n return this.repo.findOrgUsersByUser(userId);\n }\n\n async getById(id: string): Promise<Organization | null> {\n return this.repo.findById(id);\n }\n\n orgIds(): Promise<string[]> {\n return this.repo.listIds();\n }\n\n private async sendInviteEmail(invite: Invite, token: string): Promise<void> {\n if (!this.mailer || !this.inviteUrls) {\n throw new Error('invite delivery is not configured (mailer / invite urls missing)');\n }\n const { email, role } = invite;\n const isStudent = role === STUDENT_ROLE;\n const base = isStudent\n ? `${this.inviteUrls.studentPortalUrl}/welcome`\n : `${this.inviteUrls.adminAppUrl}/invite`;\n // The portal resolves the token to the invited email, so the link carries\n // only the token. The admin app still reads it from the query.\n const query = isStudent\n ? new URLSearchParams({ token })\n : new URLSearchParams({ token, email });\n const inviteUrl = `${base}?${query.toString()}`;\n if (role === STUDENT_ROLE) {\n await this.mailer.send(email, 'studentInvite', { inviteUrl, studentName: email });\n } else {\n await this.mailer.send(email, 'memberInvite', {\n inviteUrl,\n inviterName: 'Your team',\n role,\n });\n }\n }\n\n async getByExternalId(externalId: string): Promise<Organization | null> {\n return this.repo.findByExternalId(externalId);\n }\n\n async getBySlug(slug: string): Promise<Organization | null> {\n return this.repo.findBySlug(slug);\n }\n\n staffOrgUserIds(orgId: string): Promise<string[]> {\n return this.repo.findOrgUserIdsByRole(orgId, [...STAFF_ROLES]);\n }\n\n listMembers(orgId: string, query: MembersQuery): Promise<Page<Member>> {\n return this.membersRepo.list(orgId, query);\n }\n\n async updateMemberRole(\n ctx: MemberWriteContext,\n actor: Actor,\n id: string,\n role: Role,\n ): Promise<MemberRoleChange | null> {\n this.assertManagesUsers(actor, ctx.orgId);\n this.assertNotOwner(role, ctx.orgId);\n const member = await this.membersRepo.findById(ctx.orgId, id);\n if (!member) {\n return this.promoteStudent(ctx, actor, id, role);\n }\n if (member.role === 'owner') {\n this.logger.warn('role change rejected: owner role immutable', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('The owner role cannot be reassigned');\n }\n if (member.kind !== 'member' || !member.userExternalId) {\n this.logger.warn('role change rejected: not an active member', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('Only active members can have their role changed');\n }\n if (role === STUDENT_ROLE) {\n return this.demoteModerator(ctx, actor, member, member.userExternalId);\n }\n const userExternalId = member.userExternalId;\n await this.asActor(actor, id, false, () =>\n this.orgAdmin().updateRole(ctx, userExternalId, role),\n );\n const updated = await this.membersRepo.findById(ctx.orgId, id);\n this.logger.info('member role updated', { orgId: ctx.orgId, memberId: id, role });\n return updated ? toMember(updated) : null;\n }\n\n private async demoteModerator(\n ctx: MemberWriteContext,\n actor: Actor,\n member: MemberRecord,\n userExternalId: string,\n ): Promise<MemberRoleChange | null> {\n if (member.role !== 'moderator') {\n this.logger.warn('role change rejected: only a moderator becomes a student', {\n orgId: ctx.orgId,\n memberId: member.id,\n });\n throw new OrganizationRuleError('Only a moderator can be made a student');\n }\n await this.asActor(actor, member.id, true, () =>\n this.orgAdmin().removeMember(ctx, userExternalId),\n );\n const orgUser = await this.repo.findOrgUserById(ctx.orgId, member.id);\n if (!orgUser) {\n return null;\n }\n this.logger.info('moderator made a student', { orgId: ctx.orgId, memberId: member.id });\n return { ...toMember(member), role: orgUser.role, status: orgUser.status };\n }\n\n private async promoteStudent(\n ctx: MemberWriteContext,\n actor: Actor,\n id: string,\n role: Role,\n ): Promise<MemberRoleChange | null> {\n const orgUser = await this.repo.findOrgUserById(ctx.orgId, id);\n if (!orgUser) {\n return null;\n }\n if (orgUser.role !== STUDENT_ROLE || role === STUDENT_ROLE) {\n this.logger.warn('role change rejected: not an active member', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('Only active members can have their role changed');\n }\n const person = await this.people.getUserById(orgUser.userId);\n if (!person?.externalId) {\n this.logger.warn('role change rejected: student has no account', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('That student has not joined yet');\n }\n const org = await this.repo.findById(ctx.orgId);\n if (!org) {\n return null;\n }\n const orgExternalId = org.externalId ?? org.id;\n const userExternalId = person.externalId;\n await this.asActor(actor, orgUser.id, false, async () => {\n await this.orgAdmin().grantMembership(orgExternalId, userExternalId, role);\n if (!(await this.membersRepo.findById(ctx.orgId, id))) {\n await this.orgAdmin().updateRole(ctx, userExternalId, role);\n }\n });\n const updated = await this.membersRepo.findById(ctx.orgId, id);\n if (!updated) {\n this.logger.warn('role change rejected: membership not mirrored', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('The role change did not take effect');\n }\n this.logger.info('student promoted', { orgId: ctx.orgId, memberId: id, role });\n return toMember(updated);\n }\n\n private asActor<T>(\n actor: Actor,\n orgUserId: string,\n demote: boolean,\n write: () => Promise<T>,\n ): Promise<T> {\n return this.memberWrites.run({ orgUserId, actorOrgUserId: actor.id, demote }, write);\n }\n\n private memberWrite(orgUserId: string): MemberWrite | undefined {\n const write = this.memberWrites.getStore();\n return write?.orgUserId === orgUserId ? write : undefined;\n }\n\n private assertManagesUsers(actor: Actor, orgId: string): void {\n if (capability(actor.role, 'manage_users') !== true) {\n this.logger.warn('member write rejected: actor cannot manage users', {\n orgId,\n actorOrgUserId: actor.id,\n });\n throw new ForbiddenError('Only an owner or admin can manage members');\n }\n }\n\n private assertNotOwner(role: Role, orgId: string): void {\n if (role === 'owner') {\n this.logger.warn('member write rejected: owner cannot be granted', { orgId });\n throw new OrganizationRuleError('Ownership cannot be granted');\n }\n }\n\n async removeMember(ctx: MemberWriteContext, actor: Actor, id: string): Promise<boolean> {\n this.assertManagesUsers(actor, ctx.orgId);\n const member = await this.membersRepo.findById(ctx.orgId, id);\n if (!member) {\n return false;\n }\n if (member.role === 'owner') {\n this.logger.warn('member removal rejected: owner cannot be removed', {\n orgId: ctx.orgId,\n memberId: id,\n });\n throw new OrganizationRuleError('The owner cannot be removed');\n }\n if (member.kind === 'member' && member.userExternalId) {\n await this.orgAdmin().removeMember(ctx, member.userExternalId);\n } else if (member.kind === 'invite' && member.inviteId) {\n const inviteId = member.inviteId;\n await this.uow.run(async ({ organizations, outbox }) => {\n const canceled = await organizations.setInviteStatus(ctx.orgId, inviteId, 'canceled');\n if (!canceled) {\n throw new NotFoundError('Invite', inviteId);\n }\n await outbox.append([\n organizationEvents.inviteCanceled.make({ orgId: ctx.orgId, data: canceled }),\n ]);\n });\n }\n this.logger.info('member removed', { orgId: ctx.orgId, memberId: id });\n return true;\n }\n}\n"],"mappings":"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"}
@@ -20,20 +20,11 @@ interface UnlinkOrgUserInput {
20
20
  orgExternalId: string;
21
21
  userExternalId: string;
22
22
  }
23
- interface UpdatePersonInput {
24
- firstName?: string;
25
- lastName?: string;
26
- email?: string;
27
- }
28
- interface UpdateStudentInput extends UpdatePersonInput {
29
- orgId: string;
30
- orgUserId: string;
31
- }
32
23
  interface ResendStudentInviteInput {
33
24
  orgId: string;
34
25
  orgUserId: string;
35
26
  inviterUserId: string;
36
27
  }
37
28
  //#endregion
38
- export { type AcceptInviteInput, type AddOrgUserInput, type CreateInviteInput, type CreateOrgUserInput, CreateOrganizationInput, type InviteId, type InviteRole, LinkOrgUserInput, type NewOrganizationInput, type OrgUserId, type OrgUserStatus, type OrganizationId, type ProvisionUserInput, ResendStudentInviteInput, UnlinkOrgUserInput, type UpdateOrgUserInput, UpdateOrganizationInput, UpdatePersonInput, UpdateStudentInput };
29
+ export { type AcceptInviteInput, type AddOrgUserInput, type CreateInviteInput, type CreateOrgUserInput, CreateOrganizationInput, type InviteId, type InviteRole, LinkOrgUserInput, type NewOrganizationInput, type OrgUserId, type OrgUserStatus, type OrganizationId, type ProvisionUserInput, ResendStudentInviteInput, UnlinkOrgUserInput, type UpdateOrgUserInput, UpdateOrganizationInput };
39
30
  //# sourceMappingURL=types.d.ts.map
@@ -7,7 +7,7 @@ declare const progressEvents: {
7
7
  id: string;
8
8
  orgId: string;
9
9
  orgUserId: string;
10
- targetType: "course" | "activity" | "module";
10
+ targetType: "activity" | "module" | "course";
11
11
  targetId: string;
12
12
  startedAt: Date;
13
13
  position: JsonValue;
@@ -18,7 +18,7 @@ declare const progressEvents: {
18
18
  id: string;
19
19
  orgId: string;
20
20
  orgUserId: string;
21
- targetType: "course" | "activity" | "module";
21
+ targetType: "activity" | "module" | "course";
22
22
  targetId: string;
23
23
  startedAt: unknown;
24
24
  position: unknown;
@@ -26,25 +26,25 @@ declare const progressEvents: {
26
26
  createdAt: unknown;
27
27
  updatedAt: unknown;
28
28
  }, import("zod").ZodObject<{
29
- orgId: import("zod").ZodString;
30
29
  id: import("zod").ZodString;
31
- createdAt: import("zod").ZodCoercedDate<unknown>;
32
- updatedAt: import("zod").ZodCoercedDate<unknown>;
33
- startedAt: import("zod").ZodCoercedDate<unknown>;
30
+ orgId: import("zod").ZodString;
34
31
  orgUserId: import("zod").ZodString;
35
32
  targetType: import("zod").ZodEnum<{
36
- course: "course";
37
33
  activity: "activity";
38
34
  module: "module";
35
+ course: "course";
39
36
  }>;
40
37
  targetId: import("zod").ZodString;
38
+ startedAt: import("zod").ZodCoercedDate<unknown>;
41
39
  completedAt: import("zod").ZodNullable<import("zod").ZodCoercedDate<unknown>>;
40
+ createdAt: import("zod").ZodCoercedDate<unknown>;
41
+ updatedAt: import("zod").ZodCoercedDate<unknown>;
42
42
  }, import("zod/v4/core").$strip>>;
43
43
  progressCompleted: RuntimeEventDefinition<VersionedDomainEvent<{
44
44
  id: string;
45
45
  orgId: string;
46
46
  orgUserId: string;
47
- targetType: "course" | "activity" | "module";
47
+ targetType: "activity" | "module" | "course";
48
48
  targetId: string;
49
49
  startedAt: Date;
50
50
  position: JsonValue;
@@ -55,7 +55,7 @@ declare const progressEvents: {
55
55
  id: string;
56
56
  orgId: string;
57
57
  orgUserId: string;
58
- targetType: "course" | "activity" | "module";
58
+ targetType: "activity" | "module" | "course";
59
59
  targetId: string;
60
60
  startedAt: unknown;
61
61
  position: unknown;
@@ -63,19 +63,19 @@ declare const progressEvents: {
63
63
  createdAt: unknown;
64
64
  updatedAt: unknown;
65
65
  }, import("zod").ZodObject<{
66
- orgId: import("zod").ZodString;
67
66
  id: import("zod").ZodString;
68
- createdAt: import("zod").ZodCoercedDate<unknown>;
69
- updatedAt: import("zod").ZodCoercedDate<unknown>;
70
- startedAt: import("zod").ZodCoercedDate<unknown>;
67
+ orgId: import("zod").ZodString;
71
68
  orgUserId: import("zod").ZodString;
72
69
  targetType: import("zod").ZodEnum<{
73
- course: "course";
74
70
  activity: "activity";
75
71
  module: "module";
72
+ course: "course";
76
73
  }>;
77
74
  targetId: import("zod").ZodString;
75
+ startedAt: import("zod").ZodCoercedDate<unknown>;
78
76
  completedAt: import("zod").ZodNullable<import("zod").ZodCoercedDate<unknown>>;
77
+ createdAt: import("zod").ZodCoercedDate<unknown>;
78
+ updatedAt: import("zod").ZodCoercedDate<unknown>;
79
79
  }, import("zod/v4/core").$strip>>;
80
80
  };
81
81
  type ProgressStarted = EventOf<typeof progressEvents.progressStarted>;
@@ -3,6 +3,6 @@ import { ProgressId } from "../types/progress.js";
3
3
  import { ProgressCompleted, ProgressEvent, ProgressStarted, progressEvents } from "./events.js";
4
4
  import "./model.js";
5
5
  import "./types.js";
6
- import { ProgressRepository, ProgressService, ProgressUnitOfWork } from "./ports.js";
6
+ import { ContentAccessReader, ProgressRepository, ProgressService, ProgressUnitOfWork } from "./ports.js";
7
7
  import { ProgressServiceImpl } from "./service.js";
8
- export { type ProgressCompleted, type ProgressEvent, type ProgressId, type ProgressRecord, type ProgressReportItem, type ProgressRepository, type ProgressService, ProgressServiceImpl, type ProgressStarted, type ProgressTarget, type ProgressTargetType, type ProgressUnitOfWork, type ReportProgressInput, progressEvents };
8
+ export { type ContentAccessReader, type ProgressCompleted, type ProgressEvent, type ProgressId, type ProgressRecord, type ProgressReportItem, type ProgressRepository, type ProgressService, ProgressServiceImpl, type ProgressStarted, type ProgressTarget, type ProgressTargetType, type ProgressUnitOfWork, type ReportProgressInput, progressEvents };
@@ -25,6 +25,11 @@ interface ProgressRepository {
25
25
  }): Promise<ProgressRecord[]>;
26
26
  update(orgId: string, id: string, patch: Partial<Pick<ProgressRecord, 'position' | 'completedAt'>>): Promise<ProgressRecord | null>;
27
27
  }
28
+ /** Access is resolved by entitlements — a report is only taken from a student
29
+ * who can open the course. */
30
+ interface ContentAccessReader {
31
+ hasAccess(orgId: string, orgUserId: string, contentId: string): Promise<boolean>;
32
+ }
28
33
  /** Writes that emit events run through this scope so row + outbox entry commit
29
34
  * in one transaction. */
30
35
  interface ProgressWriteScope {
@@ -33,5 +38,5 @@ interface ProgressWriteScope {
33
38
  }
34
39
  type ProgressUnitOfWork = UnitOfWork<ProgressWriteScope>;
35
40
  //#endregion
36
- export { ProgressRepository, ProgressService, ProgressUnitOfWork, ProgressWriteScope };
41
+ export { ContentAccessReader, ProgressRepository, ProgressService, ProgressUnitOfWork, ProgressWriteScope };
37
42
  //# sourceMappingURL=ports.d.ts.map
@@ -5,17 +5,19 @@ import { CourseManagementService } from "../content/ports.js";
5
5
  import "../content/index.js";
6
6
  import "./model.js";
7
7
  import "./types.js";
8
- import { ProgressRepository, ProgressService, ProgressUnitOfWork } from "./ports.js";
8
+ import { ContentAccessReader, ProgressRepository, ProgressService, ProgressUnitOfWork } from "./ports.js";
9
9
  //#region src/progress/service.d.ts
10
10
  type ProgressServiceParams = {
11
11
  repo: ProgressRepository;
12
12
  content: CourseManagementService;
13
+ access: ContentAccessReader;
13
14
  uow: ProgressUnitOfWork;
14
15
  logger?: Logger;
15
16
  };
16
17
  declare class ProgressServiceImpl implements ProgressService {
17
18
  private readonly repo;
18
19
  private readonly content;
20
+ private readonly access;
19
21
  private readonly uow;
20
22
  private readonly logger;
21
23
  constructor(params: ProgressServiceParams);
@@ -1,9 +1,9 @@
1
- import { noopLogger } from "../shared/logger.js";
2
1
  import { genId } from "../shared/id.js";
3
2
  import { NotFoundError } from "../shared/errors.js";
3
+ import { noopLogger } from "../shared/logger.js";
4
4
  import { progressEvents } from "./events.js";
5
5
  //#region src/progress/service.ts
6
- /** Mirrors reporting/learn: `settings.published === false` is the only draft signal. */
6
+ /** Mirrors reporting/member: `settings.published === false` is the only draft signal. */
7
7
  function isActivityPublished(settings) {
8
8
  return settings?.published !== false;
9
9
  }
@@ -39,11 +39,13 @@ function completionSatisfied(settings, claimed) {
39
39
  var ProgressServiceImpl = class {
40
40
  repo;
41
41
  content;
42
+ access;
42
43
  uow;
43
44
  logger;
44
45
  constructor(params) {
45
46
  this.repo = params.repo;
46
47
  this.content = params.content;
48
+ this.access = params.access;
47
49
  this.uow = params.uow;
48
50
  this.logger = params.logger ?? noopLogger;
49
51
  }
@@ -51,6 +53,8 @@ var ProgressServiceImpl = class {
51
53
  const activity = await this.content.getActivity(orgId, input.activityId);
52
54
  if (!activity || !isActivityPublished(activity.settings)) throw new NotFoundError("Activity", input.activityId);
53
55
  const courseId = activity.courseId;
56
+ const course = await this.content.getCourse(orgId, courseId);
57
+ if (!course || course.status !== "published" || !await this.access.hasAccess(orgId, input.orgUserId, courseId)) throw new NotFoundError("Activity", input.activityId);
54
58
  const modules = await this.content.listCourseModules(orgId, courseId);
55
59
  const courseActivities = await this.content.listCourseActivities(orgId, courseId);
56
60
  return this.uow.run(async (scope) => {
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","names":[],"sources":["../../src/progress/service.ts"],"sourcesContent":["// progress context — service implementation (inbound port).\n// The frontend only reports usage; this service makes every completion\n// decision. One report batch runs in one UoW transaction: ensure the record,\n// merge item payloads into the per-subject state map, evaluate the activity's\n// completion rule, then module/course against current published structure —\n// appending events with the writes. The container is resolved from the\n// hierarchy (activity → module → course), never supplied by the caller.\n// Percentage/resume are derived by readers, never stored here.\nimport { genId } from '../shared/id.js';\nimport { NotFoundError } from '../shared/errors.js';\nimport type { ProgressRecord } from './model.js';\nimport type {\n ProgressRepository,\n ProgressService,\n ProgressUnitOfWork,\n ProgressWriteScope,\n} from './ports.js';\nimport type { ProgressReportItem, ProgressTarget, ReportProgressInput } from './types.js';\nimport { progressEvents, type NewProgressEvent } from './events.js';\nimport type { Activity, CourseManagementService, Module } from '../content/index.js';\nimport type { JsonValue } from '../types/index.js';\nimport type { Logger } from '../shared/ports.js';\nimport { noopLogger } from '../shared/logger.js';\n\n/** Mirrors reporting/learn: `settings.published === false` is the only draft signal. */\nfunction isActivityPublished(settings: unknown): boolean {\n return (settings as { published?: boolean } | null)?.published !== false;\n}\n\n/** Fold a report batch into the record's per-subject state map. Reserved keys\n * (`asset` = subject, `completed` = claim) are stripped; the rest is the asset\n * type's own vocabulary, stored opaquely — latest report per subject wins. */\nfunction mergeReports(\n position: unknown,\n items: ProgressReportItem[],\n): { map: Record<string, JsonValue>; changed: boolean; claimed: boolean } {\n const map = { ...((position as Record<string, JsonValue> | null) ?? {}) };\n let changed = false;\n let claimed = false;\n for (const item of items) {\n const { asset, completed, ...payload } = item;\n if (completed === true) {\n claimed = true;\n }\n if (Object.keys(payload).length > 0) {\n map[asset ?? 'self'] = payload;\n changed = true;\n }\n }\n return { map, changed, claimed };\n}\n\n/** The completion-rule seam. Only `manual` ships: no authored rule → the\n * learner's claim decides. Authored rules are never satisfied until their\n * evaluators exist. Future rules evaluate against the accumulated state map. */\nfunction completionSatisfied(settings: unknown, claimed: boolean): boolean {\n const rule = (settings as { completion?: { rule?: string } } | null)?.completion?.rule;\n if (!rule || rule === 'manual') {\n return claimed;\n }\n return false;\n}\n\nexport type ProgressServiceParams = {\n repo: ProgressRepository;\n content: CourseManagementService;\n uow: ProgressUnitOfWork;\n logger?: Logger;\n};\n\nexport class ProgressServiceImpl implements ProgressService {\n private readonly repo: ProgressRepository;\n private readonly content: CourseManagementService;\n private readonly uow: ProgressUnitOfWork;\n private readonly logger: Logger;\n\n constructor(params: ProgressServiceParams) {\n this.repo = params.repo;\n this.content = params.content;\n this.uow = params.uow;\n this.logger = params.logger ?? noopLogger;\n }\n\n async report(orgId: string, input: ReportProgressInput): Promise<ProgressRecord> {\n const activity = await this.content.getActivity(orgId, input.activityId);\n if (!activity || !isActivityPublished(activity.settings)) {\n throw new NotFoundError('Activity', input.activityId);\n }\n const courseId = activity.courseId;\n const modules = await this.content.listCourseModules(orgId, courseId);\n const courseActivities = await this.content.listCourseActivities(orgId, courseId);\n return this.uow.run(async (scope) => {\n // Serializes concurrent reports for this student+course (locks are tx-scoped;\n // both racers lock in the same order, the loser waits and then sees committed state).\n const lockIds = [\n ...courseActivities.filter((a) => isActivityPublished(a.settings)).map((a) => a.id),\n ...modules.map((m) => m.id),\n courseId,\n ];\n await scope.progress.findByTargets(orgId, input.orgUserId, lockIds, { forUpdate: true });\n const events: NewProgressEvent[] = [];\n let record = await this.ensureActivityRecord(orgId, input, scope, events);\n const state = mergeReports(record.position, input.reports);\n if (state.changed) {\n record = (await scope.progress.update(orgId, record.id, { position: state.map })) ?? record;\n }\n if (!record.completedAt && completionSatisfied(activity.settings, state.claimed)) {\n record =\n (await scope.progress.update(orgId, record.id, {\n completedAt: new Date(),\n })) ?? record;\n events.push(progressEvents.progressCompleted.make({ orgId, data: record }));\n await this.completeContainers(\n orgId,\n input,\n courseId,\n modules,\n courseActivities,\n scope,\n events,\n );\n this.logger.info('progress completed', { orgId, recordId: record.id });\n }\n if (events.length > 0) {\n await scope.outbox.append(events);\n }\n return record;\n });\n }\n\n private async ensureActivityRecord(\n orgId: string,\n input: ReportProgressInput,\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<ProgressRecord> {\n const target: ProgressTarget = {\n orgUserId: input.orgUserId,\n targetType: 'activity',\n targetId: input.activityId,\n };\n const existing = await scope.progress.findByTarget(orgId, target);\n if (existing) {\n return existing;\n }\n const now = new Date();\n const record = await scope.progress.insert(orgId, {\n id: genId('progress'),\n orgId,\n orgUserId: input.orgUserId,\n targetType: 'activity',\n targetId: input.activityId,\n startedAt: now,\n position: null,\n completedAt: null,\n createdAt: now,\n updatedAt: now,\n });\n if (!record) {\n // Lost a concurrent first-touch insert: the winner owns the row and emitted\n // progress.started. Return its row without re-emitting.\n const winner = await scope.progress.findByTarget(orgId, target);\n if (!winner) {\n throw new Error('progress record vanished after insert conflict');\n }\n return winner;\n }\n events.push(progressEvents.progressStarted.make({ orgId, data: record }));\n this.logger.info('progress started', { orgId, recordId: record.id });\n return record;\n }\n\n /** After an activity completes: newly-complete containers get their records\n * (created complete) and a progress.completed event — same transaction. */\n private async completeContainers(\n orgId: string,\n input: ReportProgressInput,\n courseId: string,\n modules: Module[],\n courseActivities: Activity[],\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<void> {\n const published = courseActivities.filter((a) => isActivityPublished(a.settings));\n const byModule = modules.map((m) => ({\n id: m.id,\n activityIds: published.filter((a) => a.moduleId === m.id).map((a) => a.id),\n }));\n const allIds = byModule.flatMap((m) => m.activityIds);\n const records = await scope.progress.findByTargets(orgId, input.orgUserId, allIds);\n const done = new Set(\n records.filter((r) => r.targetType === 'activity' && r.completedAt).map((r) => r.targetId),\n );\n const containing = byModule.find((m) => m.activityIds.includes(input.activityId));\n if (containing && containing.activityIds.every((id) => done.has(id))) {\n await this.ensureContainerComplete(orgId, input, 'module', containing.id, scope, events);\n }\n if (allIds.length > 0 && allIds.every((id) => done.has(id))) {\n await this.ensureContainerComplete(orgId, input, 'course', courseId, scope, events);\n }\n }\n\n private async ensureContainerComplete(\n orgId: string,\n input: ReportProgressInput,\n targetType: 'module' | 'course',\n targetId: string,\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<void> {\n const target: ProgressTarget = { orgUserId: input.orgUserId, targetType, targetId };\n let existing = await scope.progress.findByTarget(orgId, target);\n if (existing?.completedAt) {\n return;\n }\n if (!existing) {\n const now = new Date();\n const inserted = await scope.progress.insert(orgId, {\n id: genId('progress'),\n orgId,\n orgUserId: input.orgUserId,\n targetType,\n targetId,\n startedAt: now,\n position: null,\n completedAt: now,\n createdAt: now,\n updatedAt: now,\n });\n if (inserted) {\n events.push(progressEvents.progressCompleted.make({ orgId, data: inserted }));\n return;\n }\n // Lost a concurrent insert: re-read the winner's row.\n existing = await scope.progress.findByTarget(orgId, target);\n if (existing?.completedAt) {\n return;\n }\n if (!existing) {\n throw new Error('progress container record vanished after insert conflict');\n }\n }\n const record =\n (await scope.progress.update(orgId, existing.id, {\n completedAt: new Date(),\n })) ?? existing;\n events.push(progressEvents.progressCompleted.make({ orgId, data: record }));\n }\n\n get(orgId: string, target: ProgressTarget): Promise<ProgressRecord | null> {\n return this.repo.findByTarget(orgId, target);\n }\n\n listByTargets(orgId: string, orgUserId: string, targetIds: string[]): Promise<ProgressRecord[]> {\n return this.repo.findByTargets(orgId, orgUserId, targetIds);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"service.js","names":[],"sources":["../../src/progress/service.ts"],"sourcesContent":["// progress context — service implementation (inbound port).\n// The frontend only reports usage; this service makes every completion\n// decision. One report batch runs in one UoW transaction: ensure the record,\n// merge item payloads into the per-subject state map, evaluate the activity's\n// completion rule, then module/course against current published structure —\n// appending events with the writes. The container is resolved from the\n// hierarchy (activity → module → course), never supplied by the caller.\n// Percentage/resume are derived by readers, never stored here.\nimport { genId } from '../shared/id.js';\nimport { NotFoundError } from '../shared/errors.js';\nimport type { ProgressRecord } from './model.js';\nimport type {\n ContentAccessReader,\n ProgressRepository,\n ProgressService,\n ProgressUnitOfWork,\n ProgressWriteScope,\n} from './ports.js';\nimport type { ProgressReportItem, ProgressTarget, ReportProgressInput } from './types.js';\nimport { progressEvents, type NewProgressEvent } from './events.js';\nimport type { Activity, CourseManagementService, Module } from '../content/index.js';\nimport type { JsonValue } from '../types/index.js';\nimport type { Logger } from '../shared/ports.js';\nimport { noopLogger } from '../shared/logger.js';\n\n/** Mirrors reporting/member: `settings.published === false` is the only draft signal. */\nfunction isActivityPublished(settings: unknown): boolean {\n return (settings as { published?: boolean } | null)?.published !== false;\n}\n\n/** Fold a report batch into the record's per-subject state map. Reserved keys\n * (`asset` = subject, `completed` = claim) are stripped; the rest is the asset\n * type's own vocabulary, stored opaquely — latest report per subject wins. */\nfunction mergeReports(\n position: unknown,\n items: ProgressReportItem[],\n): { map: Record<string, JsonValue>; changed: boolean; claimed: boolean } {\n const map = { ...((position as Record<string, JsonValue> | null) ?? {}) };\n let changed = false;\n let claimed = false;\n for (const item of items) {\n const { asset, completed, ...payload } = item;\n if (completed === true) {\n claimed = true;\n }\n if (Object.keys(payload).length > 0) {\n map[asset ?? 'self'] = payload;\n changed = true;\n }\n }\n return { map, changed, claimed };\n}\n\n/** The completion-rule seam. Only `manual` ships: no authored rule → the\n * learner's claim decides. Authored rules are never satisfied until their\n * evaluators exist. Future rules evaluate against the accumulated state map. */\nfunction completionSatisfied(settings: unknown, claimed: boolean): boolean {\n const rule = (settings as { completion?: { rule?: string } } | null)?.completion?.rule;\n if (!rule || rule === 'manual') {\n return claimed;\n }\n return false;\n}\n\nexport type ProgressServiceParams = {\n repo: ProgressRepository;\n content: CourseManagementService;\n access: ContentAccessReader;\n uow: ProgressUnitOfWork;\n logger?: Logger;\n};\n\nexport class ProgressServiceImpl implements ProgressService {\n private readonly repo: ProgressRepository;\n private readonly content: CourseManagementService;\n private readonly access: ContentAccessReader;\n private readonly uow: ProgressUnitOfWork;\n private readonly logger: Logger;\n\n constructor(params: ProgressServiceParams) {\n this.repo = params.repo;\n this.content = params.content;\n this.access = params.access;\n this.uow = params.uow;\n this.logger = params.logger ?? noopLogger;\n }\n\n async report(orgId: string, input: ReportProgressInput): Promise<ProgressRecord> {\n const activity = await this.content.getActivity(orgId, input.activityId);\n if (!activity || !isActivityPublished(activity.settings)) {\n throw new NotFoundError('Activity', input.activityId);\n }\n const courseId = activity.courseId;\n const course = await this.content.getCourse(orgId, courseId);\n if (\n !course ||\n course.status !== 'published' ||\n !(await this.access.hasAccess(orgId, input.orgUserId, courseId))\n ) {\n throw new NotFoundError('Activity', input.activityId);\n }\n const modules = await this.content.listCourseModules(orgId, courseId);\n const courseActivities = await this.content.listCourseActivities(orgId, courseId);\n return this.uow.run(async (scope) => {\n // Serializes concurrent reports for this student+course (locks are tx-scoped;\n // both racers lock in the same order, the loser waits and then sees committed state).\n const lockIds = [\n ...courseActivities.filter((a) => isActivityPublished(a.settings)).map((a) => a.id),\n ...modules.map((m) => m.id),\n courseId,\n ];\n await scope.progress.findByTargets(orgId, input.orgUserId, lockIds, { forUpdate: true });\n const events: NewProgressEvent[] = [];\n let record = await this.ensureActivityRecord(orgId, input, scope, events);\n const state = mergeReports(record.position, input.reports);\n if (state.changed) {\n record = (await scope.progress.update(orgId, record.id, { position: state.map })) ?? record;\n }\n if (!record.completedAt && completionSatisfied(activity.settings, state.claimed)) {\n record =\n (await scope.progress.update(orgId, record.id, {\n completedAt: new Date(),\n })) ?? record;\n events.push(progressEvents.progressCompleted.make({ orgId, data: record }));\n await this.completeContainers(\n orgId,\n input,\n courseId,\n modules,\n courseActivities,\n scope,\n events,\n );\n this.logger.info('progress completed', { orgId, recordId: record.id });\n }\n if (events.length > 0) {\n await scope.outbox.append(events);\n }\n return record;\n });\n }\n\n private async ensureActivityRecord(\n orgId: string,\n input: ReportProgressInput,\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<ProgressRecord> {\n const target: ProgressTarget = {\n orgUserId: input.orgUserId,\n targetType: 'activity',\n targetId: input.activityId,\n };\n const existing = await scope.progress.findByTarget(orgId, target);\n if (existing) {\n return existing;\n }\n const now = new Date();\n const record = await scope.progress.insert(orgId, {\n id: genId('progress'),\n orgId,\n orgUserId: input.orgUserId,\n targetType: 'activity',\n targetId: input.activityId,\n startedAt: now,\n position: null,\n completedAt: null,\n createdAt: now,\n updatedAt: now,\n });\n if (!record) {\n // Lost a concurrent first-touch insert: the winner owns the row and emitted\n // progress.started. Return its row without re-emitting.\n const winner = await scope.progress.findByTarget(orgId, target);\n if (!winner) {\n throw new Error('progress record vanished after insert conflict');\n }\n return winner;\n }\n events.push(progressEvents.progressStarted.make({ orgId, data: record }));\n this.logger.info('progress started', { orgId, recordId: record.id });\n return record;\n }\n\n /** After an activity completes: newly-complete containers get their records\n * (created complete) and a progress.completed event — same transaction. */\n private async completeContainers(\n orgId: string,\n input: ReportProgressInput,\n courseId: string,\n modules: Module[],\n courseActivities: Activity[],\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<void> {\n const published = courseActivities.filter((a) => isActivityPublished(a.settings));\n const byModule = modules.map((m) => ({\n id: m.id,\n activityIds: published.filter((a) => a.moduleId === m.id).map((a) => a.id),\n }));\n const allIds = byModule.flatMap((m) => m.activityIds);\n const records = await scope.progress.findByTargets(orgId, input.orgUserId, allIds);\n const done = new Set(\n records.filter((r) => r.targetType === 'activity' && r.completedAt).map((r) => r.targetId),\n );\n const containing = byModule.find((m) => m.activityIds.includes(input.activityId));\n if (containing && containing.activityIds.every((id) => done.has(id))) {\n await this.ensureContainerComplete(orgId, input, 'module', containing.id, scope, events);\n }\n if (allIds.length > 0 && allIds.every((id) => done.has(id))) {\n await this.ensureContainerComplete(orgId, input, 'course', courseId, scope, events);\n }\n }\n\n private async ensureContainerComplete(\n orgId: string,\n input: ReportProgressInput,\n targetType: 'module' | 'course',\n targetId: string,\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<void> {\n const target: ProgressTarget = { orgUserId: input.orgUserId, targetType, targetId };\n let existing = await scope.progress.findByTarget(orgId, target);\n if (existing?.completedAt) {\n return;\n }\n if (!existing) {\n const now = new Date();\n const inserted = await scope.progress.insert(orgId, {\n id: genId('progress'),\n orgId,\n orgUserId: input.orgUserId,\n targetType,\n targetId,\n startedAt: now,\n position: null,\n completedAt: now,\n createdAt: now,\n updatedAt: now,\n });\n if (inserted) {\n events.push(progressEvents.progressCompleted.make({ orgId, data: inserted }));\n return;\n }\n // Lost a concurrent insert: re-read the winner's row.\n existing = await scope.progress.findByTarget(orgId, target);\n if (existing?.completedAt) {\n return;\n }\n if (!existing) {\n throw new Error('progress container record vanished after insert conflict');\n }\n }\n const record =\n (await scope.progress.update(orgId, existing.id, {\n completedAt: new Date(),\n })) ?? existing;\n events.push(progressEvents.progressCompleted.make({ orgId, data: record }));\n }\n\n get(orgId: string, target: ProgressTarget): Promise<ProgressRecord | null> {\n return this.repo.findByTarget(orgId, target);\n }\n\n listByTargets(orgId: string, orgUserId: string, targetIds: string[]): Promise<ProgressRecord[]> {\n return this.repo.findByTargets(orgId, orgUserId, targetIds);\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { CommunityAnalytics, CommunityMember, CommunityMembersQuery, CourseActivityEngagement, CourseAnalytics, CourseEnrollmentPoint, EnrollmentPoint, OverviewStats, Page, Student, StudentAnalytics, StudentCourseProgress, StudentsQuery } from "./model.js";
2
+ import { AdminReportRepository, AdminReportService } from "./ports.js";
3
+ import { AdminReportServiceImpl } from "./service.js";
4
+ export { type AdminReportRepository, type AdminReportService, AdminReportServiceImpl, type CommunityAnalytics, type CommunityMember, type CommunityMembersQuery, type CourseActivityEngagement, type CourseAnalytics, type CourseEnrollmentPoint, type EnrollmentPoint, type OverviewStats, type Page, type Student, type StudentAnalytics, type StudentCourseProgress, type StudentsQuery };
@@ -0,0 +1,2 @@
1
+ import { AdminReportServiceImpl } from "./service.js";
2
+ export { AdminReportServiceImpl };
@@ -0,0 +1,108 @@
1
+ import { OrgUserProfile, OrgUserStatus, Role } from "../../types/schemas/organizations.js";
2
+ import "../../types/index.js";
3
+ //#region src/reporting/admin/model.d.ts
4
+ interface SpacePostCount {
5
+ spaceId: string;
6
+ count: number;
7
+ }
8
+ interface PostCommentCount {
9
+ postId: string;
10
+ count: number;
11
+ }
12
+ interface CommunityAnalytics {
13
+ memberCount: number;
14
+ activeMembers: number;
15
+ postsPerSpace: SpacePostCount[];
16
+ commentsPerPost: PostCommentCount[];
17
+ messagesSent: number;
18
+ flagsRaised: number;
19
+ flagsResolved: number;
20
+ grantsLapsed: number;
21
+ grantsRenewed: number;
22
+ }
23
+ interface OverviewStats {
24
+ publishedCourses: number;
25
+ draftCourses: number;
26
+ activeStudents: number;
27
+ expiringSoon: number;
28
+ }
29
+ interface EnrollmentPoint {
30
+ date: string;
31
+ count: number;
32
+ }
33
+ interface CourseActivityEngagement {
34
+ activityId: string;
35
+ title: string;
36
+ moduleId: string;
37
+ moduleTitle: string;
38
+ started: number;
39
+ completed: number;
40
+ }
41
+ interface CourseAnalytics {
42
+ enrolled: number;
43
+ started: number;
44
+ completed: number;
45
+ /** Mean of each cohort member's completed-activities percentage (0–100). */
46
+ avgProgress: number;
47
+ activities: CourseActivityEngagement[];
48
+ }
49
+ interface CourseEnrollmentPoint {
50
+ date: string;
51
+ count: number;
52
+ }
53
+ interface Student extends OrgUserProfile {
54
+ /** As typed on the invite form. `name` is the composed rendering name and
55
+ * drifts once the person edits their own profile; these are what the admin
56
+ * edits. Null for anyone who arrived by self-signup. */
57
+ firstName: string | null;
58
+ lastName: string | null;
59
+ entitlementCount: number;
60
+ avgProgress: number;
61
+ /** `invited` until they accept — the admin added them, they have not arrived. */
62
+ status: OrgUserStatus;
63
+ joinedAt: string;
64
+ lastActiveAt: string | null;
65
+ }
66
+ interface StudentCourseProgress {
67
+ courseId: string;
68
+ title: string;
69
+ /** Published activities in the course. */
70
+ totalActivities: number;
71
+ completedActivities: number;
72
+ /** 0–100, completed published activities over the total. */
73
+ progress: number;
74
+ startedAt: string | null;
75
+ lastActivityAt: string | null;
76
+ completedAt: string | null;
77
+ }
78
+ interface StudentAnalytics {
79
+ enrolled: number;
80
+ started: number;
81
+ completed: number;
82
+ /** Mean of the per-course progress percentages (0–100). */
83
+ avgProgress: number;
84
+ courses: StudentCourseProgress[];
85
+ }
86
+ interface CommunityMember extends OrgUserProfile {
87
+ role: Role;
88
+ }
89
+ interface CommunityMembersQuery {
90
+ page: number;
91
+ pageSize: number;
92
+ search?: string | undefined;
93
+ }
94
+ interface StudentsQuery {
95
+ page: number;
96
+ pageSize: number;
97
+ search?: string | undefined;
98
+ sort?: string | undefined;
99
+ }
100
+ interface Page<T> {
101
+ rows: T[];
102
+ total: number;
103
+ page: number;
104
+ pageSize: number;
105
+ }
106
+ //#endregion
107
+ export { CommunityAnalytics, CommunityMember, CommunityMembersQuery, CourseActivityEngagement, CourseAnalytics, CourseEnrollmentPoint, EnrollmentPoint, OverviewStats, Page, PostCommentCount, SpacePostCount, Student, StudentAnalytics, StudentCourseProgress, StudentsQuery };
108
+ //# sourceMappingURL=model.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { CommunityAnalytics, CommunityMember, CommunityMembersQuery, CourseAnalytics, CourseEnrollmentPoint, EnrollmentPoint, OverviewStats, Page, Student, StudentAnalytics, StudentsQuery } from "./model.js";
2
+ //#region src/reporting/admin/ports.d.ts
3
+ interface AdminReportService {
4
+ overview(orgId: string): Promise<OverviewStats>;
5
+ enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
6
+ courseAnalytics(orgId: string, courseId: string): Promise<CourseAnalytics | null>;
7
+ courseEnrollments(orgId: string, courseId: string, days: number): Promise<CourseEnrollmentPoint[] | null>;
8
+ listStudents(orgId: string, query: StudentsQuery): Promise<Page<Student>>;
9
+ getStudent(orgId: string, id: string): Promise<Student | null>;
10
+ studentAnalytics(orgId: string, id: string): Promise<StudentAnalytics | null>;
11
+ communityAnalytics(orgId: string, communityId: string): Promise<CommunityAnalytics | null>;
12
+ communityMembers(orgId: string, communityId: string, query: CommunityMembersQuery): Promise<Page<CommunityMember> | null>;
13
+ }
14
+ interface AdminReportRepository {
15
+ overview(orgId: string): Promise<OverviewStats>;
16
+ enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
17
+ courseAnalytics(orgId: string, courseId: string): Promise<CourseAnalytics | null>;
18
+ courseEnrollments(orgId: string, courseId: string, days: number): Promise<CourseEnrollmentPoint[] | null>;
19
+ listStudents(orgId: string, query: StudentsQuery): Promise<Page<Student>>;
20
+ getStudent(orgId: string, id: string): Promise<Student | null>;
21
+ studentAnalytics(orgId: string, id: string): Promise<StudentAnalytics | null>;
22
+ communityAnalytics(orgId: string, communityId: string): Promise<CommunityAnalytics | null>;
23
+ communityMembers(orgId: string, communityId: string, query: CommunityMembersQuery): Promise<Page<CommunityMember> | null>;
24
+ }
25
+ //#endregion
26
+ export { AdminReportRepository, AdminReportService };
27
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { CommunityAnalytics, CommunityMember, CommunityMembersQuery, CourseAnalytics, CourseEnrollmentPoint, EnrollmentPoint, OverviewStats, Page, Student, StudentAnalytics, StudentsQuery } from "./model.js";
2
+ import { AdminReportRepository, AdminReportService } from "./ports.js";
3
+ //#region src/reporting/admin/service.d.ts
4
+ type AdminReportServiceParams = {
5
+ repo: AdminReportRepository;
6
+ };
7
+ declare class AdminReportServiceImpl implements AdminReportService {
8
+ private readonly repo;
9
+ constructor(params: AdminReportServiceParams);
10
+ overview(orgId: string): Promise<OverviewStats>;
11
+ enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
12
+ courseAnalytics(orgId: string, courseId: string): Promise<CourseAnalytics | null>;
13
+ courseEnrollments(orgId: string, courseId: string, days: number): Promise<CourseEnrollmentPoint[] | null>;
14
+ listStudents(orgId: string, query: StudentsQuery): Promise<Page<Student>>;
15
+ getStudent(orgId: string, id: string): Promise<Student | null>;
16
+ studentAnalytics(orgId: string, id: string): Promise<StudentAnalytics | null>;
17
+ communityAnalytics(orgId: string, communityId: string): Promise<CommunityAnalytics | null>;
18
+ communityMembers(orgId: string, communityId: string, query: CommunityMembersQuery): Promise<Page<CommunityMember> | null>;
19
+ }
20
+ //#endregion
21
+ export { AdminReportServiceImpl, AdminReportServiceParams };
22
+ //# sourceMappingURL=service.d.ts.map