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