@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,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeLowerText,
|
|
3
|
+
normalizeText,
|
|
4
|
+
toIsoString,
|
|
5
|
+
toNullableIso,
|
|
6
|
+
toNullableDateTime,
|
|
7
|
+
nowDb,
|
|
8
|
+
isDuplicateEntryError
|
|
9
|
+
} from "./repositoryUtils.js";
|
|
10
|
+
|
|
11
|
+
function mapRow(row) {
|
|
12
|
+
if (!row) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
id: Number(row.id),
|
|
18
|
+
workspaceId: Number(row.workspace_id),
|
|
19
|
+
email: normalizeLowerText(row.email),
|
|
20
|
+
roleId: normalizeLowerText(row.role_id || "member") || "member",
|
|
21
|
+
status: normalizeLowerText(row.status || "pending") || "pending",
|
|
22
|
+
tokenHash: normalizeText(row.token_hash),
|
|
23
|
+
invitedByUserId: row.invited_by_user_id == null ? null : Number(row.invited_by_user_id),
|
|
24
|
+
expiresAt: toNullableIso(row.expires_at),
|
|
25
|
+
acceptedAt: toNullableIso(row.accepted_at),
|
|
26
|
+
revokedAt: toNullableIso(row.revoked_at),
|
|
27
|
+
createdAt: toIsoString(row.created_at),
|
|
28
|
+
updatedAt: toIsoString(row.updated_at),
|
|
29
|
+
workspaceSlug: row.workspace_slug ? normalizeText(row.workspace_slug) : undefined,
|
|
30
|
+
workspaceName: row.workspace_name ? normalizeText(row.workspace_name) : undefined,
|
|
31
|
+
workspaceAvatarUrl: row.workspace_avatar_url ? normalizeText(row.workspace_avatar_url) : undefined
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WORKSPACE_INVITE_WITH_WORKSPACE_SELECT = Object.freeze([
|
|
36
|
+
"wi.*",
|
|
37
|
+
"w.slug as workspace_slug",
|
|
38
|
+
"w.name as workspace_name",
|
|
39
|
+
"w.avatar_url as workspace_avatar_url"
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function createRepository(knex) {
|
|
43
|
+
if (typeof knex !== "function") {
|
|
44
|
+
throw new TypeError("workspaceInvitesRepository requires knex.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function findPendingByTokenHash(tokenHash, options = {}) {
|
|
48
|
+
const client = options?.trx || knex;
|
|
49
|
+
const row = await client("workspace_invites")
|
|
50
|
+
.where({ token_hash: normalizeText(tokenHash), status: "pending" })
|
|
51
|
+
.first();
|
|
52
|
+
return mapRow(row);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function listPendingByEmail(email, options = {}) {
|
|
56
|
+
const client = options?.trx || knex;
|
|
57
|
+
const normalizedEmail = normalizeLowerText(email);
|
|
58
|
+
if (!normalizedEmail) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rows = await client("workspace_invites as wi")
|
|
63
|
+
.join("workspaces as w", "w.id", "wi.workspace_id")
|
|
64
|
+
.where({ "wi.email": normalizedEmail, "wi.status": "pending" })
|
|
65
|
+
.orderBy("wi.created_at", "desc")
|
|
66
|
+
.select(WORKSPACE_INVITE_WITH_WORKSPACE_SELECT);
|
|
67
|
+
|
|
68
|
+
return rows.map(mapRow).filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function listPendingByWorkspaceIdWithWorkspace(workspaceId, options = {}) {
|
|
72
|
+
const client = options?.trx || knex;
|
|
73
|
+
const rows = await client("workspace_invites as wi")
|
|
74
|
+
.join("workspaces as w", "w.id", "wi.workspace_id")
|
|
75
|
+
.where({ "wi.workspace_id": Number(workspaceId), "wi.status": "pending" })
|
|
76
|
+
.orderBy("wi.created_at", "desc")
|
|
77
|
+
.select(WORKSPACE_INVITE_WITH_WORKSPACE_SELECT);
|
|
78
|
+
|
|
79
|
+
return rows.map(mapRow).filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function insert(payload = {}, options = {}) {
|
|
83
|
+
const client = options?.trx || knex;
|
|
84
|
+
const source = payload && typeof payload === "object" ? payload : {};
|
|
85
|
+
|
|
86
|
+
const insertPayload = {
|
|
87
|
+
workspace_id: Number(source.workspaceId),
|
|
88
|
+
email: normalizeLowerText(source.email),
|
|
89
|
+
role_id: normalizeLowerText(source.roleId || "member") || "member",
|
|
90
|
+
status: normalizeLowerText(source.status || "pending") || "pending",
|
|
91
|
+
token_hash: normalizeText(source.tokenHash),
|
|
92
|
+
invited_by_user_id: source.invitedByUserId == null ? null : Number(source.invitedByUserId),
|
|
93
|
+
expires_at: toNullableDateTime(source.expiresAt),
|
|
94
|
+
accepted_at: null,
|
|
95
|
+
revoked_at: null,
|
|
96
|
+
created_at: nowDb(),
|
|
97
|
+
updated_at: nowDb()
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const result = await client("workspace_invites").insert(insertPayload);
|
|
102
|
+
const insertedId = Array.isArray(result) ? Number(result[0]) : Number(result);
|
|
103
|
+
if (Number.isInteger(insertedId) && insertedId > 0) {
|
|
104
|
+
const row = await client("workspace_invites").where({ id: insertedId }).first();
|
|
105
|
+
return mapRow(row);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!isDuplicateEntryError(error)) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const row = await client("workspace_invites")
|
|
114
|
+
.where({ workspace_id: insertPayload.workspace_id, email: insertPayload.email, status: "pending" })
|
|
115
|
+
.orderBy("id", "desc")
|
|
116
|
+
.first();
|
|
117
|
+
return mapRow(row);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function expirePendingByWorkspaceIdAndEmail(workspaceId, email, options = {}) {
|
|
121
|
+
const client = options?.trx || knex;
|
|
122
|
+
await client("workspace_invites")
|
|
123
|
+
.where({ workspace_id: Number(workspaceId), email: normalizeLowerText(email), status: "pending" })
|
|
124
|
+
.update({
|
|
125
|
+
status: "expired",
|
|
126
|
+
updated_at: nowDb()
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function markAcceptedById(inviteId, options = {}) {
|
|
131
|
+
const client = options?.trx || knex;
|
|
132
|
+
await client("workspace_invites")
|
|
133
|
+
.where({ id: Number(inviteId) })
|
|
134
|
+
.update({
|
|
135
|
+
status: "accepted",
|
|
136
|
+
accepted_at: nowDb(),
|
|
137
|
+
updated_at: nowDb()
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function revokeById(inviteId, options = {}) {
|
|
142
|
+
const client = options?.trx || knex;
|
|
143
|
+
await client("workspace_invites")
|
|
144
|
+
.where({ id: Number(inviteId) })
|
|
145
|
+
.update({
|
|
146
|
+
status: "revoked",
|
|
147
|
+
revoked_at: nowDb(),
|
|
148
|
+
updated_at: nowDb()
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function findPendingByIdForWorkspace(inviteId, workspaceId, options = {}) {
|
|
153
|
+
const client = options?.trx || knex;
|
|
154
|
+
const row = await client("workspace_invites")
|
|
155
|
+
.where({ id: Number(inviteId), workspace_id: Number(workspaceId), status: "pending" })
|
|
156
|
+
.first();
|
|
157
|
+
return mapRow(row);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
findPendingByTokenHash,
|
|
162
|
+
listPendingByEmail,
|
|
163
|
+
listPendingByWorkspaceIdWithWorkspace,
|
|
164
|
+
insert,
|
|
165
|
+
expirePendingByWorkspaceIdAndEmail,
|
|
166
|
+
markAcceptedById,
|
|
167
|
+
revokeById,
|
|
168
|
+
findPendingByIdForWorkspace
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { createRepository, mapRow };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeLowerText,
|
|
3
|
+
normalizeText,
|
|
4
|
+
toIsoString,
|
|
5
|
+
nowDb,
|
|
6
|
+
isDuplicateEntryError
|
|
7
|
+
} from "./repositoryUtils.js";
|
|
8
|
+
import { OWNER_ROLE_ID } from "../../../shared/roles.js";
|
|
9
|
+
|
|
10
|
+
function mapRow(row) {
|
|
11
|
+
if (!row) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
id: Number(row.id),
|
|
17
|
+
workspaceId: Number(row.workspace_id),
|
|
18
|
+
userId: Number(row.user_id),
|
|
19
|
+
roleId: normalizeLowerText(row.role_id || "member") || "member",
|
|
20
|
+
status: normalizeLowerText(row.status || "active") || "active",
|
|
21
|
+
createdAt: toIsoString(row.created_at),
|
|
22
|
+
updatedAt: toIsoString(row.updated_at)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mapMemberSummaryRow(row) {
|
|
27
|
+
if (!row) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
userId: Number(row.user_id),
|
|
33
|
+
roleId: normalizeLowerText(row.role_id || "member") || "member",
|
|
34
|
+
status: normalizeLowerText(row.status || "active") || "active",
|
|
35
|
+
displayName: normalizeText(row.display_name),
|
|
36
|
+
email: normalizeLowerText(row.email)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createRepository(knex) {
|
|
41
|
+
if (typeof knex !== "function") {
|
|
42
|
+
throw new TypeError("workspaceMembershipsRepository requires knex.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findByWorkspaceIdAndUserId(workspaceId, userId, options = {}) {
|
|
46
|
+
const client = options?.trx || knex;
|
|
47
|
+
const row = await client("workspace_memberships")
|
|
48
|
+
.where({ workspace_id: Number(workspaceId), user_id: Number(userId) })
|
|
49
|
+
.first();
|
|
50
|
+
return mapRow(row);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ensureOwnerMembership(workspaceId, userId, options = {}) {
|
|
54
|
+
const client = options?.trx || knex;
|
|
55
|
+
const existing = await findByWorkspaceIdAndUserId(workspaceId, userId, { trx: client });
|
|
56
|
+
if (existing) {
|
|
57
|
+
if (existing.roleId !== OWNER_ROLE_ID || existing.status !== "active") {
|
|
58
|
+
await client("workspace_memberships")
|
|
59
|
+
.where({ workspace_id: Number(workspaceId), user_id: Number(userId) })
|
|
60
|
+
.update({
|
|
61
|
+
role_id: OWNER_ROLE_ID,
|
|
62
|
+
status: "active",
|
|
63
|
+
updated_at: nowDb()
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return findByWorkspaceIdAndUserId(workspaceId, userId, { trx: client });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await client("workspace_memberships").insert({
|
|
71
|
+
workspace_id: Number(workspaceId),
|
|
72
|
+
user_id: Number(userId),
|
|
73
|
+
role_id: OWNER_ROLE_ID,
|
|
74
|
+
status: "active",
|
|
75
|
+
created_at: nowDb(),
|
|
76
|
+
updated_at: nowDb()
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!isDuplicateEntryError(error)) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return findByWorkspaceIdAndUserId(workspaceId, userId, { trx: client });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function upsertMembership(workspaceId, userId, patch = {}, options = {}) {
|
|
88
|
+
const client = options?.trx || knex;
|
|
89
|
+
const existing = await findByWorkspaceIdAndUserId(workspaceId, userId, { trx: client });
|
|
90
|
+
const roleId = normalizeLowerText(patch.roleId || existing?.roleId || "member") || "member";
|
|
91
|
+
const status = normalizeLowerText(patch.status || existing?.status || "active") || "active";
|
|
92
|
+
|
|
93
|
+
if (!existing) {
|
|
94
|
+
await client("workspace_memberships").insert({
|
|
95
|
+
workspace_id: Number(workspaceId),
|
|
96
|
+
user_id: Number(userId),
|
|
97
|
+
role_id: roleId,
|
|
98
|
+
status,
|
|
99
|
+
created_at: nowDb(),
|
|
100
|
+
updated_at: nowDb()
|
|
101
|
+
});
|
|
102
|
+
return findByWorkspaceIdAndUserId(workspaceId, userId, { trx: client });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await client("workspace_memberships")
|
|
106
|
+
.where({ workspace_id: Number(workspaceId), user_id: Number(userId) })
|
|
107
|
+
.update({
|
|
108
|
+
role_id: roleId,
|
|
109
|
+
status,
|
|
110
|
+
updated_at: nowDb()
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return findByWorkspaceIdAndUserId(workspaceId, userId, { trx: client });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function listActiveByWorkspaceId(workspaceId, options = {}) {
|
|
117
|
+
const client = options?.trx || knex;
|
|
118
|
+
const rows = await client("workspace_memberships as wm")
|
|
119
|
+
.join("user_profiles as up", "up.id", "wm.user_id")
|
|
120
|
+
.where({ "wm.workspace_id": Number(workspaceId), "wm.status": "active" })
|
|
121
|
+
.orderBy("up.display_name", "asc")
|
|
122
|
+
.select([
|
|
123
|
+
"wm.user_id",
|
|
124
|
+
"wm.role_id",
|
|
125
|
+
"wm.status",
|
|
126
|
+
"up.display_name",
|
|
127
|
+
"up.email"
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
return rows.map(mapMemberSummaryRow).filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function listActiveWorkspaceIdsByUserId(userId, options = {}) {
|
|
134
|
+
const client = options?.trx || knex;
|
|
135
|
+
const rows = await client("workspace_memberships")
|
|
136
|
+
.where({
|
|
137
|
+
user_id: Number(userId),
|
|
138
|
+
status: "active"
|
|
139
|
+
})
|
|
140
|
+
.select("workspace_id")
|
|
141
|
+
.orderBy("workspace_id", "asc");
|
|
142
|
+
|
|
143
|
+
return rows
|
|
144
|
+
.map((row) => Number(row.workspace_id))
|
|
145
|
+
.filter((workspaceId) => Number.isInteger(workspaceId) && workspaceId > 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
findByWorkspaceIdAndUserId,
|
|
150
|
+
ensureOwnerMembership,
|
|
151
|
+
upsertMembership,
|
|
152
|
+
listActiveByWorkspaceId,
|
|
153
|
+
listActiveWorkspaceIdsByUserId
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { createRepository, mapRow, mapMemberSummaryRow };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeText,
|
|
3
|
+
normalizeLowerText,
|
|
4
|
+
toIsoString,
|
|
5
|
+
toNullableIso,
|
|
6
|
+
nowDb,
|
|
7
|
+
isDuplicateEntryError
|
|
8
|
+
} from "./repositoryUtils.js";
|
|
9
|
+
import { coerceWorkspaceColor } from "../../../shared/settings.js";
|
|
10
|
+
|
|
11
|
+
function mapRow(row) {
|
|
12
|
+
if (!row) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
id: Number(row.id),
|
|
18
|
+
slug: normalizeText(row.slug),
|
|
19
|
+
name: normalizeText(row.name),
|
|
20
|
+
ownerUserId: Number(row.owner_user_id),
|
|
21
|
+
isPersonal: Boolean(row.is_personal),
|
|
22
|
+
avatarUrl: row.avatar_url ? normalizeText(row.avatar_url) : "",
|
|
23
|
+
color: coerceWorkspaceColor(row.color),
|
|
24
|
+
createdAt: toIsoString(row.created_at),
|
|
25
|
+
updatedAt: toIsoString(row.updated_at),
|
|
26
|
+
deletedAt: toNullableIso(row.deleted_at)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mapMembershipWorkspaceRow(row) {
|
|
31
|
+
if (!row) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...mapRow(row),
|
|
37
|
+
roleId: normalizeLowerText(row.role_id || "member"),
|
|
38
|
+
membershipStatus: normalizeLowerText(row.membership_status || "active") || "active"
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createRepository(knex) {
|
|
43
|
+
if (typeof knex !== "function") {
|
|
44
|
+
throw new TypeError("workspacesRepository requires knex.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function workspaceSelectColumns(client, { includeMembership = false } = {}) {
|
|
48
|
+
const columns = [
|
|
49
|
+
"w.id",
|
|
50
|
+
"w.slug",
|
|
51
|
+
client.raw("COALESCE(ws.name, w.name) as name"),
|
|
52
|
+
"w.owner_user_id",
|
|
53
|
+
"w.is_personal",
|
|
54
|
+
client.raw("COALESCE(ws.avatar_url, w.avatar_url) as avatar_url"),
|
|
55
|
+
client.raw("COALESCE(ws.color, w.color) as color"),
|
|
56
|
+
"w.created_at",
|
|
57
|
+
"w.updated_at",
|
|
58
|
+
"w.deleted_at"
|
|
59
|
+
];
|
|
60
|
+
if (includeMembership) {
|
|
61
|
+
columns.push("wm.role_id", "wm.status as membership_status");
|
|
62
|
+
}
|
|
63
|
+
return columns;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function findById(workspaceId, options = {}) {
|
|
67
|
+
const client = options?.trx || knex;
|
|
68
|
+
const row = await client("workspaces as w")
|
|
69
|
+
.leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
|
|
70
|
+
.where({ "w.id": Number(workspaceId) })
|
|
71
|
+
.select(workspaceSelectColumns(client))
|
|
72
|
+
.first();
|
|
73
|
+
return mapRow(row);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function findBySlug(slug, options = {}) {
|
|
77
|
+
const client = options?.trx || knex;
|
|
78
|
+
const normalizedSlug = normalizeLowerText(slug);
|
|
79
|
+
if (!normalizedSlug) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const row = await client("workspaces as w")
|
|
84
|
+
.leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
|
|
85
|
+
.where({ "w.slug": normalizedSlug })
|
|
86
|
+
.select(workspaceSelectColumns(client))
|
|
87
|
+
.first();
|
|
88
|
+
return mapRow(row);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function findPersonalByOwnerUserId(userId, options = {}) {
|
|
92
|
+
const client = options?.trx || knex;
|
|
93
|
+
const row = await client("workspaces as w")
|
|
94
|
+
.leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
|
|
95
|
+
.where({ "w.owner_user_id": Number(userId), "w.is_personal": 1 })
|
|
96
|
+
.orderBy("w.id", "asc")
|
|
97
|
+
.select(workspaceSelectColumns(client))
|
|
98
|
+
.first();
|
|
99
|
+
return mapRow(row);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function insert(payload = {}, options = {}) {
|
|
103
|
+
const client = options?.trx || knex;
|
|
104
|
+
const source = payload && typeof payload === "object" ? payload : {};
|
|
105
|
+
|
|
106
|
+
const insertPayload = {
|
|
107
|
+
slug: normalizeLowerText(source.slug),
|
|
108
|
+
name: normalizeText(source.name),
|
|
109
|
+
owner_user_id: Number(source.ownerUserId),
|
|
110
|
+
is_personal: source.isPersonal ? 1 : 0,
|
|
111
|
+
avatar_url: normalizeText(source.avatarUrl),
|
|
112
|
+
color: coerceWorkspaceColor(source.color),
|
|
113
|
+
created_at: nowDb(),
|
|
114
|
+
updated_at: nowDb(),
|
|
115
|
+
deleted_at: null
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const result = await client("workspaces").insert(insertPayload);
|
|
120
|
+
const insertedId = Array.isArray(result) ? Number(result[0]) : Number(result);
|
|
121
|
+
if (Number.isInteger(insertedId) && insertedId > 0) {
|
|
122
|
+
return findById(insertedId, { trx: client });
|
|
123
|
+
}
|
|
124
|
+
const bySlug = await findBySlug(insertPayload.slug, { trx: client });
|
|
125
|
+
return bySlug;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!isDuplicateEntryError(error)) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
const bySlug = await findBySlug(insertPayload.slug, { trx: client });
|
|
131
|
+
if (bySlug) {
|
|
132
|
+
return bySlug;
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function updateById(workspaceId, patch = {}, options = {}) {
|
|
139
|
+
const client = options?.trx || knex;
|
|
140
|
+
const source = patch && typeof patch === "object" ? patch : {};
|
|
141
|
+
const dbPatch = {
|
|
142
|
+
updated_at: nowDb()
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (Object.hasOwn(source, "name")) {
|
|
146
|
+
dbPatch.name = normalizeText(source.name);
|
|
147
|
+
}
|
|
148
|
+
if (Object.hasOwn(source, "avatarUrl")) {
|
|
149
|
+
dbPatch.avatar_url = normalizeText(source.avatarUrl);
|
|
150
|
+
}
|
|
151
|
+
if (Object.hasOwn(source, "color")) {
|
|
152
|
+
dbPatch.color = coerceWorkspaceColor(source.color);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await client("workspaces").where({ id: Number(workspaceId) }).update(dbPatch);
|
|
156
|
+
return findById(workspaceId, { trx: client });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function listForUserId(userId, options = {}) {
|
|
160
|
+
const client = options?.trx || knex;
|
|
161
|
+
const rows = await client("workspace_memberships as wm")
|
|
162
|
+
.join("workspaces as w", "w.id", "wm.workspace_id")
|
|
163
|
+
.leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
|
|
164
|
+
.where({ "wm.user_id": Number(userId) })
|
|
165
|
+
.whereNull("w.deleted_at")
|
|
166
|
+
.orderBy("w.is_personal", "desc")
|
|
167
|
+
.orderBy("w.id", "asc")
|
|
168
|
+
.select(workspaceSelectColumns(client, { includeMembership: true }));
|
|
169
|
+
|
|
170
|
+
return rows.map(mapMembershipWorkspaceRow).filter(Boolean);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
findById,
|
|
175
|
+
findBySlug,
|
|
176
|
+
findPersonalByOwnerUserId,
|
|
177
|
+
insert,
|
|
178
|
+
updateById,
|
|
179
|
+
listForUserId
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export { createRepository, mapRow, mapMembershipWorkspaceRow };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# `routes/`
|
|
2
|
+
|
|
3
|
+
Put shared route schema maps here when multiple route adapters consume them.
|
|
4
|
+
|
|
5
|
+
Allowed:
|
|
6
|
+
- shared route request/response schema groupings
|
|
7
|
+
- references to shared resources/commands
|
|
8
|
+
|
|
9
|
+
Not allowed:
|
|
10
|
+
- route registration/handlers
|
|
11
|
+
- business logic
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# `services/`
|
|
2
|
+
|
|
3
|
+
Put shared server services here when they are used by more than one slice.
|
|
4
|
+
|
|
5
|
+
Allowed:
|
|
6
|
+
- cross-slice service logic with clear inputs/outputs
|
|
7
|
+
- no HTTP route validator handling
|
|
8
|
+
|
|
9
|
+
Not allowed:
|
|
10
|
+
- feature-only services
|
|
11
|
+
- route adapters/controllers
|
|
12
|
+
- response schema declarations
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { normalizeIdentity } from "../repositories/userProfilesRepository.js";
|
|
2
|
+
|
|
3
|
+
async function resolveUserProfile(userProfilesRepository, user) {
|
|
4
|
+
const identity = normalizeIdentity(user);
|
|
5
|
+
if (identity) {
|
|
6
|
+
const profile = await userProfilesRepository.findByIdentity(identity);
|
|
7
|
+
if (profile) {
|
|
8
|
+
return profile;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const userId = Number(user?.id);
|
|
13
|
+
if (Number.isInteger(userId) && userId > 0) {
|
|
14
|
+
const profileById = await userProfilesRepository.findById(userId);
|
|
15
|
+
if (profileById) {
|
|
16
|
+
return profileById;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveSecurityStatus(authService, request) {
|
|
24
|
+
if (!authService || typeof authService.getSecurityStatus !== "function") {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return authService.getSecurityStatus(request);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { resolveUserProfile, resolveSecurityStatus };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
2
|
+
import { normalizeIdentity } from "../repositories/userProfilesRepository.js";
|
|
3
|
+
|
|
4
|
+
function buildNormalizedIdentityKey(identityLike) {
|
|
5
|
+
const identity = normalizeIdentity(identityLike);
|
|
6
|
+
if (!identity) {
|
|
7
|
+
throw new TypeError("Profile identity is missing required fields.");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
authProvider: identity.provider,
|
|
12
|
+
authProviderUserId: identity.providerUserId
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildNormalizedIdentityProfile(profileLike) {
|
|
17
|
+
const source = profileLike && typeof profileLike === "object" ? profileLike : {};
|
|
18
|
+
const identity = buildNormalizedIdentityKey(source);
|
|
19
|
+
const email = normalizeLowerText(source.email);
|
|
20
|
+
const displayName = normalizeText(source.displayName);
|
|
21
|
+
|
|
22
|
+
if (!email || !displayName) {
|
|
23
|
+
throw new TypeError("Profile identity is missing required fields.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
authProvider: identity.authProvider,
|
|
28
|
+
authProviderUserId: identity.authProviderUserId,
|
|
29
|
+
email,
|
|
30
|
+
displayName,
|
|
31
|
+
username: normalizeLowerText(source.username)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function profileNeedsUpdate(existing, nextProfile) {
|
|
36
|
+
if (!existing) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
existing.email !== nextProfile.email ||
|
|
42
|
+
existing.displayName !== nextProfile.displayName ||
|
|
43
|
+
existing.authProvider !== nextProfile.authProvider ||
|
|
44
|
+
existing.authProviderUserId !== nextProfile.authProviderUserId
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requireSynchronizedProfile(profile) {
|
|
49
|
+
if (profile && Number.isFinite(Number(profile.id)) && String(profile.displayName || "").trim()) {
|
|
50
|
+
return profile;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error("Profile synchronization failed.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createService({ userProfilesRepository, workspaceProvisioningService = null } = {}) {
|
|
57
|
+
if (!userProfilesRepository || typeof userProfilesRepository.findByIdentity !== "function") {
|
|
58
|
+
throw new Error("authProfileSyncService requires userProfilesRepository.findByIdentity().");
|
|
59
|
+
}
|
|
60
|
+
if (typeof userProfilesRepository.upsert !== "function") {
|
|
61
|
+
throw new Error("authProfileSyncService requires userProfilesRepository.upsert().");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function findByIdentity(identityLike, options = {}) {
|
|
65
|
+
const normalized = buildNormalizedIdentityKey(identityLike);
|
|
66
|
+
return userProfilesRepository.findByIdentity(
|
|
67
|
+
{
|
|
68
|
+
provider: normalized.authProvider,
|
|
69
|
+
providerUserId: normalized.authProviderUserId
|
|
70
|
+
},
|
|
71
|
+
options
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function upsertByIdentity(profileLike, options = {}) {
|
|
76
|
+
const normalized = buildNormalizedIdentityProfile(profileLike);
|
|
77
|
+
return userProfilesRepository.upsert(
|
|
78
|
+
{
|
|
79
|
+
authProvider: normalized.authProvider,
|
|
80
|
+
authProviderUserId: normalized.authProviderUserId,
|
|
81
|
+
email: normalized.email,
|
|
82
|
+
displayName: normalized.displayName,
|
|
83
|
+
username: normalized.username
|
|
84
|
+
},
|
|
85
|
+
options
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function syncIdentityProfile(profileLike, options = {}) {
|
|
90
|
+
const normalized = buildNormalizedIdentityProfile(profileLike);
|
|
91
|
+
|
|
92
|
+
const runSync = async (trx = null) => {
|
|
93
|
+
const operationOptions = trx ? { ...options, trx } : options;
|
|
94
|
+
const existing = await findByIdentity(normalized, operationOptions);
|
|
95
|
+
if (!profileNeedsUpdate(existing, normalized)) {
|
|
96
|
+
return requireSynchronizedProfile(existing);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const upserted = await upsertByIdentity(normalized, operationOptions);
|
|
100
|
+
const synchronizedProfile = requireSynchronizedProfile(upserted);
|
|
101
|
+
if (
|
|
102
|
+
!existing &&
|
|
103
|
+
workspaceProvisioningService &&
|
|
104
|
+
typeof workspaceProvisioningService.provisionWorkspaceForNewUser === "function"
|
|
105
|
+
) {
|
|
106
|
+
await workspaceProvisioningService.provisionWorkspaceForNewUser(synchronizedProfile, operationOptions);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return synchronizedProfile;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (options?.trx) {
|
|
113
|
+
return runSync(options.trx);
|
|
114
|
+
}
|
|
115
|
+
if (typeof userProfilesRepository.withTransaction === "function") {
|
|
116
|
+
return userProfilesRepository.withTransaction((trx) => runSync(trx));
|
|
117
|
+
}
|
|
118
|
+
return runSync();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
findByIdentity,
|
|
123
|
+
upsertByIdentity,
|
|
124
|
+
syncIdentityProfile
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { createService };
|