@jskit-ai/workspaces-core 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/locales/en.json +2 -2
  2. package/package.json +38 -11
  3. package/patterns/workspace-server/PATTERN.md +42 -17
  4. package/patterns/workspace-server/example/packages/main/src/server/WorkspaceInvitationsFeature.js +27 -0
  5. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteEmail.js +11 -10
  6. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteMailer.js +27 -0
  7. package/src/server/WorkspacesFeature.js +74 -183
  8. package/src/server/WorkspacesIntegrationsProvider.js +26 -10
  9. package/src/server/common/contributors/workspaceActionContextContributor.js +60 -70
  10. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +12 -27
  11. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +48 -63
  12. package/src/server/common/formatters/workspaceFormatter.js +12 -10
  13. package/src/server/common/repositories/workspaceInvitesRepository.js +74 -75
  14. package/src/server/common/repositories/workspaceMembershipsRepository.js +23 -45
  15. package/src/server/common/repositories/workspacesRepository.js +17 -25
  16. package/src/server/common/services/{workspaceContextService.js → workspaceDirectoryService.js} +52 -72
  17. package/src/server/common/support/realtimeServiceEvents.js +20 -12
  18. package/src/server/common/support/workspaceRecordId.js +21 -0
  19. package/src/server/common/validators/routeParamsValidator.js +3 -2
  20. package/src/server/i18n.js +5 -2
  21. package/src/server/support/resolveWorkspace.js +10 -7
  22. package/src/server/support/workspaceActionSurfaces.js +5 -41
  23. package/src/server/support/workspaceCrud.js +25 -0
  24. package/src/server/support/workspaceInvitationsPolicy.js +16 -27
  25. package/src/server/support/workspaceRouteInput.js +19 -2
  26. package/src/server/{common/support → support}/workspaceRoutePaths.js +8 -2
  27. package/src/server/types.js +47 -0
  28. package/src/server/workspaceBootstrapContributor.js +105 -142
  29. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +105 -137
  30. package/src/server/workspaceDirectory/{bootWorkspaceDirectoryRoutes.js → workspaceDirectoryRoutes.js} +7 -19
  31. package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +15 -29
  32. package/src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js +25 -0
  33. package/src/server/workspaceMembers/workspaceInviteUrls.js +24 -23
  34. package/src/server/workspaceMembers/workspaceMembersActions.js +189 -219
  35. package/src/server/workspaceMembers/{bootWorkspaceMembers.js → workspaceMembersRoutes.js} +14 -26
  36. package/src/server/workspaceMembers/workspaceMembersService.js +110 -97
  37. package/src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js +8 -0
  38. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +79 -112
  39. package/src/server/workspacePendingInvitations/{bootWorkspacePendingInvitations.js → workspacePendingInvitationsRoutes.js} +11 -8
  40. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +54 -57
  41. package/src/server/workspaceSettings/workspaceSettingsActions.js +53 -73
  42. package/src/server/workspaceSettings/workspaceSettingsRepository.js +20 -20
  43. package/src/server/workspaceSettings/{bootWorkspaceSettings.js → workspaceSettingsRoutes.js} +9 -21
  44. package/src/server/workspaceSettings/workspaceSettingsService.js +24 -26
  45. package/src/server/workspacesRuntime.js +185 -0
  46. package/src/shared/operationMessages.js +6 -15
  47. package/src/shared/resources/workspaceMembersResource.js +2 -2
  48. package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
  49. package/src/shared/resources/workspaceResource.js +4 -1
  50. package/src/shared/roles.js +15 -12
  51. package/src/shared/tenancyProfile.js +2 -2
  52. package/test/exportsContract.test.js +5 -0
  53. package/test/featureRuntime.test.js +5 -6
  54. package/test/packageMetadata.test.js +12 -0
  55. package/test/repositories.integration.test.js +1 -1
  56. package/test/requestedWorkspaceScope.test.js +24 -0
  57. package/test/roleLabels.test.js +29 -7
  58. package/test/support/invitationTransactions.js +35 -19
  59. package/test/workspaceActionSurfaces.test.js +0 -55
  60. package/test/workspaceAuthPolicyContextResolver.test.js +1 -1
  61. package/test/workspaceBootstrapContributor.test.js +7 -6
  62. package/test/workspaceCrud.test.js +23 -0
  63. package/test/workspaceInvitesRepository.test.js +9 -8
  64. package/test/workspaceMembersService.test.js +131 -28
  65. package/test/workspacePendingInvitationsResource.test.js +2 -2
  66. package/test/workspacePendingInvitationsService.test.js +69 -22
  67. package/test/workspaceService.test.js +58 -47
  68. package/test/workspaceSettingsActions.test.js +14 -7
  69. package/test/workspaceSettingsRepository.test.js +11 -0
  70. package/test/workspaceSettingsService.test.js +11 -9
  71. package/test/workspacesRepository.test.js +16 -16
  72. package/test/workspacesRouteRequestInputValidator.test.js +4 -4
  73. package/src/server/common/repositories/repositoryUtils.js +0 -43
  74. package/src/server/common/support/deepFreeze.js +0 -1
  75. package/src/server/common/support/resolveActionUser.js +0 -9
@@ -1,45 +1,28 @@
1
1
  import { resolveInviteTokenHash } from "@jskit-ai/auth-core/server/inviteTokens";
2
2
  import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
3
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
3
4
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
4
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
- import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
6
-
7
- function createService({
8
- workspaceInvitesRepository,
9
- workspaceMembershipsRepository,
10
- roleCatalog = null
11
- } = {}) {
12
- if (!workspaceInvitesRepository || !workspaceMembershipsRepository) {
13
- throw new Error("workspacePendingInvitationsService requires invite and membership repositories.");
14
- }
15
-
16
- let acceptanceParticipant = null;
17
- let registrationClosed = false;
18
-
5
+ import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
6
+
7
+ /**
8
+ * @param {object} dependencies
9
+ * @param {import("../types.js").WorkspaceInvitesRepository} dependencies.workspaceInvitesRepository
10
+ * @param {import("../types.js").WorkspaceMembershipsRepository} dependencies.workspaceMembershipsRepository
11
+ * @param {import("../types.js").WorkspaceRoleCatalog} dependencies.roleCatalog
12
+ * @param {import("../types.js").WorkspaceInviteAcceptanceParticipant} dependencies.acceptanceParticipant
13
+ */
14
+ function createService({ workspaceInvitesRepository, workspaceMembershipsRepository, roleCatalog, acceptanceParticipant }) {
19
15
  function roleLabelMetadata(roleSid) {
20
- const role = roleCatalog?.roles?.find((entry) => entry.id === (normalizeLowerText(roleSid || "member") || "member"));
16
+ const role = roleCatalog.roles.find((entry) => entry.id === (normalizeLowerText(roleSid || "member") || "member"));
21
17
  const label = typeof role?.label === "string" ? role.label.trim() : "";
22
18
  return label ? { roleLabel: label } : {};
23
19
  }
24
20
 
25
- function registerAcceptanceParticipant(participant) {
26
- if (typeof participant !== "function") {
27
- throw new TypeError("Acceptance participant must be a function.");
28
- }
29
- if (acceptanceParticipant) {
30
- throw new Error("An invitation acceptance participant is already registered.");
31
- }
32
- if (registrationClosed) {
33
- throw new Error("Invitation acceptance participant registration is closed.");
34
- }
35
- acceptanceParticipant = participant;
36
- }
37
-
38
21
  async function withInviteTransaction(user, token, options, work) {
22
+ // readable-check: allow required-collaborators - platform object: a Knex transaction, which acceptance and refusal open themselves
39
23
  if (options.trx) {
40
24
  throw new TypeError("Invitation acceptance/refusal owns its transaction; use the acceptance participant for related writes.");
41
25
  }
42
- registrationClosed = true;
43
26
  // Locate outside the transaction to avoid a stale MySQL repeatable-read
44
27
  // snapshot. The callback validates again under the workspace/invite locks.
45
28
  const initial = await requirePendingInviteForUserByToken(user, token, options);
@@ -79,7 +62,7 @@ function createService({
79
62
  return tokenHash;
80
63
  }
81
64
 
82
- function maskInviteEmail(email = "") {
65
+ function maskInviteEmail(email) {
83
66
  const normalizedEmail = normalizeLowerText(email);
84
67
  const [localPart = "", domain = ""] = normalizedEmail.split("@");
85
68
  if (!localPart || !domain) {
@@ -89,7 +72,7 @@ function createService({
89
72
  return `${visibleLocal}${"*".repeat(Math.max(localPart.length - visibleLocal.length, 1))}@${domain}`;
90
73
  }
91
74
 
92
- function resolvePublicInviteStatus(invite = null) {
75
+ function resolvePublicInviteStatus(invite) {
93
76
  if (!invite) {
94
77
  return "not_found";
95
78
  }
@@ -103,7 +86,7 @@ function createService({
103
86
  return "pending";
104
87
  }
105
88
 
106
- function mapResolvedInvite(invite = null, token = "") {
89
+ function mapResolvedInvite(invite, token) {
107
90
  const status = resolvePublicInviteStatus(invite);
108
91
  if (!invite) {
109
92
  return {
@@ -141,7 +124,7 @@ function createService({
141
124
  };
142
125
  }
143
126
 
144
- function mapPendingInvite(invite = {}) {
127
+ function mapPendingInvite(invite) {
145
128
  const id = normalizeRecordId(invite.id, { fallback: null });
146
129
  const workspaceId = normalizeRecordId(invite.workspaceId, { fallback: null });
147
130
  const tokenHash = normalizeText(invite.tokenHash);
@@ -175,7 +158,7 @@ function createService({
175
158
  });
176
159
  }
177
160
 
178
- if (normalizeLowerText(invite.email) !== normalizeLowerText(actor?.email)) {
161
+ if (normalizeLowerText(invite.email) !== normalizeLowerText(actor.email)) {
179
162
  throw new AppError(403, "Invitation email does not match authenticated user.", {
180
163
  code: "workspaces.invite.emailMismatchUser"
181
164
  });
@@ -189,26 +172,34 @@ function createService({
189
172
 
190
173
  async function listPendingInvitesForUser(user, options = {}) {
191
174
  const actor = requireAuthenticatedInviteUser(user);
192
- const actorEmail = normalizeLowerText(actor?.email);
175
+ const actorEmail = normalizeLowerText(actor.email);
193
176
  if (!actorEmail) {
194
177
  return [];
195
178
  }
196
179
 
197
180
  const invites = await workspaceInvitesRepository.listPendingByEmail(actorEmail, options);
198
- return invites.map((invite) => mapPendingInvite(invite)).filter(Boolean);
181
+ return invites.map(mapPendingInvite).filter(Boolean);
199
182
  }
200
183
 
201
- async function resolveInviteByToken(token, options = {}) {
184
+ // The bootstrap reads the pending invitations as they are; the action answers
185
+ // with them as JSON:API data.
186
+ async function listPendingInvitesResponse(user, options = {}) {
187
+ return returnJsonApiData({ pendingInvites: await listPendingInvitesForUser(user, options) });
188
+ }
189
+
190
+ async function findInviteByToken(token, options = {}) {
202
191
  const tokenHash = requireInviteTokenHash(token);
203
- const invite = typeof workspaceInvitesRepository.findByTokenHashWithWorkspace === "function"
204
- ? await workspaceInvitesRepository.findByTokenHashWithWorkspace(tokenHash, options)
205
- : await workspaceInvitesRepository.findPendingByTokenHash(tokenHash, options);
192
+ const invite = await workspaceInvitesRepository.findByTokenHashWithWorkspace(tokenHash, options);
206
193
 
207
194
  return mapResolvedInvite(invite, token);
208
195
  }
209
196
 
210
- async function resolveInviteContextForAuth({ token, email } = {}, options = {}) {
211
- const resolved = await resolveInviteByToken(token, options);
197
+ async function resolveInviteByToken(token, options = {}) {
198
+ return returnJsonApiData(await findInviteByToken(token, options));
199
+ }
200
+
201
+ async function resolveInviteContextForAuth({ token, email }, options = {}) {
202
+ const resolved = await findInviteByToken(token, options);
212
203
  if (resolved.status !== "pending") {
213
204
  throw new AppError(409, "Invitation is not available.", { code: "workspaces.invite.notAvailable" });
214
205
  }
@@ -229,15 +220,15 @@ function createService({
229
220
  };
230
221
  }
231
222
 
232
- function requireWorkspaceIdFromInvite(invite, methodName = "workspacePendingInvitationsService") {
233
- const workspaceId = normalizeRecordId(invite?.workspaceId, { fallback: null });
223
+ function requireWorkspaceIdFromInvite(invite, methodName) {
224
+ const workspaceId = normalizeRecordId(invite.workspaceId, { fallback: null });
234
225
  if (!workspaceId) {
235
226
  throw new Error(`${methodName} expected invite workspace id.`);
236
227
  }
237
228
  return workspaceId;
238
229
  }
239
230
 
240
- async function resolveInviteActionInput(user, token, options = {}, methodName = "workspacePendingInvitationsService") {
231
+ async function resolveInviteActionInput(user, token, options, methodName) {
241
232
  const resolvedInvite = await requirePendingInviteForUserByToken(user, token, options);
242
233
  if (resolvedInvite.invite.expiresAt && new Date(resolvedInvite.invite.expiresAt).getTime() < Date.now()) {
243
234
  await workspaceInvitesRepository.revokeById(resolvedInvite.invite.id, options);
@@ -250,7 +241,7 @@ function createService({
250
241
  };
251
242
  }
252
243
 
253
- async function acceptInviteByToken({ user, token } = {}, options = {}) {
244
+ async function acceptInviteByToken({ user, token }, options = {}) {
254
245
  return withInviteTransaction(user, token, options, async (transactionOptions) => {
255
246
  const resolved = await resolveInviteActionInput(
256
247
  user,
@@ -268,19 +259,17 @@ function createService({
268
259
  { roleSid: resolvedInvite.invite.roleSid, status: "active" },
269
260
  transactionOptions
270
261
  );
271
- if (acceptanceParticipant) {
272
- // Repository locking options stay internal to this service.
273
- await acceptanceParticipant({
274
- invite: Object.freeze({ ...resolvedInvite.invite }),
275
- user: Object.freeze({ ...resolvedInvite.user })
276
- }, Object.freeze({ ...options, trx: transactionOptions.trx }));
277
- }
262
+ // Repository locking options stay internal to this service.
263
+ await acceptanceParticipant({
264
+ invite: Object.freeze({ ...resolvedInvite.invite }),
265
+ user: Object.freeze({ ...resolvedInvite.user })
266
+ }, Object.freeze({ ...options, trx: transactionOptions.trx }));
278
267
  await workspaceInvitesRepository.markAcceptedById(resolvedInvite.invite.id, transactionOptions);
279
268
  return { decision: "accepted", workspaceId };
280
269
  });
281
270
  }
282
271
 
283
- async function refuseInviteByToken({ user, token } = {}, options = {}) {
272
+ async function refuseInviteByToken({ user, token }, options = {}) {
284
273
  return withInviteTransaction(user, token, options, async (transactionOptions) => {
285
274
  const resolved = await resolveInviteActionInput(
286
275
  user,
@@ -297,13 +286,21 @@ function createService({
297
286
  });
298
287
  }
299
288
 
289
+ // Accepts the invitation a token names when the user decided "accept", and refuses it otherwise.
290
+ async function redeemInviteByToken(user, { token, decision }, options = {}) {
291
+ return returnJsonApiData(decision === "accept"
292
+ ? await acceptInviteByToken({ user, token }, options)
293
+ : await refuseInviteByToken({ user, token }, options));
294
+ }
295
+
300
296
  return Object.freeze({
301
- registerAcceptanceParticipant,
302
297
  resolveInviteByToken,
303
298
  resolveInviteContextForAuth,
304
299
  listPendingInvitesForUser,
300
+ listPendingInvitesResponse,
305
301
  acceptInviteByToken,
306
- refuseInviteByToken
302
+ refuseInviteByToken,
303
+ redeemInviteByToken
307
304
  });
308
305
  }
309
306
 
@@ -1,5 +1,4 @@
1
1
  import { composeSchemaDefinitions } from "@jskit-ai/kernel/shared/validators";
2
- import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
3
2
  import { workspaceSettingsResource } from "../../shared/resources/workspaceSettingsResource.js";
4
3
  import { workspaceSlugParamsValidator } from "../common/validators/routeParamsValidator.js";
5
4
  import { createWorkspaceEntityAndBootstrapEvents } from "../common/support/realtimeServiceEvents.js";
@@ -19,79 +18,60 @@ const workspaceSettingsUpdateInputValidator = composeSchemaDefinitions([
19
18
  context: "workspaceSettingsActions.workspaceSettingsUpdateInputValidator"
20
19
  });
21
20
 
22
- const workspaceSettingsActionSpecifications = Object.freeze([
23
- {
24
- id: "workspace.settings.read",
25
- version: 1,
26
- kind: "query",
27
- channels: ["api", "automation", "internal"],
28
- surfaces: ["*"],
29
- permission: {
30
- require: "any",
31
- permissions: ["workspace.settings.view", "workspace.settings.update"]
32
- },
33
- input: workspaceSlugParamsValidator,
34
- output: null,
35
- extensions: {
36
- assistant: {
37
- description: "Read the active workspace appearance and invitation settings.",
38
- output: workspaceSettingsResource.operations.view.output,
39
- transformResult: (result) => result.value
40
- }
41
- },
42
- idempotency: "none",
43
- async run(workspaceSettingsService, input, context) {
44
- const response = await workspaceSettingsService.getWorkspaceSettings(resolveWorkspace(context, input), {
45
- context
46
- });
47
-
48
- return returnJsonApiData(response);
49
- }
50
- },
51
- {
52
- id: "workspace.settings.update",
53
- version: 1,
54
- kind: "command",
55
- channels: ["api", "assistant_tool", "automation", "internal"],
56
- surfaces: ["*"],
57
- permission: {
58
- require: "all",
59
- permissions: ["workspace.settings.update"]
60
- },
61
- input: workspaceSettingsUpdateInputValidator,
62
- output: null,
63
- extensions: {
64
- assistant: {
65
- description: "Update the active workspace appearance and invitation settings.",
66
- output: workspaceSettingsResource.operations.patch.output,
67
- transformResult: (result) => result.value
68
- }
69
- },
70
- idempotency: "optional",
71
- events: WORKSPACE_SETTINGS_CHANGED_EVENTS,
72
- async run(workspaceSettingsService, input, context) {
73
- const { workspaceSlug, ...patch } = input;
74
- const response = await workspaceSettingsService.updateWorkspaceSettings(
21
+ /** @param {{ workspaceSettingsService: import("../types.js").WorkspaceSettingsService }} dependencies */
22
+ function createWorkspaceSettingsActions({ workspaceSettingsService }) {
23
+ return Object.freeze([
24
+ Object.freeze({
25
+ id: "workspace.settings.read",
26
+ version: 1,
27
+ kind: "query",
28
+ channels: ["api", "automation", "internal"],
29
+ surfaces: ["*"],
30
+ permission: {
31
+ require: "any",
32
+ permissions: ["workspace.settings.view", "workspace.settings.update"]
33
+ },
34
+ input: workspaceSlugParamsValidator,
35
+ output: null,
36
+ extensions: {
37
+ assistant: {
38
+ description: "Read the active workspace appearance and invitation settings.",
39
+ output: workspaceSettingsResource.operations.view.output,
40
+ transformResult: (result) => result.value
41
+ }
42
+ },
43
+ execute: (input, context) => workspaceSettingsService.getWorkspaceSettings(
75
44
  resolveWorkspace(context, input),
76
- patch,
77
- {
78
- context
45
+ { context }
46
+ )
47
+ }),
48
+ Object.freeze({
49
+ id: "workspace.settings.update",
50
+ version: 1,
51
+ kind: "command",
52
+ channels: ["api", "assistant_tool", "automation", "internal"],
53
+ surfaces: ["*"],
54
+ permission: {
55
+ require: "all",
56
+ permissions: ["workspace.settings.update"]
57
+ },
58
+ input: workspaceSettingsUpdateInputValidator,
59
+ output: null,
60
+ extensions: {
61
+ assistant: {
62
+ description: "Update the active workspace appearance and invitation settings.",
63
+ output: workspaceSettingsResource.operations.patch.output,
64
+ transformResult: (result) => result.value
79
65
  }
80
- );
81
-
82
- return returnJsonApiData(response);
83
- }
84
- }
85
- ]);
86
-
87
- function buildWorkspaceSettingsActions({ workspaceSettingsService } = {}) {
88
- if (!workspaceSettingsService) throw new TypeError("buildWorkspaceSettingsActions requires workspaceSettingsService.");
89
- return workspaceSettingsActionSpecifications.map(({ run, ...definition }) => Object.freeze({
90
- ...definition,
91
- execute(input, context) {
92
- return run(workspaceSettingsService, input, context);
93
- }
94
- }));
66
+ },
67
+ events: WORKSPACE_SETTINGS_CHANGED_EVENTS,
68
+ execute: (input, context) => workspaceSettingsService.updateWorkspaceSettings(
69
+ resolveWorkspace(context, input),
70
+ input,
71
+ { context }
72
+ )
73
+ })
74
+ ]);
95
75
  }
96
76
 
97
- export { workspaceSettingsActionSpecifications, buildWorkspaceSettingsActions };
77
+ export { createWorkspaceSettingsActions };
@@ -1,17 +1,14 @@
1
- import {
2
- normalizeRecordId,
3
- isDuplicateEntryError,
4
- retryDeadlocks
5
- } from "../common/repositories/repositoryUtils.js";
1
+ import { isLostDuplicateInsertRace, resolveTransaction, retryDeadlocks } from "@jskit-ai/database-runtime/shared";
6
2
  import {
7
3
  createJsonRestContext,
8
4
  extractJsonRestCollectionRows
9
5
  } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
10
6
  import { pickOwnProperties } from "@jskit-ai/kernel/shared/support";
7
+ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
11
8
  import { WORKSPACE_SETTINGS_FIELD_KEYS } from "../../shared/resources/workspaceSettingsResource.js";
12
9
  import { resolveWorkspaceThemePalettes } from "../../shared/settings.js";
13
10
 
14
- function createDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvitesEnabled, defaultColorSettings = {}) {
11
+ function buildDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvitesEnabled, defaultColorSettings) {
15
12
  const palettes = resolveWorkspaceThemePalettes(defaultColorSettings);
16
13
 
17
14
  return {
@@ -28,10 +25,13 @@ function createDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvites
28
25
  };
29
26
  }
30
27
 
31
- function createRepository({ api, defaultInvitesEnabled = true, defaultColorSettings = {} } = {}) {
32
- if (!api?.resources?.workspaceSettings) {
33
- throw new TypeError("workspaceSettingsRepository requires json-rest-api workspaceSettings resource.");
34
- }
28
+ /**
29
+ * @param {object} dependencies
30
+ * @param {import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi} dependencies.api
31
+ * @param {boolean} dependencies.defaultInvitesEnabled
32
+ * @param {object} dependencies.defaultColorSettings The configured colors that replace the theme's.
33
+ */
34
+ function createRepository({ api, defaultInvitesEnabled, defaultColorSettings }) {
35
35
  const withTransaction = (work) => retryDeadlocks(() => api.transaction(work));
36
36
 
37
37
  async function queryFirst(filters = {}, options = {}) {
@@ -41,10 +41,10 @@ function createRepository({ api, defaultInvitesEnabled = true, defaultColorSetti
41
41
  queryParams: {
42
42
  filters
43
43
  },
44
- transaction: options?.trx || null,
44
+ transaction: resolveTransaction(options),
45
45
  format: "plain"
46
46
  },
47
- createJsonRestContext(options?.context || null)
47
+ createJsonRestContext(options.context)
48
48
  )
49
49
  );
50
50
 
@@ -74,19 +74,19 @@ function createRepository({ api, defaultInvitesEnabled = true, defaultColorSetti
74
74
  try {
75
75
  await api.resources.workspaceSettings.post(
76
76
  {
77
- data: createDefaultWorkspaceSettingsCreatePayload(
77
+ data: buildDefaultWorkspaceSettingsCreatePayload(
78
78
  normalizedWorkspaceId,
79
- defaultInvitesEnabled === true,
79
+ defaultInvitesEnabled,
80
80
  defaultColorSettings
81
81
  ),
82
82
  format: "plain",
83
83
  returning: "full",
84
- transaction: options?.trx || null
84
+ transaction: resolveTransaction(options)
85
85
  },
86
- createJsonRestContext(options?.context || null)
86
+ createJsonRestContext(options.context)
87
87
  );
88
88
  } catch (error) {
89
- if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
89
+ if (!isLostDuplicateInsertRace(error, options)) {
90
90
  throw error;
91
91
  }
92
92
  }
@@ -94,7 +94,7 @@ function createRepository({ api, defaultInvitesEnabled = true, defaultColorSetti
94
94
  return findByWorkspaceId(normalizedWorkspaceId, options);
95
95
  }
96
96
 
97
- async function updateSettingsByWorkspaceId(workspaceId, patch = {}, options = {}) {
97
+ async function updateSettingsByWorkspaceId(workspaceId, patch, options = {}) {
98
98
  const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
99
99
  if (!normalizedWorkspaceId) {
100
100
  throw new TypeError("workspaceSettingsRepository.updateSettingsByWorkspaceId requires a valid workspace id.");
@@ -117,9 +117,9 @@ function createRepository({ api, defaultInvitesEnabled = true, defaultColorSetti
117
117
  },
118
118
  format: "plain",
119
119
  returning: "full",
120
- transaction: options?.trx || null
120
+ transaction: resolveTransaction(options)
121
121
  },
122
- createJsonRestContext(options?.context || null)
122
+ createJsonRestContext(options.context)
123
123
  );
124
124
 
125
125
  return findByWorkspaceId(normalizedWorkspaceId, options);
@@ -1,28 +1,16 @@
1
1
  import { createJsonApiResourceRouteContract } from "@jskit-ai/http-runtime/shared/validators/jsonApiRouteTransport";
2
2
  import { WORKSPACE_SETTINGS_TRANSPORT } from "../../shared/jsonApiTransports.js";
3
3
  import { workspaceSettingsResource } from "../../shared/resources/workspaceSettingsResource.js";
4
- import { resolveWorkspaceRoutePath } from "../common/support/workspaceRoutePaths.js";
4
+ import { resolveWorkspaceRecordId } from "../common/support/workspaceRecordId.js";
5
+ import { resolveWorkspaceRoutePath } from "../support/workspaceRoutePaths.js";
5
6
  import { workspaceSlugParamsValidator } from "../common/validators/routeParamsValidator.js";
6
7
  import { resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig } from "../support/workspaceActionSurfaces.js";
7
8
 
8
- function resolveWorkspaceSettingsRecordId(record = {}, context = {}) {
9
- const workspaceId = record?.workspace?.id;
10
- if (workspaceId != null && String(workspaceId).trim()) {
11
- return workspaceId;
12
- }
13
-
14
- const workspaceSlug = context?.request?.params?.workspaceSlug;
15
- if (workspaceSlug != null && String(workspaceSlug).trim()) {
16
- return workspaceSlug;
17
- }
18
-
19
- throw new Error("Workspace settings JSON:API response requires workspace id.");
20
- }
21
-
22
- function registerWorkspaceSettingsRoutes(router, { config = {} } = {}) {
23
- if (!router || typeof router.register !== "function") {
24
- throw new TypeError("registerWorkspaceSettingsRoutes requires router.register().");
25
- }
9
+ /**
10
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeRouter} router
11
+ * @param {{ config: import("@jskit-ai/kernel/server/types").RuntimeConfig }} options
12
+ */
13
+ function registerWorkspaceSettingsRoutes(router, { config }) {
26
14
  const workspaceRouteSurfaceId = resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig(config);
27
15
 
28
16
  router.register(
@@ -41,7 +29,7 @@ function registerWorkspaceSettingsRoutes(router, { config = {} } = {}) {
41
29
  ...WORKSPACE_SETTINGS_TRANSPORT,
42
30
  output: workspaceSettingsResource.operations.view.output,
43
31
  outputKind: "record",
44
- getRecordId: resolveWorkspaceSettingsRecordId
32
+ getRecordId: resolveWorkspaceRecordId
45
33
  })
46
34
  },
47
35
  async function (request, reply) {
@@ -72,7 +60,7 @@ function registerWorkspaceSettingsRoutes(router, { config = {} } = {}) {
72
60
  body: workspaceSettingsResource.operations.patch.body,
73
61
  output: workspaceSettingsResource.operations.patch.output,
74
62
  outputKind: "record",
75
- getRecordId: resolveWorkspaceSettingsRecordId,
63
+ getRecordId: resolveWorkspaceRecordId,
76
64
  includeValidation400: true
77
65
  })
78
66
  },
@@ -1,23 +1,17 @@
1
1
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
2
- import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
3
2
  import { pickOwnProperties } from "@jskit-ai/kernel/shared/support";
4
- import {
5
- WORKSPACE_SETTINGS_FIELD_KEYS
6
- } from "../../shared/resources/workspaceSettingsResource.js";
7
- import { createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
8
-
9
- function createService({
10
- workspaceSettingsRepository,
11
- workspaceInvitationsEnabled = true,
12
- roleCatalog = null
13
- } = {}) {
14
- if (!workspaceSettingsRepository) {
15
- throw new Error("workspaceSettingsService requires workspaceSettingsRepository.");
16
- }
17
- const resolvedRoleCatalog = roleCatalog && typeof roleCatalog === "object" ? roleCatalog : createWorkspaceRoleCatalog();
18
- const invitesAvailable = workspaceInvitationsEnabled === true;
19
-
20
- async function getWorkspaceSettings(workspace, options = {}) {
3
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
4
+ import { WORKSPACE_SETTINGS_FIELD_KEYS } from "../../shared/resources/workspaceSettingsResource.js";
5
+ import { cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
6
+
7
+ /**
8
+ * @param {object} dependencies
9
+ * @param {import("../types.js").WorkspaceSettingsRepository} dependencies.workspaceSettingsRepository
10
+ * @param {boolean} dependencies.workspaceInvitationsEnabled
11
+ * @param {import("../types.js").WorkspaceRoleCatalog} dependencies.roleCatalog
12
+ */
13
+ function createService({ workspaceSettingsRepository, workspaceInvitationsEnabled, roleCatalog }) {
14
+ async function readWorkspaceSettings(workspace, options = {}) {
21
15
  const settingsRecord = await workspaceSettingsRepository.ensureForWorkspaceId(workspace.id, {
22
16
  ...options,
23
17
  workspace
@@ -26,10 +20,10 @@ function createService({
26
20
  for (const fieldKey of WORKSPACE_SETTINGS_FIELD_KEYS) {
27
21
  settings[fieldKey] = settingsRecord[fieldKey];
28
22
  }
29
- const invitesEnabled = invitesAvailable && settings.invitesEnabled !== false;
23
+ const invitesEnabled = workspaceInvitationsEnabled && settings.invitesEnabled !== false;
30
24
  settings.invitesEnabled = invitesEnabled;
31
- settings.invitesAvailable = invitesAvailable;
32
- settings.invitesEffective = invitesAvailable && invitesEnabled;
25
+ settings.invitesAvailable = workspaceInvitationsEnabled;
26
+ settings.invitesEffective = invitesEnabled;
33
27
 
34
28
  return {
35
29
  workspace: {
@@ -38,13 +32,17 @@ function createService({
38
32
  ownerUserId: normalizeRecordId(workspace.ownerUserId, { fallback: "" })
39
33
  },
40
34
  settings,
41
- roleCatalog: cloneWorkspaceRoleCatalog(resolvedRoleCatalog)
35
+ roleCatalog: cloneWorkspaceRoleCatalog(roleCatalog)
42
36
  };
43
37
  }
44
38
 
45
- async function updateWorkspaceSettings(workspace, payload = {}, options = {}) {
46
- const source = normalizeObjectInput(payload);
47
- const settingsPatch = pickOwnProperties(source, WORKSPACE_SETTINGS_FIELD_KEYS);
39
+ async function getWorkspaceSettings(workspace, options = {}) {
40
+ return returnJsonApiData(await readWorkspaceSettings(workspace, options));
41
+ }
42
+
43
+ /** The action's validated input; only the settings fields go to the repository. */
44
+ async function updateWorkspaceSettings(workspace, input, options = {}) {
45
+ const settingsPatch = pickOwnProperties(input, WORKSPACE_SETTINGS_FIELD_KEYS);
48
46
 
49
47
  if (Object.keys(settingsPatch).length > 0) {
50
48
  await workspaceSettingsRepository.updateSettingsByWorkspaceId(workspace.id, settingsPatch, {
@@ -53,7 +51,7 @@ function createService({
53
51
  });
54
52
  }
55
53
 
56
- return getWorkspaceSettings(workspace, options);
54
+ return returnJsonApiData(await readWorkspaceSettings(workspace, options));
57
55
  }
58
56
 
59
57
  return Object.freeze({