@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.
- package/package.descriptor.mjs +464 -0
- package/package.json +35 -0
- package/src/server/UsersCoreServiceProvider.js +74 -0
- package/src/server/accountNotifications/accountNotificationsActions.js +39 -0
- package/src/server/accountNotifications/accountNotificationsService.js +41 -0
- package/src/server/accountNotifications/bootAccountNotificationsRoutes.js +41 -0
- package/src/server/accountNotifications/registerAccountNotifications.js +39 -0
- package/src/server/accountPreferences/accountPreferencesActions.js +39 -0
- package/src/server/accountPreferences/accountPreferencesService.js +41 -0
- package/src/server/accountPreferences/bootAccountPreferencesRoutes.js +41 -0
- package/src/server/accountPreferences/registerAccountPreferences.js +39 -0
- package/src/server/accountProfile/accountProfileActions.js +137 -0
- package/src/server/accountProfile/accountProfileService.js +124 -0
- package/src/server/accountProfile/avatarService.js +141 -0
- package/src/server/accountProfile/avatarStorageService.js +132 -0
- package/src/server/accountProfile/bootAccountProfileRoutes.js +166 -0
- package/src/server/accountProfile/registerAccountProfile.js +62 -0
- package/src/server/accountProfile/registerAvatarMultipartSupport.js +43 -0
- package/src/server/accountSecurity/accountSecurityActions.js +144 -0
- package/src/server/accountSecurity/accountSecurityService.js +103 -0
- package/src/server/accountSecurity/bootAccountSecurityRoutes.js +183 -0
- package/src/server/accountSecurity/registerAccountSecurity.js +31 -0
- package/src/server/common/README.md +21 -0
- package/src/server/common/contributors/README.md +11 -0
- package/src/server/common/contributors/workspaceActionContextContributor.js +79 -0
- package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +34 -0
- package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +79 -0
- package/src/server/common/diTokens.js +21 -0
- package/src/server/common/formatters/README.md +11 -0
- package/src/server/common/formatters/accountAvatarFormatter.js +42 -0
- package/src/server/common/formatters/accountSecurityStatusFormatter.js +71 -0
- package/src/server/common/formatters/accountSettingsResponseFormatter.js +62 -0
- package/src/server/common/formatters/workspaceFormatter.js +46 -0
- package/src/server/common/registerCommonRepositories.js +45 -0
- package/src/server/common/registerSharedApi.js +9 -0
- package/src/server/common/repositories/README.md +24 -0
- package/src/server/common/repositories/repositoryUtils.js +50 -0
- package/src/server/common/repositories/userProfilesRepository.js +251 -0
- package/src/server/common/repositories/userSettingsRepository.js +179 -0
- package/src/server/common/repositories/workspaceInvitesRepository.js +172 -0
- package/src/server/common/repositories/workspaceMembershipsRepository.js +157 -0
- package/src/server/common/repositories/workspacesRepository.js +183 -0
- package/src/server/common/routes/README.md +11 -0
- package/src/server/common/services/README.md +12 -0
- package/src/server/common/services/accountContextService.js +31 -0
- package/src/server/common/services/authProfileSyncService.js +128 -0
- package/src/server/common/services/workspaceContextService.js +270 -0
- package/src/server/common/support/deepFreeze.js +17 -0
- package/src/server/common/support/realtimeServiceEvents.js +94 -0
- package/src/server/common/support/resolveActionUser.js +11 -0
- package/src/server/common/support/workspaceRoutePaths.js +17 -0
- package/src/server/common/validators/README.md +11 -0
- package/src/server/common/validators/authenticatedUserValidator.js +42 -0
- package/src/server/common/validators/routeParamsValidator.js +62 -0
- package/src/server/consoleSettings/bootConsoleSettingsRoutes.js +64 -0
- package/src/server/consoleSettings/consoleService.js +36 -0
- package/src/server/consoleSettings/consoleSettingsActions.js +55 -0
- package/src/server/consoleSettings/consoleSettingsRepository.js +111 -0
- package/src/server/consoleSettings/consoleSettingsService.js +40 -0
- package/src/server/consoleSettings/registerConsoleSettings.js +57 -0
- package/src/server/registerWorkspaceBootstrap.js +36 -0
- package/src/server/registerWorkspaceCore.js +95 -0
- package/src/server/support/resolveWorkspace.js +16 -0
- package/src/server/support/workspaceActionSurfaces.js +135 -0
- package/src/server/support/workspaceInvitationsPolicy.js +45 -0
- package/src/server/support/workspaceRouteInput.js +22 -0
- package/src/server/workspaceBootstrapContributor.js +401 -0
- package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +73 -0
- package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +19 -0
- package/src/server/workspaceDirectory/workspaceDirectoryActions.js +65 -0
- package/src/server/workspaceMembers/bootWorkspaceMembers.js +238 -0
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +112 -0
- package/src/server/workspaceMembers/workspaceMembersActions.js +186 -0
- package/src/server/workspaceMembers/workspaceMembersService.js +210 -0
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +63 -0
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +128 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +74 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +137 -0
- package/src/server/workspaceSettings/bootWorkspaceSettings.js +77 -0
- package/src/server/workspaceSettings/registerWorkspaceSettings.js +67 -0
- package/src/server/workspaceSettings/workspaceSettingsActions.js +72 -0
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +135 -0
- package/src/server/workspaceSettings/workspaceSettingsService.js +65 -0
- package/src/shared/events/usersEvents.js +19 -0
- package/src/shared/index.js +91 -0
- package/src/shared/operationMessages.js +16 -0
- package/src/shared/resources/consoleSettingsFields.js +55 -0
- package/src/shared/resources/consoleSettingsResource.js +139 -0
- package/src/shared/resources/resolveGlobalArrayRegistry.js +6 -0
- package/src/shared/resources/userProfileResource.js +148 -0
- package/src/shared/resources/userSettingsFields.js +71 -0
- package/src/shared/resources/userSettingsResource.js +416 -0
- package/src/shared/resources/workspaceMembersResource.js +352 -0
- package/src/shared/resources/workspacePendingInvitationsResource.js +87 -0
- package/src/shared/resources/workspaceResource.js +149 -0
- package/src/shared/resources/workspaceSettingsFields.js +60 -0
- package/src/shared/resources/workspaceSettingsResource.js +178 -0
- package/src/shared/roles.js +136 -0
- package/src/shared/settings.js +31 -0
- package/src/shared/support/usersApiPaths.js +34 -0
- package/src/shared/support/usersVisibility.js +45 -0
- package/src/shared/support/workspacePathModel.js +145 -0
- package/src/shared/tenancyMode.js +35 -0
- package/src/shared/tenancyProfile.js +73 -0
- package/templates/config/workspaceRoles.js +30 -0
- package/templates/migrations/users_core_console_owner.cjs +39 -0
- package/templates/migrations/users_core_initial.cjs +118 -0
- package/templates/migrations/users_core_profile_username.cjs +98 -0
- package/templates/packages/main/src/shared/resources/consoleSettingsFields.js +11 -0
- package/templates/packages/main/src/shared/resources/userSettingsFields.js +138 -0
- package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +105 -0
- package/test/authProfileSyncService.test.js +119 -0
- package/test/avatarService.test.js +114 -0
- package/test/avatarStorageService.test.js +61 -0
- package/test/consoleService.test.js +57 -0
- package/test/consoleSettingsService.test.js +86 -0
- package/test/exportsContract.test.js +38 -0
- package/test/registerAvatarMultipartSupport.test.js +64 -0
- package/test/registerServiceRealtimeEvents.test.js +160 -0
- package/test/registerWorkspaceDirectory.test.js +26 -0
- package/test/registerWorkspaceSettings.test.js +44 -0
- package/test/resourcesCanonical.test.js +90 -0
- package/test/roles.test.js +74 -0
- package/test/settingsFieldRegistriesSingleton.test.js +24 -0
- package/test/tenancyProfile.test.js +67 -0
- package/test/userSettingsResource.test.js +31 -0
- package/test/usersApiPaths.test.js +31 -0
- package/test/usersRouteRequestInputValidator.test.js +556 -0
- package/test/usersRouteResources.test.js +113 -0
- package/test/usersRouteValidators.test.js +49 -0
- package/test/usersVisibility.test.js +22 -0
- package/test/workspaceActionContextContributor.test.js +251 -0
- package/test/workspaceActionSurfaces.test.js +105 -0
- package/test/workspaceAuthPolicyContextResolver.test.js +119 -0
- package/test/workspaceBootstrapContributor.test.js +466 -0
- package/test/workspaceInvitationsPolicy.test.js +71 -0
- package/test/workspaceInvitesRepository.test.js +111 -0
- package/test/workspaceMembersService.test.js +400 -0
- package/test/workspacePathModel.test.js +93 -0
- package/test/workspacePendingInvitationsResource.test.js +38 -0
- package/test/workspacePendingInvitationsService.test.js +151 -0
- package/test/workspaceRouteVisibilityResolver.test.js +83 -0
- package/test/workspaceService.test.js +480 -0
- package/test/workspaceSettingsActions.test.js +42 -0
- package/test/workspaceSettingsRepository.test.js +156 -0
- package/test/workspaceSettingsResource.test.js +156 -0
- package/test/workspaceSettingsService.test.js +120 -0
- 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 };
|