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