@jskit-ai/workspaces-core 0.1.14 → 0.1.16
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 +2 -2
- package/package.json +18 -3
- package/src/server/WorkspacesCoreServiceProvider.js +41 -2
- package/src/server/common/contributors/workspaceActionContextContributor.js +88 -0
- package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +34 -0
- package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +78 -0
- package/src/server/common/formatters/workspaceFormatter.js +53 -0
- package/src/server/common/repositories/repositoryUtils.js +59 -0
- package/src/server/common/repositories/workspaceInvitesRepository.js +208 -0
- package/src/server/common/repositories/workspaceMembershipsRepository.js +190 -0
- package/src/server/common/repositories/workspacesRepository.js +202 -0
- package/src/server/common/services/workspaceContextService.js +281 -0
- package/src/server/common/support/deepFreeze.js +1 -0
- package/src/server/common/support/realtimeServiceEvents.js +91 -0
- package/src/server/common/support/resolveActionUser.js +9 -0
- package/src/server/common/support/workspaceRoutePaths.js +18 -0
- package/src/server/common/validators/authenticatedUserValidator.js +43 -0
- package/src/server/common/validators/routeParamsValidator.js +62 -0
- package/src/server/registerWorkspaceBootstrap.js +27 -0
- package/src/server/registerWorkspaceCore.js +100 -0
- package/src/server/registerWorkspaceRepositories.js +26 -0
- package/src/server/support/resolveWorkspace.js +16 -0
- package/src/server/support/workspaceActionSurfaces.js +118 -0
- package/src/server/support/workspaceInvitationsPolicy.js +45 -0
- package/src/server/support/workspaceRouteInput.js +22 -0
- package/src/server/workspaceBootstrapContributor.js +233 -0
- package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +133 -0
- package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +19 -0
- package/src/server/workspaceDirectory/workspaceDirectoryActions.js +133 -0
- package/src/server/workspaceMembers/bootWorkspaceMembers.js +236 -0
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +108 -0
- package/src/server/workspaceMembers/workspaceMembersActions.js +186 -0
- package/src/server/workspaceMembers/workspaceMembersService.js +222 -0
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +62 -0
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +119 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +74 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +138 -0
- package/src/server/workspaceSettings/bootWorkspaceSettings.js +76 -0
- package/src/server/workspaceSettings/registerWorkspaceSettings.js +62 -0
- package/src/server/workspaceSettings/workspaceSettingsActions.js +72 -0
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +154 -0
- package/src/server/workspaceSettings/workspaceSettingsService.js +66 -0
- package/src/shared/operationMessages.js +16 -0
- package/src/shared/resources/resolveGlobalArrayRegistry.js +6 -0
- package/src/shared/resources/workspaceMembersResource.js +354 -0
- package/src/shared/resources/workspacePendingInvitationsResource.js +82 -0
- package/src/shared/resources/workspaceResource.js +176 -0
- package/src/shared/resources/workspaceSettingsFields.js +59 -0
- package/src/shared/resources/workspaceSettingsResource.js +169 -0
- package/src/shared/roles.js +161 -0
- package/src/shared/settings.js +119 -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/packages/main/src/shared/resources/workspaceSettingsFields.js +2 -2
- package/test/registerServiceRealtimeEvents.test.js +116 -0
- package/test/registerWorkspaceDirectory.test.js +31 -0
- package/test/registerWorkspaceSettings.test.js +40 -0
- package/test/repositoryContracts.test.js +34 -0
- package/test/resourcesCanonical.test.js +74 -0
- package/test/roles.test.js +159 -0
- package/test/routeParamsValidator.test.js +49 -0
- package/test/settingsFieldRegistriesSingleton.test.js +14 -0
- package/test/tenancyProfile.test.js +67 -0
- package/test/usersRouteResources.test.js +97 -0
- package/test/workspaceActionContextContributor.test.js +344 -0
- package/test/workspaceActionSurfaces.test.js +85 -0
- package/test/workspaceAuthPolicyContextResolver.test.js +119 -0
- package/test/workspaceBootstrapContributor.test.js +169 -0
- package/test/workspaceInvitationsPolicy.test.js +71 -0
- package/test/workspaceInvitesRepository.test.js +111 -0
- package/test/workspaceMembersService.test.js +398 -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 +546 -0
- package/test/workspaceSettingsActions.test.js +52 -0
- package/test/workspaceSettingsRepository.test.js +202 -0
- package/test/workspaceSettingsResource.test.js +169 -0
- package/test/workspaceSettingsService.test.js +140 -0
- package/test/workspacesRouteRequestInputValidator.test.js +5 -5
- package/test-support/registerDefaultSettingsFields.js +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeLowerText,
|
|
3
|
+
normalizeRecordId,
|
|
4
|
+
normalizeDbRecordId,
|
|
5
|
+
normalizeText,
|
|
6
|
+
toIsoString,
|
|
7
|
+
nowDb,
|
|
8
|
+
isDuplicateEntryError,
|
|
9
|
+
createWithTransaction
|
|
10
|
+
} from "./repositoryUtils.js";
|
|
11
|
+
import { OWNER_ROLE_ID } from "../../../shared/roles.js";
|
|
12
|
+
|
|
13
|
+
function mapRow(row) {
|
|
14
|
+
if (!row) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
id: normalizeDbRecordId(row.id, { fallback: "" }),
|
|
20
|
+
workspaceId: normalizeDbRecordId(row.workspace_id, { fallback: "" }),
|
|
21
|
+
userId: normalizeDbRecordId(row.user_id, { fallback: "" }),
|
|
22
|
+
roleSid: normalizeLowerText(row.role_sid || "member") || "member",
|
|
23
|
+
status: normalizeLowerText(row.status || "active") || "active",
|
|
24
|
+
createdAt: toIsoString(row.created_at),
|
|
25
|
+
updatedAt: toIsoString(row.updated_at)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapMemberSummaryRow(row) {
|
|
30
|
+
if (!row) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
userId: normalizeDbRecordId(row.user_id, { fallback: "" }),
|
|
36
|
+
roleSid: normalizeLowerText(row.role_sid || "member") || "member",
|
|
37
|
+
status: normalizeLowerText(row.status || "active") || "active",
|
|
38
|
+
displayName: normalizeText(row.display_name),
|
|
39
|
+
email: normalizeLowerText(row.email)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createRepository(knex) {
|
|
44
|
+
if (typeof knex !== "function") {
|
|
45
|
+
throw new TypeError("workspaceMembershipsRepository requires knex.");
|
|
46
|
+
}
|
|
47
|
+
const withTransaction = createWithTransaction(knex);
|
|
48
|
+
|
|
49
|
+
async function findByWorkspaceIdAndUserId(workspaceId, userId, options = {}) {
|
|
50
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
51
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
52
|
+
if (!normalizedWorkspaceId || !normalizedUserId) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const client = options?.trx || knex;
|
|
57
|
+
const row = await client("workspace_memberships")
|
|
58
|
+
.where({ workspace_id: normalizedWorkspaceId, user_id: normalizedUserId })
|
|
59
|
+
.first();
|
|
60
|
+
return mapRow(row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function ensureOwnerMembership(workspaceId, userId, options = {}) {
|
|
64
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
65
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
66
|
+
if (!normalizedWorkspaceId || !normalizedUserId) {
|
|
67
|
+
throw new TypeError("workspaceMembershipsRepository.ensureOwnerMembership requires workspaceId and userId.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const client = options?.trx || knex;
|
|
71
|
+
const existing = await findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, { trx: client });
|
|
72
|
+
if (existing) {
|
|
73
|
+
if (existing.roleSid !== OWNER_ROLE_ID || existing.status !== "active") {
|
|
74
|
+
await client("workspace_memberships")
|
|
75
|
+
.where({ workspace_id: normalizedWorkspaceId, user_id: normalizedUserId })
|
|
76
|
+
.update({
|
|
77
|
+
role_sid: OWNER_ROLE_ID,
|
|
78
|
+
status: "active",
|
|
79
|
+
updated_at: nowDb()
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, { trx: client });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await client("workspace_memberships").insert({
|
|
87
|
+
workspace_id: normalizedWorkspaceId,
|
|
88
|
+
user_id: normalizedUserId,
|
|
89
|
+
role_sid: OWNER_ROLE_ID,
|
|
90
|
+
status: "active",
|
|
91
|
+
created_at: nowDb(),
|
|
92
|
+
updated_at: nowDb()
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!isDuplicateEntryError(error)) {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, { trx: client });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function upsertMembership(workspaceId, userId, patch = {}, options = {}) {
|
|
104
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
105
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
106
|
+
if (!normalizedWorkspaceId || !normalizedUserId) {
|
|
107
|
+
throw new TypeError("workspaceMembershipsRepository.upsertMembership requires workspaceId and userId.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const client = options?.trx || knex;
|
|
111
|
+
const existing = await findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, { trx: client });
|
|
112
|
+
const roleSid = normalizeLowerText(patch.roleSid || existing?.roleSid || "member") || "member";
|
|
113
|
+
const status = normalizeLowerText(patch.status || existing?.status || "active") || "active";
|
|
114
|
+
|
|
115
|
+
if (!existing) {
|
|
116
|
+
await client("workspace_memberships").insert({
|
|
117
|
+
workspace_id: normalizedWorkspaceId,
|
|
118
|
+
user_id: normalizedUserId,
|
|
119
|
+
role_sid: roleSid,
|
|
120
|
+
status,
|
|
121
|
+
created_at: nowDb(),
|
|
122
|
+
updated_at: nowDb()
|
|
123
|
+
});
|
|
124
|
+
return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, { trx: client });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await client("workspace_memberships")
|
|
128
|
+
.where({ workspace_id: normalizedWorkspaceId, user_id: normalizedUserId })
|
|
129
|
+
.update({
|
|
130
|
+
role_sid: roleSid,
|
|
131
|
+
status,
|
|
132
|
+
updated_at: nowDb()
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, { trx: client });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function listActiveByWorkspaceId(workspaceId, options = {}) {
|
|
139
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
140
|
+
if (!normalizedWorkspaceId) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const client = options?.trx || knex;
|
|
145
|
+
const rows = await client("workspace_memberships as wm")
|
|
146
|
+
.join("users as up", "up.id", "wm.user_id")
|
|
147
|
+
.where({ "wm.workspace_id": normalizedWorkspaceId, "wm.status": "active" })
|
|
148
|
+
.orderBy("up.display_name", "asc")
|
|
149
|
+
.select([
|
|
150
|
+
"wm.user_id",
|
|
151
|
+
"wm.role_sid",
|
|
152
|
+
"wm.status",
|
|
153
|
+
"up.display_name",
|
|
154
|
+
"up.email"
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
return rows.map(mapMemberSummaryRow).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function listActiveWorkspaceIdsByUserId(userId, options = {}) {
|
|
161
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
162
|
+
if (!normalizedUserId) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const client = options?.trx || knex;
|
|
167
|
+
const rows = await client("workspace_memberships")
|
|
168
|
+
.where({
|
|
169
|
+
user_id: normalizedUserId,
|
|
170
|
+
status: "active"
|
|
171
|
+
})
|
|
172
|
+
.select("workspace_id")
|
|
173
|
+
.orderBy("workspace_id", "asc");
|
|
174
|
+
|
|
175
|
+
return rows
|
|
176
|
+
.map((row) => normalizeDbRecordId(row.workspace_id, { fallback: null }))
|
|
177
|
+
.filter(Boolean);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
withTransaction,
|
|
182
|
+
findByWorkspaceIdAndUserId,
|
|
183
|
+
ensureOwnerMembership,
|
|
184
|
+
upsertMembership,
|
|
185
|
+
listActiveByWorkspaceId,
|
|
186
|
+
listActiveWorkspaceIdsByUserId
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { createRepository, mapRow, mapMemberSummaryRow };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { resolveInsertedRecordId } from "@jskit-ai/database-runtime/shared";
|
|
2
|
+
import {
|
|
3
|
+
normalizeDbRecordId,
|
|
4
|
+
normalizeRecordId,
|
|
5
|
+
normalizeText,
|
|
6
|
+
normalizeLowerText,
|
|
7
|
+
toIsoString,
|
|
8
|
+
toNullableIso,
|
|
9
|
+
nowDb,
|
|
10
|
+
isDuplicateEntryError,
|
|
11
|
+
createWithTransaction
|
|
12
|
+
} from "./repositoryUtils.js";
|
|
13
|
+
|
|
14
|
+
function mapRow(row) {
|
|
15
|
+
if (!row) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
id: normalizeDbRecordId(row.id, { fallback: "" }),
|
|
21
|
+
slug: normalizeText(row.slug),
|
|
22
|
+
name: normalizeText(row.name),
|
|
23
|
+
ownerUserId: normalizeDbRecordId(row.owner_user_id, { fallback: "" }),
|
|
24
|
+
isPersonal: Boolean(row.is_personal),
|
|
25
|
+
avatarUrl: row.avatar_url ? normalizeText(row.avatar_url) : "",
|
|
26
|
+
createdAt: toIsoString(row.created_at),
|
|
27
|
+
updatedAt: toIsoString(row.updated_at),
|
|
28
|
+
deletedAt: toNullableIso(row.deleted_at)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mapMembershipWorkspaceRow(row) {
|
|
33
|
+
if (!row) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...mapRow(row),
|
|
39
|
+
roleSid: normalizeLowerText(row.role_sid || "member"),
|
|
40
|
+
membershipStatus: normalizeLowerText(row.membership_status || "active") || "active"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createRepository(knex) {
|
|
45
|
+
if (typeof knex !== "function") {
|
|
46
|
+
throw new TypeError("workspacesRepository requires knex.");
|
|
47
|
+
}
|
|
48
|
+
const withTransaction = createWithTransaction(knex);
|
|
49
|
+
|
|
50
|
+
function workspaceSelectColumns({ includeMembership = false } = {}) {
|
|
51
|
+
const columns = [
|
|
52
|
+
"w.id",
|
|
53
|
+
"w.slug",
|
|
54
|
+
"w.name",
|
|
55
|
+
"w.owner_user_id",
|
|
56
|
+
"w.is_personal",
|
|
57
|
+
"w.avatar_url",
|
|
58
|
+
"w.created_at",
|
|
59
|
+
"w.updated_at",
|
|
60
|
+
"w.deleted_at"
|
|
61
|
+
];
|
|
62
|
+
if (includeMembership) {
|
|
63
|
+
columns.push("wm.role_sid", "wm.status as membership_status");
|
|
64
|
+
}
|
|
65
|
+
return columns;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function findById(workspaceId, options = {}) {
|
|
69
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
70
|
+
if (!normalizedWorkspaceId) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const client = options?.trx || knex;
|
|
75
|
+
const row = await client("workspaces as w")
|
|
76
|
+
.where({ "w.id": normalizedWorkspaceId })
|
|
77
|
+
.select(workspaceSelectColumns())
|
|
78
|
+
.first();
|
|
79
|
+
return mapRow(row);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function findBySlug(slug, options = {}) {
|
|
83
|
+
const client = options?.trx || knex;
|
|
84
|
+
const normalizedSlug = normalizeLowerText(slug);
|
|
85
|
+
if (!normalizedSlug) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const row = await client("workspaces as w")
|
|
90
|
+
.where({ "w.slug": normalizedSlug })
|
|
91
|
+
.select(workspaceSelectColumns())
|
|
92
|
+
.first();
|
|
93
|
+
return mapRow(row);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function findPersonalByOwnerUserId(userId, options = {}) {
|
|
97
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
98
|
+
if (!normalizedUserId) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const client = options?.trx || knex;
|
|
103
|
+
const row = await client("workspaces as w")
|
|
104
|
+
.where({ "w.owner_user_id": normalizedUserId, "w.is_personal": 1 })
|
|
105
|
+
.orderBy("w.id", "asc")
|
|
106
|
+
.select(workspaceSelectColumns())
|
|
107
|
+
.first();
|
|
108
|
+
return mapRow(row);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function insert(payload = {}, options = {}) {
|
|
112
|
+
const client = options?.trx || knex;
|
|
113
|
+
const source = payload && typeof payload === "object" ? payload : {};
|
|
114
|
+
const ownerUserId = normalizeRecordId(source.ownerUserId, { fallback: null });
|
|
115
|
+
if (!ownerUserId) {
|
|
116
|
+
throw new TypeError("workspacesRepository.insert requires ownerUserId.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const insertPayload = {
|
|
120
|
+
slug: normalizeLowerText(source.slug),
|
|
121
|
+
name: normalizeText(source.name),
|
|
122
|
+
owner_user_id: ownerUserId,
|
|
123
|
+
is_personal: source.isPersonal ? 1 : 0,
|
|
124
|
+
avatar_url: normalizeText(source.avatarUrl),
|
|
125
|
+
created_at: nowDb(),
|
|
126
|
+
updated_at: nowDb(),
|
|
127
|
+
deleted_at: null
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const result = await client("workspaces").insert(insertPayload);
|
|
132
|
+
const insertedId = resolveInsertedRecordId(result, { fallback: null });
|
|
133
|
+
if (insertedId) {
|
|
134
|
+
return findById(insertedId, { trx: client });
|
|
135
|
+
}
|
|
136
|
+
const bySlug = await findBySlug(insertPayload.slug, { trx: client });
|
|
137
|
+
return bySlug;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!isDuplicateEntryError(error)) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const bySlug = await findBySlug(insertPayload.slug, { trx: client });
|
|
143
|
+
if (bySlug) {
|
|
144
|
+
return bySlug;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function updateById(workspaceId, patch = {}, options = {}) {
|
|
151
|
+
const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
|
|
152
|
+
if (!normalizedWorkspaceId) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const client = options?.trx || knex;
|
|
157
|
+
const source = patch && typeof patch === "object" ? patch : {};
|
|
158
|
+
const dbPatch = {
|
|
159
|
+
updated_at: nowDb()
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (Object.hasOwn(source, "name")) {
|
|
163
|
+
dbPatch.name = normalizeText(source.name);
|
|
164
|
+
}
|
|
165
|
+
if (Object.hasOwn(source, "avatarUrl")) {
|
|
166
|
+
dbPatch.avatar_url = normalizeText(source.avatarUrl);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await client("workspaces").where({ id: normalizedWorkspaceId }).update(dbPatch);
|
|
170
|
+
return findById(normalizedWorkspaceId, { trx: client });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function listForUserId(userId, options = {}) {
|
|
174
|
+
const normalizedUserId = normalizeRecordId(userId, { fallback: null });
|
|
175
|
+
if (!normalizedUserId) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const client = options?.trx || knex;
|
|
180
|
+
const rows = await client("workspace_memberships as wm")
|
|
181
|
+
.join("workspaces as w", "w.id", "wm.workspace_id")
|
|
182
|
+
.where({ "wm.user_id": normalizedUserId })
|
|
183
|
+
.whereNull("w.deleted_at")
|
|
184
|
+
.orderBy("w.is_personal", "desc")
|
|
185
|
+
.orderBy("w.id", "asc")
|
|
186
|
+
.select(workspaceSelectColumns({ includeMembership: true }));
|
|
187
|
+
|
|
188
|
+
return rows.map(mapMembershipWorkspaceRow).filter(Boolean);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
withTransaction,
|
|
193
|
+
findById,
|
|
194
|
+
findBySlug,
|
|
195
|
+
findPersonalByOwnerUserId,
|
|
196
|
+
insert,
|
|
197
|
+
updateById,
|
|
198
|
+
listForUserId
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { createRepository, mapRow, mapMembershipWorkspaceRow };
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
3
|
+
import {
|
|
4
|
+
TENANCY_MODE_NONE,
|
|
5
|
+
resolveTenancyProfile
|
|
6
|
+
} from "../../../shared/tenancyProfile.js";
|
|
7
|
+
import {
|
|
8
|
+
resolveRolePermissions
|
|
9
|
+
} from "../../../shared/roles.js";
|
|
10
|
+
import {
|
|
11
|
+
mapWorkspaceSummary
|
|
12
|
+
} from "../formatters/workspaceFormatter.js";
|
|
13
|
+
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
14
|
+
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
15
|
+
import { authenticatedUserValidator } from "../validators/authenticatedUserValidator.js";
|
|
16
|
+
|
|
17
|
+
function toSlugPart(value) {
|
|
18
|
+
const normalized = normalizeLowerText(value)
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "")
|
|
21
|
+
.slice(0, 48);
|
|
22
|
+
return normalized || "workspace";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildWorkspaceBaseSlug(user = {}) {
|
|
26
|
+
const username = normalizeLowerText(user.username);
|
|
27
|
+
if (username) {
|
|
28
|
+
return toSlugPart(username);
|
|
29
|
+
}
|
|
30
|
+
const displayName = normalizeText(user.displayName);
|
|
31
|
+
if (displayName) {
|
|
32
|
+
return toSlugPart(displayName);
|
|
33
|
+
}
|
|
34
|
+
const email = normalizeLowerText(user.email);
|
|
35
|
+
if (email.includes("@")) {
|
|
36
|
+
return toSlugPart(email.split("@")[0]);
|
|
37
|
+
}
|
|
38
|
+
return "workspace";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildWorkspaceName(user = {}) {
|
|
42
|
+
const displayName = normalizeText(user.displayName);
|
|
43
|
+
if (displayName) {
|
|
44
|
+
return `${displayName}'s Workspace`;
|
|
45
|
+
}
|
|
46
|
+
const email = normalizeLowerText(user.email);
|
|
47
|
+
if (email) {
|
|
48
|
+
return `${email}'s Workspace`;
|
|
49
|
+
}
|
|
50
|
+
return "Workspace";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildPermissionsFromMembership(membership, appConfig = {}) {
|
|
54
|
+
const roleSid = normalizeLowerText(membership?.roleSid || "member");
|
|
55
|
+
return resolveRolePermissions(roleSid, appConfig);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hashInviteToken(token) {
|
|
59
|
+
return createHash("sha256").update(normalizeText(token)).digest("hex");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeWorkspaceCreationInput(payload = {}) {
|
|
63
|
+
const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
64
|
+
return {
|
|
65
|
+
name: normalizeText(source.name),
|
|
66
|
+
requestedSlug: normalizeLowerText(source.slug)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createService({
|
|
71
|
+
appConfig = {},
|
|
72
|
+
workspacesRepository,
|
|
73
|
+
workspaceMembershipsRepository,
|
|
74
|
+
workspaceSettingsRepository
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (
|
|
77
|
+
!workspacesRepository ||
|
|
78
|
+
!workspaceMembershipsRepository ||
|
|
79
|
+
!workspaceSettingsRepository
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("workspaceService requires repositories.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const resolvedTenancyProfile = resolveTenancyProfile(appConfig);
|
|
85
|
+
const resolvedTenancyMode = resolvedTenancyProfile.mode;
|
|
86
|
+
const workspacePolicy = resolvedTenancyProfile.workspace;
|
|
87
|
+
async function ensureUniqueWorkspaceSlug(baseSlug, options = {}) {
|
|
88
|
+
let suffix = 0;
|
|
89
|
+
while (suffix < 1000) {
|
|
90
|
+
suffix += 1;
|
|
91
|
+
const candidate = suffix === 1 ? toSlugPart(baseSlug) : `${toSlugPart(baseSlug)}-${suffix}`;
|
|
92
|
+
const existing = await workspacesRepository.findBySlug(candidate, options);
|
|
93
|
+
if (!existing) {
|
|
94
|
+
return candidate;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new AppError(500, "Unable to generate unique workspace slug.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function ensureWorkspaceSettingsForWorkspace(workspace, options = {}) {
|
|
101
|
+
return workspaceSettingsRepository.ensureForWorkspaceId(workspace?.id, {
|
|
102
|
+
...options,
|
|
103
|
+
workspace
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensurePersonalWorkspaceForUser(user, options = {}) {
|
|
108
|
+
const normalizedUser = authenticatedUserValidator.normalize(user);
|
|
109
|
+
if (!normalizedUser) {
|
|
110
|
+
throw new AppError(400, "Invalid authenticated user payload.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const existing = await workspacesRepository.findPersonalByOwnerUserId(normalizedUser.id, options);
|
|
114
|
+
if (existing) {
|
|
115
|
+
await workspaceMembershipsRepository.ensureOwnerMembership(existing.id, normalizedUser.id, options);
|
|
116
|
+
await ensureWorkspaceSettingsForWorkspace(existing, options);
|
|
117
|
+
return existing;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const slug = await ensureUniqueWorkspaceSlug(buildWorkspaceBaseSlug(normalizedUser), options);
|
|
121
|
+
const inserted = await workspacesRepository.insert(
|
|
122
|
+
{
|
|
123
|
+
slug,
|
|
124
|
+
name: buildWorkspaceName(normalizedUser),
|
|
125
|
+
ownerUserId: normalizedUser.id,
|
|
126
|
+
isPersonal: true,
|
|
127
|
+
avatarUrl: ""
|
|
128
|
+
},
|
|
129
|
+
options
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await workspaceMembershipsRepository.ensureOwnerMembership(inserted.id, normalizedUser.id, options);
|
|
133
|
+
await ensureWorkspaceSettingsForWorkspace(inserted, options);
|
|
134
|
+
return inserted;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function listWorkspacesForUser(user, options = {}) {
|
|
138
|
+
const normalizedUser = authenticatedUserValidator.normalize(user);
|
|
139
|
+
if (!normalizedUser) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (resolvedTenancyMode === TENANCY_MODE_NONE) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const list = await workspacesRepository.listForUserId(normalizedUser.id, options);
|
|
148
|
+
const accessible = list
|
|
149
|
+
.map((entry) => mapWorkspaceSummary(entry, { roleSid: entry.roleSid, status: entry.membershipStatus }))
|
|
150
|
+
.filter((entry) => entry.isAccessible);
|
|
151
|
+
|
|
152
|
+
return accessible;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function listWorkspacesForAuthenticatedUser(user, options = {}) {
|
|
156
|
+
return listWorkspacesForUser(user, options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function provisionWorkspaceForNewUser(user, options = {}) {
|
|
160
|
+
const normalizedUser = authenticatedUserValidator.normalize(user);
|
|
161
|
+
if (!normalizedUser) {
|
|
162
|
+
throw new AppError(400, "Invalid authenticated user payload.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (workspacePolicy.autoProvision !== true) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return ensurePersonalWorkspaceForUser(normalizedUser, options);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function createWorkspaceForAuthenticatedUser(user, payload = {}, options = {}) {
|
|
173
|
+
const normalizedUser = authenticatedUserValidator.normalize(user);
|
|
174
|
+
if (!normalizedUser) {
|
|
175
|
+
throw new AppError(401, "Authentication required.");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (workspacePolicy.allowSelfCreate !== true) {
|
|
179
|
+
throw new AppError(403, "Workspace creation is disabled for this tenancy mode.");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const createInput = normalizeWorkspaceCreationInput(payload);
|
|
183
|
+
if (!createInput.name) {
|
|
184
|
+
throw new AppError(400, "Workspace name is required.");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const slugBase = createInput.requestedSlug || toSlugPart(createInput.name);
|
|
188
|
+
const slug = await ensureUniqueWorkspaceSlug(slugBase, options);
|
|
189
|
+
const inserted = await workspacesRepository.insert(
|
|
190
|
+
{
|
|
191
|
+
slug,
|
|
192
|
+
name: createInput.name,
|
|
193
|
+
ownerUserId: normalizedUser.id,
|
|
194
|
+
isPersonal: false,
|
|
195
|
+
avatarUrl: ""
|
|
196
|
+
},
|
|
197
|
+
options
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await workspaceMembershipsRepository.ensureOwnerMembership(inserted.id, normalizedUser.id, options);
|
|
201
|
+
await ensureWorkspaceSettingsForWorkspace(inserted, options);
|
|
202
|
+
return inserted;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function getWorkspaceForAuthenticatedUser(user, workspaceSlug, options = {}) {
|
|
206
|
+
const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
|
|
207
|
+
return workspaceContext.workspace;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function updateWorkspaceForAuthenticatedUser(user, workspaceSlug, patch = {}, options = {}) {
|
|
211
|
+
const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
|
|
212
|
+
return workspacesRepository.updateById(workspaceContext.workspace.id, patch, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
|
|
216
|
+
const normalizedUser = authenticatedUserValidator.normalize(user);
|
|
217
|
+
if (!normalizedUser) {
|
|
218
|
+
throw new AppError(401, "Authentication required.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (resolvedTenancyMode === TENANCY_MODE_NONE) {
|
|
222
|
+
throw new AppError(403, "Workspace context is disabled.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const normalizedWorkspaceSlug = normalizeLowerText(workspaceSlug);
|
|
226
|
+
if (!normalizedWorkspaceSlug) {
|
|
227
|
+
throw new AppError(400, "workspaceSlug is required.");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const workspace = await workspacesRepository.findBySlug(normalizedWorkspaceSlug, options);
|
|
231
|
+
|
|
232
|
+
if (!workspace) {
|
|
233
|
+
throw new AppError(404, "Workspace not found.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let membership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(
|
|
237
|
+
workspace.id,
|
|
238
|
+
normalizedUser.id,
|
|
239
|
+
options
|
|
240
|
+
);
|
|
241
|
+
const actorOwnsWorkspace =
|
|
242
|
+
normalizeRecordId(workspace.ownerUserId, { fallback: null }) ===
|
|
243
|
+
normalizeRecordId(normalizedUser.id, { fallback: null });
|
|
244
|
+
const membershipIsActive = normalizeLowerText(membership?.status) === "active";
|
|
245
|
+
|
|
246
|
+
if (!membershipIsActive && actorOwnsWorkspace) {
|
|
247
|
+
membership = await workspaceMembershipsRepository.ensureOwnerMembership(workspace.id, normalizedUser.id, options);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!membership || normalizeLowerText(membership.status) !== "active") {
|
|
251
|
+
throw new AppError(403, "You do not have access to this workspace.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const workspaceSettings = await ensureWorkspaceSettingsForWorkspace(workspace, options);
|
|
255
|
+
const permissions = buildPermissionsFromMembership(membership, appConfig);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
workspace,
|
|
259
|
+
membership,
|
|
260
|
+
permissions,
|
|
261
|
+
workspaceSettings
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
toSlugPart,
|
|
267
|
+
buildWorkspaceName,
|
|
268
|
+
buildWorkspaceBaseSlug,
|
|
269
|
+
hashInviteToken,
|
|
270
|
+
ensurePersonalWorkspaceForUser,
|
|
271
|
+
provisionWorkspaceForNewUser,
|
|
272
|
+
createWorkspaceForAuthenticatedUser,
|
|
273
|
+
getWorkspaceForAuthenticatedUser,
|
|
274
|
+
updateWorkspaceForAuthenticatedUser,
|
|
275
|
+
listWorkspacesForUser,
|
|
276
|
+
listWorkspacesForAuthenticatedUser,
|
|
277
|
+
resolveWorkspaceContextForUserBySlug
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export { createService };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
|