@jskit-ai/users-core 0.1.4

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 (148) hide show
  1. package/package.descriptor.mjs +464 -0
  2. package/package.json +35 -0
  3. package/src/server/UsersCoreServiceProvider.js +74 -0
  4. package/src/server/accountNotifications/accountNotificationsActions.js +39 -0
  5. package/src/server/accountNotifications/accountNotificationsService.js +41 -0
  6. package/src/server/accountNotifications/bootAccountNotificationsRoutes.js +41 -0
  7. package/src/server/accountNotifications/registerAccountNotifications.js +39 -0
  8. package/src/server/accountPreferences/accountPreferencesActions.js +39 -0
  9. package/src/server/accountPreferences/accountPreferencesService.js +41 -0
  10. package/src/server/accountPreferences/bootAccountPreferencesRoutes.js +41 -0
  11. package/src/server/accountPreferences/registerAccountPreferences.js +39 -0
  12. package/src/server/accountProfile/accountProfileActions.js +137 -0
  13. package/src/server/accountProfile/accountProfileService.js +124 -0
  14. package/src/server/accountProfile/avatarService.js +141 -0
  15. package/src/server/accountProfile/avatarStorageService.js +132 -0
  16. package/src/server/accountProfile/bootAccountProfileRoutes.js +166 -0
  17. package/src/server/accountProfile/registerAccountProfile.js +62 -0
  18. package/src/server/accountProfile/registerAvatarMultipartSupport.js +43 -0
  19. package/src/server/accountSecurity/accountSecurityActions.js +144 -0
  20. package/src/server/accountSecurity/accountSecurityService.js +103 -0
  21. package/src/server/accountSecurity/bootAccountSecurityRoutes.js +183 -0
  22. package/src/server/accountSecurity/registerAccountSecurity.js +31 -0
  23. package/src/server/common/README.md +21 -0
  24. package/src/server/common/contributors/README.md +11 -0
  25. package/src/server/common/contributors/workspaceActionContextContributor.js +79 -0
  26. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +34 -0
  27. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +79 -0
  28. package/src/server/common/diTokens.js +21 -0
  29. package/src/server/common/formatters/README.md +11 -0
  30. package/src/server/common/formatters/accountAvatarFormatter.js +42 -0
  31. package/src/server/common/formatters/accountSecurityStatusFormatter.js +71 -0
  32. package/src/server/common/formatters/accountSettingsResponseFormatter.js +62 -0
  33. package/src/server/common/formatters/workspaceFormatter.js +46 -0
  34. package/src/server/common/registerCommonRepositories.js +45 -0
  35. package/src/server/common/registerSharedApi.js +9 -0
  36. package/src/server/common/repositories/README.md +24 -0
  37. package/src/server/common/repositories/repositoryUtils.js +50 -0
  38. package/src/server/common/repositories/userProfilesRepository.js +251 -0
  39. package/src/server/common/repositories/userSettingsRepository.js +179 -0
  40. package/src/server/common/repositories/workspaceInvitesRepository.js +172 -0
  41. package/src/server/common/repositories/workspaceMembershipsRepository.js +157 -0
  42. package/src/server/common/repositories/workspacesRepository.js +183 -0
  43. package/src/server/common/routes/README.md +11 -0
  44. package/src/server/common/services/README.md +12 -0
  45. package/src/server/common/services/accountContextService.js +31 -0
  46. package/src/server/common/services/authProfileSyncService.js +128 -0
  47. package/src/server/common/services/workspaceContextService.js +270 -0
  48. package/src/server/common/support/deepFreeze.js +17 -0
  49. package/src/server/common/support/realtimeServiceEvents.js +94 -0
  50. package/src/server/common/support/resolveActionUser.js +11 -0
  51. package/src/server/common/support/workspaceRoutePaths.js +17 -0
  52. package/src/server/common/validators/README.md +11 -0
  53. package/src/server/common/validators/authenticatedUserValidator.js +42 -0
  54. package/src/server/common/validators/routeParamsValidator.js +62 -0
  55. package/src/server/consoleSettings/bootConsoleSettingsRoutes.js +64 -0
  56. package/src/server/consoleSettings/consoleService.js +36 -0
  57. package/src/server/consoleSettings/consoleSettingsActions.js +55 -0
  58. package/src/server/consoleSettings/consoleSettingsRepository.js +111 -0
  59. package/src/server/consoleSettings/consoleSettingsService.js +40 -0
  60. package/src/server/consoleSettings/registerConsoleSettings.js +57 -0
  61. package/src/server/registerWorkspaceBootstrap.js +36 -0
  62. package/src/server/registerWorkspaceCore.js +95 -0
  63. package/src/server/support/resolveWorkspace.js +16 -0
  64. package/src/server/support/workspaceActionSurfaces.js +135 -0
  65. package/src/server/support/workspaceInvitationsPolicy.js +45 -0
  66. package/src/server/support/workspaceRouteInput.js +22 -0
  67. package/src/server/workspaceBootstrapContributor.js +401 -0
  68. package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +73 -0
  69. package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +19 -0
  70. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +65 -0
  71. package/src/server/workspaceMembers/bootWorkspaceMembers.js +238 -0
  72. package/src/server/workspaceMembers/registerWorkspaceMembers.js +112 -0
  73. package/src/server/workspaceMembers/workspaceMembersActions.js +186 -0
  74. package/src/server/workspaceMembers/workspaceMembersService.js +210 -0
  75. package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +63 -0
  76. package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +128 -0
  77. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +74 -0
  78. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +137 -0
  79. package/src/server/workspaceSettings/bootWorkspaceSettings.js +77 -0
  80. package/src/server/workspaceSettings/registerWorkspaceSettings.js +67 -0
  81. package/src/server/workspaceSettings/workspaceSettingsActions.js +72 -0
  82. package/src/server/workspaceSettings/workspaceSettingsRepository.js +135 -0
  83. package/src/server/workspaceSettings/workspaceSettingsService.js +65 -0
  84. package/src/shared/events/usersEvents.js +19 -0
  85. package/src/shared/index.js +91 -0
  86. package/src/shared/operationMessages.js +16 -0
  87. package/src/shared/resources/consoleSettingsFields.js +55 -0
  88. package/src/shared/resources/consoleSettingsResource.js +139 -0
  89. package/src/shared/resources/resolveGlobalArrayRegistry.js +6 -0
  90. package/src/shared/resources/userProfileResource.js +148 -0
  91. package/src/shared/resources/userSettingsFields.js +71 -0
  92. package/src/shared/resources/userSettingsResource.js +416 -0
  93. package/src/shared/resources/workspaceMembersResource.js +352 -0
  94. package/src/shared/resources/workspacePendingInvitationsResource.js +87 -0
  95. package/src/shared/resources/workspaceResource.js +149 -0
  96. package/src/shared/resources/workspaceSettingsFields.js +60 -0
  97. package/src/shared/resources/workspaceSettingsResource.js +178 -0
  98. package/src/shared/roles.js +136 -0
  99. package/src/shared/settings.js +31 -0
  100. package/src/shared/support/usersApiPaths.js +34 -0
  101. package/src/shared/support/usersVisibility.js +45 -0
  102. package/src/shared/support/workspacePathModel.js +145 -0
  103. package/src/shared/tenancyMode.js +35 -0
  104. package/src/shared/tenancyProfile.js +73 -0
  105. package/templates/config/workspaceRoles.js +30 -0
  106. package/templates/migrations/users_core_console_owner.cjs +39 -0
  107. package/templates/migrations/users_core_initial.cjs +118 -0
  108. package/templates/migrations/users_core_profile_username.cjs +98 -0
  109. package/templates/packages/main/src/shared/resources/consoleSettingsFields.js +11 -0
  110. package/templates/packages/main/src/shared/resources/userSettingsFields.js +138 -0
  111. package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +105 -0
  112. package/test/authProfileSyncService.test.js +119 -0
  113. package/test/avatarService.test.js +114 -0
  114. package/test/avatarStorageService.test.js +61 -0
  115. package/test/consoleService.test.js +57 -0
  116. package/test/consoleSettingsService.test.js +86 -0
  117. package/test/exportsContract.test.js +38 -0
  118. package/test/registerAvatarMultipartSupport.test.js +64 -0
  119. package/test/registerServiceRealtimeEvents.test.js +160 -0
  120. package/test/registerWorkspaceDirectory.test.js +26 -0
  121. package/test/registerWorkspaceSettings.test.js +44 -0
  122. package/test/resourcesCanonical.test.js +90 -0
  123. package/test/roles.test.js +74 -0
  124. package/test/settingsFieldRegistriesSingleton.test.js +24 -0
  125. package/test/tenancyProfile.test.js +67 -0
  126. package/test/userSettingsResource.test.js +31 -0
  127. package/test/usersApiPaths.test.js +31 -0
  128. package/test/usersRouteRequestInputValidator.test.js +556 -0
  129. package/test/usersRouteResources.test.js +113 -0
  130. package/test/usersRouteValidators.test.js +49 -0
  131. package/test/usersVisibility.test.js +22 -0
  132. package/test/workspaceActionContextContributor.test.js +251 -0
  133. package/test/workspaceActionSurfaces.test.js +105 -0
  134. package/test/workspaceAuthPolicyContextResolver.test.js +119 -0
  135. package/test/workspaceBootstrapContributor.test.js +466 -0
  136. package/test/workspaceInvitationsPolicy.test.js +71 -0
  137. package/test/workspaceInvitesRepository.test.js +111 -0
  138. package/test/workspaceMembersService.test.js +400 -0
  139. package/test/workspacePathModel.test.js +93 -0
  140. package/test/workspacePendingInvitationsResource.test.js +38 -0
  141. package/test/workspacePendingInvitationsService.test.js +151 -0
  142. package/test/workspaceRouteVisibilityResolver.test.js +83 -0
  143. package/test/workspaceService.test.js +480 -0
  144. package/test/workspaceSettingsActions.test.js +42 -0
  145. package/test/workspaceSettingsRepository.test.js +156 -0
  146. package/test/workspaceSettingsResource.test.js +156 -0
  147. package/test/workspaceSettingsService.test.js +120 -0
  148. package/test-support/registerDefaultSettingsFields.js +3 -0
@@ -0,0 +1,46 @@
1
+ import { coerceWorkspaceColor } from "../../../shared/settings.js";
2
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
3
+
4
+ function mapWorkspaceSummary(workspace, membership) {
5
+ return {
6
+ id: Number(workspace.id),
7
+ slug: normalizeText(workspace.slug),
8
+ name: normalizeText(workspace.name),
9
+ color: coerceWorkspaceColor(workspace.color),
10
+ avatarUrl: normalizeText(workspace.avatarUrl),
11
+ roleId: normalizeLowerText(membership?.roleId || "member") || "member",
12
+ isAccessible: normalizeLowerText(membership?.status || "active") === "active"
13
+ };
14
+ }
15
+
16
+ function mapWorkspaceSettingsPublic(workspaceSettings, { workspaceInvitationsEnabled = true } = {}) {
17
+ const source = workspaceSettings && typeof workspaceSettings === "object" ? workspaceSettings : {};
18
+ const invitesAvailable = workspaceInvitationsEnabled === true;
19
+ const invitesEnabled = invitesAvailable && source.invitesEnabled !== false;
20
+ return {
21
+ name: normalizeText(source.name),
22
+ color: coerceWorkspaceColor(source.color),
23
+ avatarUrl: normalizeText(source.avatarUrl),
24
+ invitesEnabled,
25
+ invitesAvailable,
26
+ invitesEffective: invitesAvailable && invitesEnabled
27
+ };
28
+ }
29
+
30
+ function mapMembershipSummary(membership, workspace) {
31
+ if (!membership) {
32
+ return null;
33
+ }
34
+
35
+ return {
36
+ workspaceId: Number(workspace?.id || membership.workspaceId),
37
+ roleId: normalizeLowerText(membership.roleId || "member") || "member",
38
+ status: normalizeLowerText(membership.status || "active") || "active"
39
+ };
40
+ }
41
+
42
+ export {
43
+ mapMembershipSummary,
44
+ mapWorkspaceSettingsPublic,
45
+ mapWorkspaceSummary
46
+ };
@@ -0,0 +1,45 @@
1
+ import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
2
+ import { createRepository as createUserProfilesRepository } from "./repositories/userProfilesRepository.js";
3
+ import { createRepository as createUserSettingsRepository } from "./repositories/userSettingsRepository.js";
4
+ import { createRepository as createWorkspacesRepository } from "./repositories/workspacesRepository.js";
5
+ import { createRepository as createWorkspaceMembershipsRepository } from "./repositories/workspaceMembershipsRepository.js";
6
+ import { createRepository as createWorkspaceInvitesRepository } from "./repositories/workspaceInvitesRepository.js";
7
+ import { createRepository as createConsoleSettingsRepository } from "../consoleSettings/consoleSettingsRepository.js";
8
+
9
+ function registerCommonRepositories(app) {
10
+ if (!app || typeof app.singleton !== "function") {
11
+ throw new Error("registerCommonRepositories requires application singleton().");
12
+ }
13
+
14
+ app.singleton("userProfilesRepository", (scope) => {
15
+ const knex = scope.make(KERNEL_TOKENS.Knex);
16
+ return createUserProfilesRepository(knex);
17
+ });
18
+
19
+ app.singleton("userSettingsRepository", (scope) => {
20
+ const knex = scope.make(KERNEL_TOKENS.Knex);
21
+ return createUserSettingsRepository(knex);
22
+ });
23
+
24
+ app.singleton("workspacesRepository", (scope) => {
25
+ const knex = scope.make(KERNEL_TOKENS.Knex);
26
+ return createWorkspacesRepository(knex);
27
+ });
28
+
29
+ app.singleton("workspaceMembershipsRepository", (scope) => {
30
+ const knex = scope.make(KERNEL_TOKENS.Knex);
31
+ return createWorkspaceMembershipsRepository(knex);
32
+ });
33
+
34
+ app.singleton("workspaceInvitesRepository", (scope) => {
35
+ const knex = scope.make(KERNEL_TOKENS.Knex);
36
+ return createWorkspaceInvitesRepository(knex);
37
+ });
38
+
39
+ app.singleton("consoleSettingsRepository", (scope) => {
40
+ const knex = scope.make(KERNEL_TOKENS.Knex);
41
+ return createConsoleSettingsRepository(knex);
42
+ });
43
+ }
44
+
45
+ export { registerCommonRepositories };
@@ -0,0 +1,9 @@
1
+ function registerSharedApi(app, usersCoreApi) {
2
+ if (!app || typeof app.singleton !== "function") {
3
+ throw new Error("registerSharedApi requires application singleton().");
4
+ }
5
+
6
+ app.singleton("users.core", () => usersCoreApi);
7
+ }
8
+
9
+ export { registerSharedApi };
@@ -0,0 +1,24 @@
1
+ # `repositories/`
2
+
3
+ Use this directory for persistence code shared by more than one server slice.
4
+
5
+ What belongs here:
6
+ - repository helpers that are part of persistence logic
7
+ - shared repositories used by multiple features/slices
8
+
9
+ Examples:
10
+ - SQL/date/JSON helper functions reused by several repositories
11
+ - `workspaceMembershipsRepository`
12
+ - `workspaceInvitesRepository`
13
+
14
+ Do not put these here:
15
+ - repositories used by only one feature
16
+ - business logic
17
+ - action definitions
18
+ - transport validation
19
+ - feature-specific response mapping
20
+
21
+ Rule:
22
+ - if a helper is reused by multiple repository files, it can live here
23
+ - if a repository is reused by multiple slices, keep it here
24
+ - if a repository is owned by one slice only, keep it in that slice folder
@@ -0,0 +1,50 @@
1
+ import { toInsertDateTime, toNullableDateTime, toIsoString } from "@jskit-ai/database-runtime/shared";
2
+ import { isDuplicateEntryError } from "@jskit-ai/database-runtime/shared/duplicateEntry";
3
+ import { normalizeText, normalizeLowerText } from "@jskit-ai/kernel/shared/support/normalize";
4
+
5
+ function nowDb() {
6
+ return toInsertDateTime();
7
+ }
8
+
9
+ function toNullableIso(value) {
10
+ if (!value) {
11
+ return null;
12
+ }
13
+ return toIsoString(value);
14
+ }
15
+
16
+ function uniqueSorted(values) {
17
+ return [...new Set(values)].sort((left, right) => String(left).localeCompare(String(right)));
18
+ }
19
+
20
+ function parseJson(value, fallback = {}) {
21
+ if (value == null) {
22
+ return fallback;
23
+ }
24
+ if (typeof value === "object") {
25
+ return value;
26
+ }
27
+ try {
28
+ return JSON.parse(String(value));
29
+ } catch {
30
+ return fallback;
31
+ }
32
+ }
33
+
34
+ function toDbJson(value, fallback = {}) {
35
+ const source = value && typeof value === "object" ? value : fallback;
36
+ return JSON.stringify(source);
37
+ }
38
+
39
+ export {
40
+ toNullableDateTime,
41
+ toIsoString,
42
+ isDuplicateEntryError,
43
+ normalizeText,
44
+ normalizeLowerText,
45
+ nowDb,
46
+ toNullableIso,
47
+ uniqueSorted,
48
+ parseJson,
49
+ toDbJson
50
+ };
@@ -0,0 +1,251 @@
1
+ import {
2
+ isDuplicateEntryError,
3
+ normalizeLowerText,
4
+ normalizeText,
5
+ toIsoString,
6
+ toNullableDateTime,
7
+ toNullableIso,
8
+ nowDb
9
+ } from "./repositoryUtils.js";
10
+
11
+ const USERNAME_MAX_LENGTH = 120;
12
+
13
+ function normalizeIdentity(identityLike) {
14
+ const source = identityLike && typeof identityLike === "object" ? identityLike : {};
15
+ const provider = normalizeLowerText(source.provider || source.authProvider);
16
+ const providerUserId = normalizeText(source.providerUserId || source.authProviderUserId);
17
+ if (!provider || !providerUserId) {
18
+ return null;
19
+ }
20
+ return {
21
+ provider,
22
+ providerUserId
23
+ };
24
+ }
25
+
26
+ function normalizeUsername(value) {
27
+ const normalized = normalizeLowerText(value)
28
+ .replace(/[^a-z0-9]+/g, "-")
29
+ .replace(/^-+|-+$/g, "")
30
+ .slice(0, USERNAME_MAX_LENGTH);
31
+ return normalized || "";
32
+ }
33
+
34
+ function usernameBaseFromEmail(email) {
35
+ const normalizedEmail = normalizeLowerText(email);
36
+ const emailLocalPart = normalizedEmail.includes("@") ? normalizedEmail.split("@")[0] : normalizedEmail;
37
+ const username = normalizeUsername(emailLocalPart);
38
+ return username || "user";
39
+ }
40
+
41
+ function buildUsernameCandidate(baseUsername, suffix) {
42
+ const normalizedBase = normalizeUsername(baseUsername) || "user";
43
+ if (suffix < 1) {
44
+ return normalizedBase;
45
+ }
46
+
47
+ const suffixText = `-${suffix + 1}`;
48
+ const allowedBaseLength = USERNAME_MAX_LENGTH - suffixText.length;
49
+ const trimmedBase = normalizedBase.slice(0, allowedBaseLength);
50
+ return `${trimmedBase}${suffixText}`;
51
+ }
52
+
53
+ function mapProfileRow(row) {
54
+ if (!row) {
55
+ return null;
56
+ }
57
+ return {
58
+ id: Number(row.id),
59
+ authProvider: normalizeLowerText(row.auth_provider),
60
+ authProviderUserId: normalizeText(row.auth_provider_user_id),
61
+ email: normalizeLowerText(row.email),
62
+ username: normalizeLowerText(row.username),
63
+ displayName: normalizeText(row.display_name),
64
+ avatarStorageKey: row.avatar_storage_key ? normalizeText(row.avatar_storage_key) : null,
65
+ avatarVersion: row.avatar_version == null ? null : String(row.avatar_version),
66
+ avatarUpdatedAt: toNullableIso(row.avatar_updated_at),
67
+ createdAt: toIsoString(row.created_at)
68
+ };
69
+ }
70
+
71
+ function duplicateTargetsEmail(error) {
72
+ if (!isDuplicateEntryError(error)) {
73
+ return false;
74
+ }
75
+ const message = normalizeLowerText(error?.sqlMessage || error?.message);
76
+ return message.includes("email");
77
+ }
78
+
79
+ function duplicateTargetsUsername(error) {
80
+ if (!isDuplicateEntryError(error)) {
81
+ return false;
82
+ }
83
+ const message = normalizeLowerText(error?.sqlMessage || error?.message);
84
+ return message.includes("username");
85
+ }
86
+
87
+ function createDuplicateEmailConflictError() {
88
+ const error = new Error("Email is already linked to a different profile.");
89
+ error.code = "USER_PROFILE_EMAIL_CONFLICT";
90
+ return error;
91
+ }
92
+
93
+ async function resolveUniqueUsername(client, baseUsername, { excludeUserId = 0 } = {}) {
94
+ for (let suffix = 0; suffix < 1000; suffix += 1) {
95
+ const candidate = buildUsernameCandidate(baseUsername, suffix);
96
+ const existing = await client("user_profiles").where({ username: candidate }).first();
97
+ if (!existing || Number(existing.id) === Number(excludeUserId || 0)) {
98
+ return candidate;
99
+ }
100
+ }
101
+
102
+ throw new Error("Unable to generate unique username.");
103
+ }
104
+
105
+ function createRepository(knex) {
106
+ if (typeof knex !== "function") {
107
+ throw new TypeError("userProfilesRepository requires knex.");
108
+ }
109
+
110
+ async function findById(userId, options = {}) {
111
+ const client = options?.trx || knex;
112
+ const row = await client("user_profiles").where({ id: userId }).first();
113
+ return mapProfileRow(row);
114
+ }
115
+
116
+ async function findByIdentity(identityLike, options = {}) {
117
+ const client = options?.trx || knex;
118
+ const identity = normalizeIdentity(identityLike);
119
+ if (!identity) {
120
+ return null;
121
+ }
122
+
123
+ const row = await client("user_profiles")
124
+ .where({
125
+ auth_provider: identity.provider,
126
+ auth_provider_user_id: identity.providerUserId
127
+ })
128
+ .first();
129
+ return mapProfileRow(row);
130
+ }
131
+
132
+ async function updateDisplayNameById(userId, displayName, options = {}) {
133
+ const client = options?.trx || knex;
134
+ await client("user_profiles")
135
+ .where({ id: userId })
136
+ .update({
137
+ display_name: normalizeText(displayName)
138
+ });
139
+ return findById(userId, { trx: client });
140
+ }
141
+
142
+ async function updateAvatarById(userId, avatar = {}, options = {}) {
143
+ const client = options?.trx || knex;
144
+ await client("user_profiles")
145
+ .where({ id: userId })
146
+ .update({
147
+ avatar_storage_key: avatar.avatarStorageKey || null,
148
+ avatar_version: avatar.avatarVersion == null ? null : String(avatar.avatarVersion),
149
+ avatar_updated_at: toNullableDateTime(avatar.avatarUpdatedAt) || nowDb()
150
+ });
151
+
152
+ return findById(userId, { trx: client });
153
+ }
154
+
155
+ async function clearAvatarById(userId, options = {}) {
156
+ const client = options?.trx || knex;
157
+ await client("user_profiles")
158
+ .where({ id: userId })
159
+ .update({
160
+ avatar_storage_key: null,
161
+ avatar_version: null,
162
+ avatar_updated_at: null
163
+ });
164
+ return findById(userId, { trx: client });
165
+ }
166
+
167
+ async function upsert(profileLike = {}, options = {}) {
168
+ const client = options?.trx || knex;
169
+ const identity = normalizeIdentity(profileLike);
170
+ if (!identity) {
171
+ throw new TypeError("upsert requires provider/authProvider and providerUserId/authProviderUserId.");
172
+ }
173
+
174
+ const email = normalizeLowerText(profileLike.email);
175
+ const displayName = normalizeText(profileLike.displayName);
176
+ const requestedUsername = normalizeUsername(profileLike.username);
177
+ if (!email || !displayName) {
178
+ throw new TypeError("upsert requires email and displayName.");
179
+ }
180
+
181
+ const executeUpsert = async (trx) => {
182
+ const where = {
183
+ auth_provider: identity.provider,
184
+ auth_provider_user_id: identity.providerUserId
185
+ };
186
+ const existing = await trx("user_profiles").where(where).first();
187
+
188
+ try {
189
+ if (existing) {
190
+ const existingUsername = normalizeUsername(existing.username);
191
+ const username = existingUsername || (await resolveUniqueUsername(trx, requestedUsername || usernameBaseFromEmail(email), {
192
+ excludeUserId: existing.id
193
+ }));
194
+ await trx("user_profiles").where({ id: existing.id }).update({
195
+ email,
196
+ display_name: displayName,
197
+ username
198
+ });
199
+ } else {
200
+ const username = await resolveUniqueUsername(trx, requestedUsername || usernameBaseFromEmail(email));
201
+ await trx("user_profiles").insert({
202
+ auth_provider: identity.provider,
203
+ auth_provider_user_id: identity.providerUserId,
204
+ email,
205
+ display_name: displayName,
206
+ username
207
+ });
208
+ }
209
+ } catch (error) {
210
+ if (duplicateTargetsEmail(error)) {
211
+ throw createDuplicateEmailConflictError();
212
+ }
213
+ if (duplicateTargetsUsername(error)) {
214
+ throw error;
215
+ }
216
+ if (!isDuplicateEntryError(error)) {
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ const reloaded = await trx("user_profiles").where(where).first();
222
+ return mapProfileRow(reloaded);
223
+ };
224
+
225
+ if (options?.trx) {
226
+ return executeUpsert(client);
227
+ }
228
+
229
+ return knex.transaction(executeUpsert);
230
+ }
231
+
232
+ async function withTransaction(work) {
233
+ if (typeof work !== "function") {
234
+ throw new TypeError("withTransaction requires a callback.");
235
+ }
236
+
237
+ return knex.transaction((trx) => work(trx));
238
+ }
239
+
240
+ return Object.freeze({
241
+ findById,
242
+ findByIdentity,
243
+ updateDisplayNameById,
244
+ updateAvatarById,
245
+ clearAvatarById,
246
+ upsert,
247
+ withTransaction
248
+ });
249
+ }
250
+
251
+ export { createRepository, normalizeIdentity, mapProfileRow };
@@ -0,0 +1,179 @@
1
+ import {
2
+ toIsoString,
3
+ nowDb,
4
+ isDuplicateEntryError
5
+ } from "./repositoryUtils.js";
6
+ import { DEFAULT_USER_SETTINGS } from "../../../shared/settings.js";
7
+ import {
8
+ userSettingsFields
9
+ } from "../../../shared/resources/userSettingsFields.js";
10
+
11
+ function mapRow(row) {
12
+ if (!row) {
13
+ return null;
14
+ }
15
+
16
+ const mapped = {
17
+ userId: Number(row.user_id),
18
+ lastActiveWorkspaceId: row.last_active_workspace_id == null ? null : Number(row.last_active_workspace_id),
19
+ passwordSignInEnabled: row.password_sign_in_enabled == null ? true : Boolean(row.password_sign_in_enabled),
20
+ passwordSetupRequired: row.password_setup_required == null ? false : Boolean(row.password_setup_required),
21
+ createdAt: toIsoString(row.created_at),
22
+ updatedAt: toIsoString(row.updated_at)
23
+ };
24
+
25
+ for (const field of userSettingsFields) {
26
+ const value = Object.hasOwn(row, field.dbColumn)
27
+ ? row[field.dbColumn]
28
+ : field.resolveDefault({
29
+ settings: mapped,
30
+ row
31
+ });
32
+ mapped[field.key] = field.normalizeOutput(value, {
33
+ settings: mapped,
34
+ row
35
+ });
36
+ }
37
+
38
+ return mapped;
39
+ }
40
+
41
+ function normalizeBoolean(value, fallback = false) {
42
+ if (value === undefined) {
43
+ return fallback;
44
+ }
45
+ return value === true;
46
+ }
47
+
48
+ function createInsertPayload(userId) {
49
+ const payload = {
50
+ user_id: Number(userId),
51
+ last_active_workspace_id: null,
52
+ password_sign_in_enabled: DEFAULT_USER_SETTINGS.passwordSignInEnabled,
53
+ password_setup_required: DEFAULT_USER_SETTINGS.passwordSetupRequired,
54
+ created_at: nowDb(),
55
+ updated_at: nowDb()
56
+ };
57
+
58
+ const resolvedDefaults = {};
59
+ for (const field of userSettingsFields) {
60
+ const defaultValue = field.resolveDefault({
61
+ settings: resolvedDefaults
62
+ });
63
+ payload[field.dbColumn] = field.normalizeInput(defaultValue, {
64
+ payload: resolvedDefaults,
65
+ settings: resolvedDefaults
66
+ });
67
+ resolvedDefaults[field.key] = field.normalizeOutput(defaultValue, {
68
+ settings: resolvedDefaults
69
+ });
70
+ }
71
+
72
+ return payload;
73
+ }
74
+
75
+ function createRepository(knex) {
76
+ if (typeof knex !== "function") {
77
+ throw new TypeError("userSettingsRepository requires knex.");
78
+ }
79
+
80
+ async function findByUserId(userId, options = {}) {
81
+ const client = options?.trx || knex;
82
+ const row = await client("user_settings").where({ user_id: Number(userId) }).first();
83
+ return mapRow(row);
84
+ }
85
+
86
+ async function ensureForUserId(userId, options = {}) {
87
+ const client = options?.trx || knex;
88
+ const numericUserId = Number(userId);
89
+ const existing = await findByUserId(numericUserId, { trx: client });
90
+ if (existing) {
91
+ return existing;
92
+ }
93
+
94
+ try {
95
+ await client("user_settings").insert(createInsertPayload(numericUserId));
96
+ } catch (error) {
97
+ if (!isDuplicateEntryError(error)) {
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ return findByUserId(numericUserId, { trx: client });
103
+ }
104
+
105
+ async function patchUserSettings(userId, patch = {}, options = {}) {
106
+ const client = options?.trx || knex;
107
+ const ensured = await ensureForUserId(userId, { trx: client });
108
+ const source = patch && typeof patch === "object" ? patch : {};
109
+
110
+ const dbPatch = {
111
+ updated_at: nowDb()
112
+ };
113
+
114
+ for (const field of userSettingsFields) {
115
+ if (!Object.hasOwn(source, field.key)) {
116
+ continue;
117
+ }
118
+ dbPatch[field.dbColumn] = field.normalizeInput(source[field.key], {
119
+ payload: source,
120
+ settings: ensured
121
+ });
122
+ }
123
+
124
+ if (Object.hasOwn(source, "passwordSignInEnabled")) {
125
+ dbPatch.password_sign_in_enabled = normalizeBoolean(source.passwordSignInEnabled, ensured.passwordSignInEnabled);
126
+ }
127
+ if (Object.hasOwn(source, "passwordSetupRequired")) {
128
+ dbPatch.password_setup_required = normalizeBoolean(source.passwordSetupRequired, ensured.passwordSetupRequired);
129
+ }
130
+ if (Object.hasOwn(source, "lastActiveWorkspaceId")) {
131
+ dbPatch.last_active_workspace_id = source.lastActiveWorkspaceId == null ? null : Number(source.lastActiveWorkspaceId);
132
+ }
133
+
134
+ await client("user_settings").where({ user_id: Number(userId) }).update(dbPatch);
135
+ return findByUserId(userId, { trx: client });
136
+ }
137
+
138
+ async function updatePreferences(userId, patch = {}, options = {}) {
139
+ return patchUserSettings(userId, patch, options);
140
+ }
141
+
142
+ async function updateNotifications(userId, patch = {}, options = {}) {
143
+ return patchUserSettings(userId, patch, options);
144
+ }
145
+
146
+ async function updatePasswordSignInEnabled(userId, enabled, options = {}) {
147
+ return patchUserSettings(
148
+ userId,
149
+ {
150
+ passwordSignInEnabled: enabled,
151
+ passwordSetupRequired: Object.hasOwn(options, "passwordSetupRequired")
152
+ ? options.passwordSetupRequired
153
+ : undefined
154
+ },
155
+ options
156
+ );
157
+ }
158
+
159
+ async function updatePasswordSetupRequired(userId, required, options = {}) {
160
+ return patchUserSettings(userId, { passwordSetupRequired: required }, options);
161
+ }
162
+
163
+ async function updateLastActiveWorkspaceId(userId, workspaceId, options = {}) {
164
+ return patchUserSettings(userId, { lastActiveWorkspaceId: workspaceId }, options);
165
+ }
166
+
167
+ return Object.freeze({
168
+ findByUserId,
169
+ ensureForUserId,
170
+ patchUserSettings,
171
+ updatePreferences,
172
+ updateNotifications,
173
+ updatePasswordSignInEnabled,
174
+ updatePasswordSetupRequired,
175
+ updateLastActiveWorkspaceId
176
+ });
177
+ }
178
+
179
+ export { createRepository, mapRow };