@jskit-ai/workspaces-core 0.2.11 → 0.2.13
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/CHANGELOG.md +1 -0
- package/package.json +10 -10
- package/src/server/common/repositories/workspaceMembershipsRepository.js +39 -9
- package/src/server/workspaceMembers/workspaceMembersService.js +8 -0
- package/test/repositories.integration.test.js +75 -13
- package/test/workspaceMembershipsRepository.test.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- `services.members.getMember(workspace, userId, options)` answers one active member's summary, the same as their `listMembers` entry, or `null` for a removed or unknown member. It reads that one membership through `workspaceMembershipsRepository.findActiveByWorkspaceIdAndUserId`, so a per-member lookup (a notification's recipient check, for example) costs the same few statements in a workspace of 10 or 1,000 members instead of listing them all.
|
|
5
6
|
- The workspace, settings, member and invitation field messages are sent in the coded form `{ code, message, params }`, so their field errors translate. The English is unchanged.
|
|
6
7
|
- The resources' save, delete and validation feedback names catalog keys under `jskit.workspaces.feedback.*` instead of plain English; JSKIT's forms render them with `text()`, so the English shown is unchanged.
|
|
7
8
|
- The invitation email writes its expiry date with `Intl` in the recipient's locale and saved time zone (UTC without one), for example "November 2, 2026 at 12:00 AM UTC", instead of the raw ISO timestamp. An app's own `workspaces.invite-email-template` also receives the recipient's `timeZone` next to `locale`.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/workspaces-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"test": "
|
|
6
|
+
"test": "jskit test"
|
|
7
7
|
},
|
|
8
8
|
"exports": {
|
|
9
9
|
"./server/WorkspacesFeature": "./src/server/WorkspacesFeature.js",
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"./locales/en.json": "./locales/en.json"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
26
|
-
"@jskit-ai/json-rest-api-core": "0.2.
|
|
27
|
-
"@jskit-ai/resource-core": "0.1.
|
|
28
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
29
|
-
"@jskit-ai/users-core": "0.2.
|
|
25
|
+
"@jskit-ai/database-runtime": "0.1.260",
|
|
26
|
+
"@jskit-ai/json-rest-api-core": "0.2.12",
|
|
27
|
+
"@jskit-ai/resource-core": "0.1.202",
|
|
28
|
+
"@jskit-ai/resource-crud-core": "0.1.202",
|
|
29
|
+
"@jskit-ai/users-core": "0.2.13",
|
|
30
30
|
"json-rest-schema": "^1.0.17"
|
|
31
31
|
},
|
|
32
32
|
"description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
}
|
|
199
199
|
},
|
|
200
200
|
"peerDependencies": {
|
|
201
|
-
"@jskit-ai/auth-core": "0.2.
|
|
202
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
203
|
-
"@jskit-ai/kernel": "0.2.
|
|
201
|
+
"@jskit-ai/auth-core": "0.2.11",
|
|
202
|
+
"@jskit-ai/http-runtime": "0.2.11",
|
|
203
|
+
"@jskit-ai/kernel": "0.2.11"
|
|
204
204
|
}
|
|
205
205
|
}
|
|
@@ -42,6 +42,21 @@ function normalizeMemberSummaryRow(row) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// A membership read with its user included, as a member summary.
|
|
46
|
+
function memberSummaryFromMembership(row) {
|
|
47
|
+
if (!row) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return normalizeMemberSummaryRow({
|
|
52
|
+
user_id: row.user?.id,
|
|
53
|
+
role_sid: row.roleSid,
|
|
54
|
+
status: row.status,
|
|
55
|
+
display_name: row.user?.displayName,
|
|
56
|
+
email: row.user?.email
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
/** @param {{ api: import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi }} dependencies */
|
|
46
61
|
function createRepository({ api }) {
|
|
47
62
|
const withTransaction = (work) => retryDeadlocks(() => api.transaction(work));
|
|
@@ -215,20 +230,34 @@ function createRepository({ api }) {
|
|
|
215
230
|
{ include: ["user"] }
|
|
216
231
|
);
|
|
217
232
|
|
|
218
|
-
const members = rows
|
|
219
|
-
.map((row) => normalizeMemberSummaryRow({
|
|
220
|
-
user_id: row?.user?.id,
|
|
221
|
-
role_sid: row?.roleSid,
|
|
222
|
-
status: row?.status,
|
|
223
|
-
display_name: row?.user?.displayName,
|
|
224
|
-
email: row?.user?.email
|
|
225
|
-
}))
|
|
226
|
-
.filter(Boolean);
|
|
233
|
+
const members = rows.map(memberSummaryFromMembership).filter(Boolean);
|
|
227
234
|
|
|
228
235
|
members.sort((left, right) => String(left.displayName || "").localeCompare(String(right.displayName || "")));
|
|
229
236
|
return members;
|
|
230
237
|
}
|
|
231
238
|
|
|
239
|
+
// One active member's summary, as listActiveByWorkspaceId lists it, or null. Its cost does not grow
|
|
240
|
+
// with the workspace's members.
|
|
241
|
+
async function findActiveByWorkspaceIdAndUserId(workspaceId, userId, options = {}) {
|
|
242
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
243
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
244
|
+
if (!normalizedWorkspaceId || !normalizedUserId) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const rows = await queryMemberships(
|
|
249
|
+
{
|
|
250
|
+
workspace: normalizedWorkspaceId,
|
|
251
|
+
user: normalizedUserId,
|
|
252
|
+
status: "active"
|
|
253
|
+
},
|
|
254
|
+
options,
|
|
255
|
+
{ include: ["user"] }
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return memberSummaryFromMembership(rows[0] || null);
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
async function listActiveWorkspaceIdsByUserId(userId, options = {}) {
|
|
233
262
|
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
234
263
|
if (!normalizedUserId) {
|
|
@@ -255,6 +284,7 @@ function createRepository({ api }) {
|
|
|
255
284
|
ensureOwnerMembership,
|
|
256
285
|
upsertMembership,
|
|
257
286
|
listActiveByWorkspaceId,
|
|
287
|
+
findActiveByWorkspaceIdAndUserId,
|
|
258
288
|
listActiveWorkspaceIdsByUserId
|
|
259
289
|
});
|
|
260
290
|
}
|
|
@@ -208,6 +208,13 @@ function createService({
|
|
|
208
208
|
});
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// One active member's summary, the same as their listMembers entry, or null for a removed or
|
|
212
|
+
// unknown member. It reads one membership, so a per-member lookup costs the same in any workspace.
|
|
213
|
+
async function getMember(workspace, userId, options = {}) {
|
|
214
|
+
const member = await workspaceMembershipsRepository.findActiveByWorkspaceIdAndUserId(workspace.id, userId, options);
|
|
215
|
+
return member ? mapMemberSummary(member, workspace) : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
211
218
|
async function listMembersResponse(workspace, options = {}) {
|
|
212
219
|
return returnJsonApiData(await listMembers(workspace, options));
|
|
213
220
|
}
|
|
@@ -410,6 +417,7 @@ function createService({
|
|
|
410
417
|
listRoles,
|
|
411
418
|
listRolesResponse,
|
|
412
419
|
listMembers,
|
|
420
|
+
getMember,
|
|
413
421
|
listMembersResponse,
|
|
414
422
|
updateMemberRole,
|
|
415
423
|
removeMember,
|
|
@@ -238,6 +238,20 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
|
|
|
238
238
|
assert.deepEqual(await workspaces.listForUserId(member.id), []);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
const createMembers = () => createMembersService({
|
|
242
|
+
workspaceMembershipsRepository: memberships,
|
|
243
|
+
workspaceInvitesRepository: invites,
|
|
244
|
+
inviteExpiresInMs: 86400000,
|
|
245
|
+
roleCatalog: createWorkspaceRoleCatalog(),
|
|
246
|
+
workspaceInvitationsEnabled: false,
|
|
247
|
+
inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
|
|
248
|
+
workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
|
|
249
|
+
workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail,
|
|
250
|
+
i18n: createWorkspacesServerI18n(),
|
|
251
|
+
userProfilesRepository: profiles,
|
|
252
|
+
userSettingsRepository: userSettings
|
|
253
|
+
});
|
|
254
|
+
|
|
241
255
|
// Lists of more rows than json-rest-api's default page (20) and maximum page (100), written in bulk.
|
|
242
256
|
async function insertRows(table, rows) {
|
|
243
257
|
for (let start = 0; start < rows.length; start += 100) {
|
|
@@ -289,19 +303,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
|
|
|
289
303
|
assert.deepEqual(listed.map(({ displayName }) => displayName), expectedNames);
|
|
290
304
|
assert.equal(new Set(listed.map(({ userId }) => userId)).size, 1050);
|
|
291
305
|
|
|
292
|
-
const membersService =
|
|
293
|
-
workspaceMembershipsRepository: memberships,
|
|
294
|
-
workspaceInvitesRepository: invites,
|
|
295
|
-
inviteExpiresInMs: 86400000,
|
|
296
|
-
roleCatalog: createWorkspaceRoleCatalog(),
|
|
297
|
-
workspaceInvitationsEnabled: false,
|
|
298
|
-
inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
|
|
299
|
-
workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
|
|
300
|
-
workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail,
|
|
301
|
-
i18n: createWorkspacesServerI18n(),
|
|
302
|
-
userProfilesRepository: profiles,
|
|
303
|
-
userSettingsRepository: userSettings
|
|
304
|
-
});
|
|
306
|
+
const membersService = createMembers();
|
|
305
307
|
const { members } = await membersService.listMembers(workspace);
|
|
306
308
|
assert.deepEqual(members.map(({ userId }) => userId), listed.map(({ userId }) => userId));
|
|
307
309
|
assert.deepEqual(members.filter(({ isOwner }) => isOwner).map(({ userId }) => userId), [ownerId]);
|
|
@@ -315,6 +317,66 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
|
|
|
315
317
|
});
|
|
316
318
|
});
|
|
317
319
|
|
|
320
|
+
test("getMember answers a member's listMembers entry at the same cost among 10 and 200 members", async () => {
|
|
321
|
+
const usernames = Array.from({ length: 212 }, (_, index) => `person-${String(index).padStart(3, "0")}`);
|
|
322
|
+
const users = await insertUsers(usernames, (username) => username.replace("person-", "Person "));
|
|
323
|
+
const ownerId = users.get("person-000");
|
|
324
|
+
const small = await workspaces.insert({ slug: "small-team", name: "Small Team", ownerUserId: ownerId });
|
|
325
|
+
const large = await workspaces.insert({ slug: "large-team", name: "Large Team", ownerUserId: ownerId });
|
|
326
|
+
// Small: persons 0-9 (9 removed). Large: persons 0 and 10-209 (209 removed). 210 and 211 are in neither.
|
|
327
|
+
const membership = (workspaceId, username, status = "active") => ({
|
|
328
|
+
workspace_id: workspaceId,
|
|
329
|
+
user_id: users.get(username),
|
|
330
|
+
role_sid: users.get(username) === ownerId ? "owner" : "member",
|
|
331
|
+
status
|
|
332
|
+
});
|
|
333
|
+
await insertRows("workspace_memberships", [
|
|
334
|
+
...usernames.slice(0, 10).map((username) => membership(small.id, username, username === "person-009" ? "revoked" : "active")),
|
|
335
|
+
membership(large.id, "person-000"),
|
|
336
|
+
...usernames.slice(10, 210).map((username) => membership(large.id, username, username === "person-209" ? "revoked" : "active"))
|
|
337
|
+
]);
|
|
338
|
+
const membersService = createMembers();
|
|
339
|
+
async function statementsOf(run) {
|
|
340
|
+
let statements = 0;
|
|
341
|
+
const onQuery = () => { statements += 1; };
|
|
342
|
+
knex.on("query", onQuery);
|
|
343
|
+
try {
|
|
344
|
+
const result = await run();
|
|
345
|
+
return { result, statements };
|
|
346
|
+
} finally {
|
|
347
|
+
knex.removeListener("query", onQuery);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const [workspace, memberName, removedName] of [
|
|
352
|
+
[small, "person-004", "person-009"],
|
|
353
|
+
[large, "person-150", "person-209"]
|
|
354
|
+
]) {
|
|
355
|
+
const { members } = await membersService.listMembers(workspace);
|
|
356
|
+
for (const userId of [users.get(memberName), ownerId]) {
|
|
357
|
+
const entry = members.find((member) => member.userId === userId);
|
|
358
|
+
assert.ok(entry);
|
|
359
|
+
assert.deepEqual(await membersService.getMember(workspace, userId), entry);
|
|
360
|
+
}
|
|
361
|
+
assert.equal((await membersService.getMember(workspace, ownerId)).isOwner, true);
|
|
362
|
+
assert.equal(await membersService.getMember(workspace, users.get(removedName)), null);
|
|
363
|
+
assert.equal(await membersService.getMember(workspace, users.get("person-211")), null);
|
|
364
|
+
assert.equal(await membersService.getMember(workspace, "999999"), null);
|
|
365
|
+
}
|
|
366
|
+
assert.equal(await membersService.getMember(small, users.get("person-150")), null);
|
|
367
|
+
|
|
368
|
+
const smallRead = await statementsOf(() => membersService.getMember(small, users.get("person-004")));
|
|
369
|
+
const largeRead = await statementsOf(() => membersService.getMember(large, users.get("person-150")));
|
|
370
|
+
assert.equal(smallRead.result.displayName, "Person 004");
|
|
371
|
+
assert.equal(largeRead.result.displayName, "Person 150");
|
|
372
|
+
assert.equal(largeRead.statements, smallRead.statements);
|
|
373
|
+
assert.ok(smallRead.statements <= 4, `one member read costs ${smallRead.statements} statements`);
|
|
374
|
+
await profiles.withTransaction(async (trx) => {
|
|
375
|
+
const member = await membersService.getMember(large, users.get("person-150"), { trx });
|
|
376
|
+
assert.equal(member.userId, users.get("person-150"));
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
318
380
|
test("a member of 130 workspaces has all of them listed", async () => {
|
|
319
381
|
const users = await insertUsers(["host", "roaming"]);
|
|
320
382
|
const workspaceIds = await insertWorkspaces(135, users.get("host"));
|