@csntgao/uni-base 0.1.0
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/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
- package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OrganizationMultiEmployeeSelectorTransportError,
|
|
3
|
+
type OrganizationDepartmentNode,
|
|
4
|
+
type OrganizationEmployeeEmploymentStatus,
|
|
5
|
+
type OrganizationEmployeePage,
|
|
6
|
+
type OrganizationEmployeeSelectionItem,
|
|
7
|
+
type UniplatOrganizationMultiEmployeeSelectorTransport,
|
|
8
|
+
} from './organization-multi-employee-selector'
|
|
9
|
+
|
|
10
|
+
export interface UniplatBaseOrganizationMultiEmployeeSelectorTransportOptions {
|
|
11
|
+
serviceBaseUrl: string
|
|
12
|
+
getAccessToken(): string | Promise<string>
|
|
13
|
+
fetch?: typeof fetch
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DIRECTORY_SERVICE = 'system.org_directory'
|
|
17
|
+
const TREE_ACTION = 'tree_full'
|
|
18
|
+
const NODE_CONTACTS_ACTION = 'node_contacts'
|
|
19
|
+
const SEARCH_ACTION = 'contact_search'
|
|
20
|
+
const CONTACT_GET_ACTION = 'contact_get'
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
22
|
+
const RESOLVE_CONCURRENCY = 4
|
|
23
|
+
|
|
24
|
+
export function createUniplatBaseOrganizationMultiEmployeeSelectorTransport(
|
|
25
|
+
options: UniplatBaseOrganizationMultiEmployeeSelectorTransportOptions,
|
|
26
|
+
): UniplatOrganizationMultiEmployeeSelectorTransport {
|
|
27
|
+
if (!options || typeof options.getAccessToken !== 'function') {
|
|
28
|
+
throw new TypeError('Organization employee selector transport requires getAccessToken()')
|
|
29
|
+
}
|
|
30
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
31
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
32
|
+
|
|
33
|
+
const request = async (action: string, payload: Record<string, unknown>): Promise<unknown> => {
|
|
34
|
+
let accessToken = ''
|
|
35
|
+
try {
|
|
36
|
+
accessToken = await resolveAccessToken(options.getAccessToken)
|
|
37
|
+
return await requestData(
|
|
38
|
+
fetchImplementation,
|
|
39
|
+
`${serviceBaseUrl}/${DIRECTORY_SERVICE}/${action}`,
|
|
40
|
+
accessToken,
|
|
41
|
+
{ payload: { directory_scope: 'organization', ...payload } },
|
|
42
|
+
)
|
|
43
|
+
} finally {
|
|
44
|
+
accessToken = ''
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
async loadDepartmentTree(): Promise<OrganizationDepartmentNode[]> {
|
|
50
|
+
const data = record(await request(TREE_ACTION, {}))
|
|
51
|
+
if (!Array.isArray(data.nodes)) throw invalidResponse()
|
|
52
|
+
return data.nodes.map(parseDepartment)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async listDepartmentEmployees(input): Promise<OrganizationEmployeePage> {
|
|
56
|
+
const departmentId = positiveIdentifier(input.departmentId)
|
|
57
|
+
if (!departmentId) throw new TypeError('departmentId is invalid')
|
|
58
|
+
const page = positiveInteger(input.page, 'page')
|
|
59
|
+
const pageSize = boundedPageSize(input.pageSize)
|
|
60
|
+
validateEmploymentStatuses(input.employmentStatuses)
|
|
61
|
+
const data = record(
|
|
62
|
+
await request(NODE_CONTACTS_ACTION, {
|
|
63
|
+
node_id: departmentId,
|
|
64
|
+
page,
|
|
65
|
+
page_size: pageSize,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
return parseEmployeePage(data, page, pageSize)
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async searchEmployees(input): Promise<OrganizationEmployeePage> {
|
|
72
|
+
const page = positiveInteger(input.page, 'page')
|
|
73
|
+
const pageSize = boundedPageSize(input.pageSize)
|
|
74
|
+
validateEmploymentStatuses(input.employmentStatuses)
|
|
75
|
+
const keyword = optionalText(input.keyword, 80)
|
|
76
|
+
const data = record(
|
|
77
|
+
await request(SEARCH_ACTION, {
|
|
78
|
+
keyword,
|
|
79
|
+
status: 1,
|
|
80
|
+
employment_status: 'active',
|
|
81
|
+
page,
|
|
82
|
+
page_size: pageSize,
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
return parseEmployeePage(data, page, pageSize)
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async resolveEmployees(input): Promise<OrganizationEmployeeSelectionItem[]> {
|
|
89
|
+
const employeeIds = uniqueEmployeeIds(input.employeeIds)
|
|
90
|
+
const employees: OrganizationEmployeeSelectionItem[] = []
|
|
91
|
+
for (let index = 0; index < employeeIds.length; index += RESOLVE_CONCURRENCY) {
|
|
92
|
+
const batch = employeeIds.slice(index, index + RESOLVE_CONCURRENCY)
|
|
93
|
+
const results = await Promise.all(
|
|
94
|
+
batch.map(async (employeeId) => {
|
|
95
|
+
const data = record(await request(CONTACT_GET_ACTION, { employee_id: employeeId }))
|
|
96
|
+
return isSelectableEmployeeRecord(data) ? parseEmployee(data) : null
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
employees.push(
|
|
100
|
+
...results.filter(
|
|
101
|
+
(employee): employee is OrganizationEmployeeSelectionItem => employee !== null,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
return employees
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function requestData(
|
|
111
|
+
fetchImplementation: typeof fetch | undefined,
|
|
112
|
+
endpoint: string,
|
|
113
|
+
accessToken: string,
|
|
114
|
+
body: Record<string, unknown>,
|
|
115
|
+
): Promise<unknown> {
|
|
116
|
+
if (!fetchImplementation) throw requestFailed('REQUEST_FAILED', true)
|
|
117
|
+
const controller = new AbortController()
|
|
118
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
|
119
|
+
let response: Response
|
|
120
|
+
let payload: unknown
|
|
121
|
+
try {
|
|
122
|
+
response = await fetchImplementation(endpoint, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
credentials: 'include',
|
|
125
|
+
cache: 'no-store',
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
headers: {
|
|
128
|
+
accept: 'application/json',
|
|
129
|
+
authorization: `Bearer ${accessToken}`,
|
|
130
|
+
'content-type': 'application/json',
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
})
|
|
134
|
+
payload = await response.json()
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
137
|
+
throw requestFailed('TIMEOUT', true)
|
|
138
|
+
}
|
|
139
|
+
throw requestFailed('REQUEST_FAILED', true)
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timeout)
|
|
142
|
+
}
|
|
143
|
+
if (response.status === 401) throw requestFailed('AUTH_REQUIRED', false)
|
|
144
|
+
if (response.status === 403) throw requestFailed('FORBIDDEN', false)
|
|
145
|
+
const envelope = record(payload)
|
|
146
|
+
if (!response.ok || ('rescode' in envelope && integer(envelope.rescode) !== 0)) {
|
|
147
|
+
const code = optionalText(envelope.error_code, 80)
|
|
148
|
+
if (code === 'UNAUTHORIZED' || code === 'AUTH_REQUIRED') {
|
|
149
|
+
throw requestFailed('AUTH_REQUIRED', false)
|
|
150
|
+
}
|
|
151
|
+
if (code === 'FORBIDDEN' || code.endsWith('_FORBIDDEN')) {
|
|
152
|
+
throw requestFailed('FORBIDDEN', false)
|
|
153
|
+
}
|
|
154
|
+
throw requestFailed('SERVICE_ERROR', true)
|
|
155
|
+
}
|
|
156
|
+
return 'rescode' in envelope ? envelope.data : payload
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseDepartment(value: unknown): OrganizationDepartmentNode {
|
|
160
|
+
const data = record(value)
|
|
161
|
+
const id = positiveIdentifier(data.id)
|
|
162
|
+
const name = requiredText(data.name, 100)
|
|
163
|
+
if (!id) throw invalidResponse()
|
|
164
|
+
const parentId = positiveIdentifier(data.parent_id)
|
|
165
|
+
const result: OrganizationDepartmentNode = {
|
|
166
|
+
id,
|
|
167
|
+
name,
|
|
168
|
+
depth: nonNegativeInteger(data.depth),
|
|
169
|
+
}
|
|
170
|
+
if (parentId) result.parentId = parentId
|
|
171
|
+
if (data.member_count !== undefined) result.memberCount = nonNegativeInteger(data.member_count)
|
|
172
|
+
return Object.freeze(result)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseEmployeePage(
|
|
176
|
+
data: Record<string, unknown>,
|
|
177
|
+
fallbackPage: number,
|
|
178
|
+
fallbackPageSize: number,
|
|
179
|
+
): OrganizationEmployeePage {
|
|
180
|
+
if (!Array.isArray(data.items)) throw invalidResponse()
|
|
181
|
+
const page = positiveIntegerOr(data.page, fallbackPage)
|
|
182
|
+
const pageSize = positiveIntegerOr(data.page_size, fallbackPageSize)
|
|
183
|
+
const total = nonNegativeInteger(data.total)
|
|
184
|
+
const items = dedupeEmployees(data.items.filter(isSelectableEmployeeRecord).map(parseEmployee))
|
|
185
|
+
return Object.freeze({
|
|
186
|
+
items,
|
|
187
|
+
page,
|
|
188
|
+
pageSize,
|
|
189
|
+
total,
|
|
190
|
+
hasMore: page * pageSize < total,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isSelectableEmployeeRecord(value: unknown): boolean {
|
|
195
|
+
const data = record(value)
|
|
196
|
+
if ('enable' in data && integer(data.enable) !== 1) return false
|
|
197
|
+
if ('active' in data && integer(data.active) !== 1) return false
|
|
198
|
+
if ('status' in data && integer(data.status) !== 1) return false
|
|
199
|
+
return employmentStatus(data.employment_status, data.status) === 'active'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseEmployee(value: unknown): OrganizationEmployeeSelectionItem {
|
|
203
|
+
const data = record(value)
|
|
204
|
+
const employeeId = positiveIdentifier(data.employee_id ?? data.eid)
|
|
205
|
+
const displayName = requiredText(data.display_name ?? data.nickname ?? data.name, 80)
|
|
206
|
+
if (!employeeId) throw invalidResponse()
|
|
207
|
+
const departments = parseDepartments(data)
|
|
208
|
+
const primary = parsePrimaryDepartment(data, departments)
|
|
209
|
+
const maskedMobile = safeMaskedMobile(data.mobile_masked ?? data.mobile)
|
|
210
|
+
const avatarFileId = optionalText(data.avatar_file_id, 128)
|
|
211
|
+
const avatarColor = optionalText(data.avatar_color, 32)
|
|
212
|
+
const result: OrganizationEmployeeSelectionItem = {
|
|
213
|
+
employeeId,
|
|
214
|
+
displayName,
|
|
215
|
+
departmentIds: departments.map(({ id }) => id),
|
|
216
|
+
departmentNames: departments.map(({ name }) => name),
|
|
217
|
+
employmentStatus: employmentStatus(data.employment_status, data.status),
|
|
218
|
+
}
|
|
219
|
+
if (maskedMobile) result.maskedMobile = maskedMobile
|
|
220
|
+
if (primary?.id) result.primaryDepartmentId = primary.id
|
|
221
|
+
if (primary?.name) result.primaryDepartmentName = primary.name
|
|
222
|
+
if (avatarFileId) result.avatar = Object.freeze({ kind: 'file', fileId: avatarFileId })
|
|
223
|
+
else if (/^#[0-9a-fA-F]{6}$/.test(avatarColor)) {
|
|
224
|
+
result.avatar = Object.freeze({ kind: 'color', color: avatarColor })
|
|
225
|
+
} else result.avatar = Object.freeze({ kind: 'none' })
|
|
226
|
+
return Object.freeze(result)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseDepartments(data: Record<string, unknown>): Array<{ id: string; name: string }> {
|
|
230
|
+
const departments: Array<{ id: string; name: string }> = []
|
|
231
|
+
const values = Array.isArray(data.nodes)
|
|
232
|
+
? data.nodes
|
|
233
|
+
: Array.isArray(data.assignments)
|
|
234
|
+
? data.assignments
|
|
235
|
+
: []
|
|
236
|
+
for (const value of values) {
|
|
237
|
+
const node = record(value)
|
|
238
|
+
const id = positiveIdentifier(node.id ?? node.node_id)
|
|
239
|
+
const name = optionalText(node.name ?? node.node_name, 100)
|
|
240
|
+
if (id && name && !departments.some((department) => department.id === id)) {
|
|
241
|
+
departments.push({ id, name })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const nodeId = positiveIdentifier(data.node_id)
|
|
245
|
+
const nodeName = optionalText(data.node_name, 100)
|
|
246
|
+
if (nodeId && nodeName && !departments.some((department) => department.id === nodeId)) {
|
|
247
|
+
departments.push({ id: nodeId, name: nodeName })
|
|
248
|
+
}
|
|
249
|
+
return departments
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parsePrimaryDepartment(
|
|
253
|
+
data: Record<string, unknown>,
|
|
254
|
+
departments: readonly { id: string; name: string }[],
|
|
255
|
+
): { id: string; name: string } | null {
|
|
256
|
+
const primary = record(data.primary_node)
|
|
257
|
+
const primaryId = positiveIdentifier(primary.id ?? primary.node_id)
|
|
258
|
+
const primaryName = optionalText(primary.name ?? primary.node_name, 100)
|
|
259
|
+
if (primaryId && primaryName) return { id: primaryId, name: primaryName }
|
|
260
|
+
if (data.is_primary === true) {
|
|
261
|
+
const nodeId = positiveIdentifier(data.node_id)
|
|
262
|
+
const nodeName = optionalText(data.node_name, 100)
|
|
263
|
+
if (nodeId && nodeName) return { id: nodeId, name: nodeName }
|
|
264
|
+
}
|
|
265
|
+
return departments[0] ?? null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function employmentStatus(value: unknown, status: unknown): OrganizationEmployeeEmploymentStatus {
|
|
269
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
|
|
270
|
+
if (normalized === 'active') return 'active'
|
|
271
|
+
if (normalized === 'pending') return 'pending'
|
|
272
|
+
if (normalized === 'rejected') return 'rejected'
|
|
273
|
+
if (normalized === 'left' || normalized === 'departed') return 'left'
|
|
274
|
+
if (!normalized && integer(status) === 1) return 'active'
|
|
275
|
+
return 'unknown'
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function dedupeEmployees(
|
|
279
|
+
employees: readonly OrganizationEmployeeSelectionItem[],
|
|
280
|
+
): OrganizationEmployeeSelectionItem[] {
|
|
281
|
+
return [...new Map(employees.map((employee) => [employee.employeeId, employee])).values()]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function uniqueEmployeeIds(values: readonly string[]): string[] {
|
|
285
|
+
if (!Array.isArray(values)) throw new TypeError('employeeIds must be an array')
|
|
286
|
+
const result: string[] = []
|
|
287
|
+
for (const value of values) {
|
|
288
|
+
const employeeId = positiveIdentifier(value)
|
|
289
|
+
if (!employeeId) throw new TypeError('employeeId is invalid')
|
|
290
|
+
if (!result.includes(employeeId)) result.push(employeeId)
|
|
291
|
+
}
|
|
292
|
+
return result
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validateEmploymentStatuses(value: readonly string[]): void {
|
|
296
|
+
if (!Array.isArray(value) || value.length !== 1 || value[0] !== 'active') {
|
|
297
|
+
throw new TypeError('Only active employment status is supported')
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function safeMaskedMobile(value: unknown): string {
|
|
302
|
+
const mobile = optionalText(value, 32)
|
|
303
|
+
return mobile && !/^1[3-9]\d{9}$/.test(mobile) ? mobile : ''
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function positiveInteger(value: unknown, field: string): number {
|
|
307
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
308
|
+
throw new TypeError(`${field} must be a positive integer`)
|
|
309
|
+
}
|
|
310
|
+
return value
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function boundedPageSize(value: unknown): number {
|
|
314
|
+
const pageSize = positiveInteger(value, 'pageSize')
|
|
315
|
+
if (pageSize > 200) throw new TypeError('pageSize must not exceed 200')
|
|
316
|
+
return pageSize
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function positiveIntegerOr(value: unknown, fallback: number): number {
|
|
320
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : fallback
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function nonNegativeInteger(value: unknown): number {
|
|
324
|
+
const normalized = Number(value)
|
|
325
|
+
return Number.isSafeInteger(normalized) && normalized >= 0 ? normalized : 0
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function positiveIdentifier(value: unknown): string {
|
|
329
|
+
const normalized =
|
|
330
|
+
typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
|
331
|
+
return /^[1-9]\d{0,19}$/.test(normalized) ? normalized : ''
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function requiredText(value: unknown, maximumLength: number): string {
|
|
335
|
+
const normalized = optionalText(value, maximumLength)
|
|
336
|
+
if (!normalized) throw invalidResponse()
|
|
337
|
+
return normalized
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function optionalText(value: unknown, maximumLength: number): string {
|
|
341
|
+
return typeof value === 'string' ? value.trim().slice(0, maximumLength) : ''
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function record(value: unknown): Record<string, unknown> {
|
|
345
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
346
|
+
? (value as Record<string, unknown>)
|
|
347
|
+
: {}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function integer(value: unknown): number {
|
|
351
|
+
const normalized = Number(value)
|
|
352
|
+
return Number.isFinite(normalized) ? Math.trunc(normalized) : -1
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function resolveAccessToken(provider: () => string | Promise<string>): Promise<string> {
|
|
356
|
+
let token = ''
|
|
357
|
+
try {
|
|
358
|
+
const value = await provider()
|
|
359
|
+
token = typeof value === 'string' ? value.trim() : ''
|
|
360
|
+
} catch {
|
|
361
|
+
// Provider errors can contain credentials and are intentionally discarded.
|
|
362
|
+
}
|
|
363
|
+
if (!token) throw requestFailed('AUTH_REQUIRED', false)
|
|
364
|
+
return token
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function validateServiceBaseUrl(value: string): string {
|
|
368
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
369
|
+
if (!raw) throw new TypeError('Organization employee selector serviceBaseUrl is required')
|
|
370
|
+
const base = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
371
|
+
let parsed: URL
|
|
372
|
+
try {
|
|
373
|
+
parsed = new URL(raw, base)
|
|
374
|
+
} catch {
|
|
375
|
+
throw new TypeError('Organization employee selector serviceBaseUrl is invalid')
|
|
376
|
+
}
|
|
377
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
378
|
+
throw new TypeError('Organization employee selector serviceBaseUrl must use safe HTTP(S)')
|
|
379
|
+
}
|
|
380
|
+
if (parsed.search || parsed.hash) {
|
|
381
|
+
throw new TypeError(
|
|
382
|
+
'Organization employee selector serviceBaseUrl must not contain query or hash',
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
return parsed.href.replace(/\/$/, '')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function requestFailed(code: string, retryable: boolean) {
|
|
389
|
+
return new OrganizationMultiEmployeeSelectorTransportError(code, retryable)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function invalidResponse() {
|
|
393
|
+
return requestFailed('INVALID_RESPONSE', false)
|
|
394
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OrganizationOnboardingTransportError,
|
|
3
|
+
type CreatedOnboardingOrganization,
|
|
4
|
+
type OnboardingOrganizationType,
|
|
5
|
+
type OrganizationOnboardingTransport,
|
|
6
|
+
type OrganizationOnboardingTransportErrorCode,
|
|
7
|
+
} from './organization-onboarding-transport'
|
|
8
|
+
|
|
9
|
+
export interface UniplatBaseOrganizationOnboardingTransportOptions {
|
|
10
|
+
serviceBaseUrl: string
|
|
11
|
+
getAccessToken(): string | Promise<string>
|
|
12
|
+
fetch?: typeof fetch
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CREATE_PATH = 'system.org/create_organization'
|
|
16
|
+
|
|
17
|
+
export function createUniplatBaseOrganizationOnboardingTransport(
|
|
18
|
+
options: UniplatBaseOrganizationOnboardingTransportOptions,
|
|
19
|
+
): OrganizationOnboardingTransport {
|
|
20
|
+
if (!options || typeof options.getAccessToken !== 'function') {
|
|
21
|
+
throw new TypeError('UniplatBase organization onboarding transport requires getAccessToken()')
|
|
22
|
+
}
|
|
23
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
24
|
+
const createEndpoint = `${serviceBaseUrl}/${CREATE_PATH}`
|
|
25
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
26
|
+
|
|
27
|
+
const createOrganization = async (
|
|
28
|
+
body: Record<string, unknown>,
|
|
29
|
+
expectedType: OnboardingOrganizationType,
|
|
30
|
+
): Promise<CreatedOnboardingOrganization> => {
|
|
31
|
+
let accessToken = ''
|
|
32
|
+
try {
|
|
33
|
+
accessToken = requireAccessToken(await options.getAccessToken())
|
|
34
|
+
const data = await requestData(fetchImplementation, createEndpoint, accessToken, body)
|
|
35
|
+
return parseCreatedOrganization(data, expectedType)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof OrganizationOnboardingTransportError) throw error
|
|
38
|
+
throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
39
|
+
} finally {
|
|
40
|
+
accessToken = ''
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async createEnterprise(request) {
|
|
46
|
+
return createOrganization(
|
|
47
|
+
{
|
|
48
|
+
organization_type: 'enterprise',
|
|
49
|
+
team_name: requireText(request.organizationName),
|
|
50
|
+
customer_name: requireText(request.enterpriseName),
|
|
51
|
+
credit_code: requireText(request.unifiedSocialCreditCode).toUpperCase(),
|
|
52
|
+
},
|
|
53
|
+
'enterprise',
|
|
54
|
+
)
|
|
55
|
+
},
|
|
56
|
+
async createTeam(request) {
|
|
57
|
+
return createOrganization(
|
|
58
|
+
{
|
|
59
|
+
organization_type: 'team',
|
|
60
|
+
team_name: requireText(request.teamName),
|
|
61
|
+
},
|
|
62
|
+
'team',
|
|
63
|
+
)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requestData(
|
|
69
|
+
fetchImplementation: typeof fetch | undefined,
|
|
70
|
+
endpoint: string,
|
|
71
|
+
accessToken: string,
|
|
72
|
+
body: Record<string, unknown>,
|
|
73
|
+
): Promise<Record<string, unknown>> {
|
|
74
|
+
if (!fetchImplementation) throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
75
|
+
|
|
76
|
+
let response: Response
|
|
77
|
+
try {
|
|
78
|
+
response = await fetchImplementation(endpoint, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
credentials: 'include',
|
|
81
|
+
cache: 'no-store',
|
|
82
|
+
headers: {
|
|
83
|
+
accept: 'application/json',
|
|
84
|
+
authorization: `Bearer ${accessToken}`,
|
|
85
|
+
'content-type': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
})
|
|
89
|
+
} catch {
|
|
90
|
+
throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
93
|
+
|
|
94
|
+
let envelope: Record<string, unknown>
|
|
95
|
+
try {
|
|
96
|
+
envelope = asRecord(await response.json())
|
|
97
|
+
} catch {
|
|
98
|
+
throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
99
|
+
}
|
|
100
|
+
if (number(envelope.rescode) !== 0) {
|
|
101
|
+
throw new OrganizationOnboardingTransportError(normalizeTransportErrorCode(envelope.error_code))
|
|
102
|
+
}
|
|
103
|
+
return asRecord(envelope.data)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseCreatedOrganization(
|
|
107
|
+
data: Record<string, unknown>,
|
|
108
|
+
expectedType: OnboardingOrganizationType,
|
|
109
|
+
): CreatedOnboardingOrganization {
|
|
110
|
+
const id = positiveIdentifier(data.oid)
|
|
111
|
+
const type = data.organization_type
|
|
112
|
+
const name = text(expectedType === 'enterprise' ? data.customer_name : data.team_name).trim()
|
|
113
|
+
if (!id || type !== expectedType || !name) {
|
|
114
|
+
throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
115
|
+
}
|
|
116
|
+
return { id, name, type: expectedType }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateServiceBaseUrl(rawValue: string): string {
|
|
120
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
121
|
+
if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
|
|
122
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
123
|
+
let url: URL
|
|
124
|
+
try {
|
|
125
|
+
url = new URL(value, documentBase)
|
|
126
|
+
} catch {
|
|
127
|
+
throw new TypeError('UniplatBase serviceBaseUrl is invalid')
|
|
128
|
+
}
|
|
129
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
130
|
+
throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
|
|
131
|
+
}
|
|
132
|
+
if (url.search || url.hash) {
|
|
133
|
+
throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
|
|
134
|
+
}
|
|
135
|
+
return url.href.replace(/\/+$/, '')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function requireAccessToken(rawValue: unknown): string {
|
|
139
|
+
const token = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
140
|
+
if (!token) throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
141
|
+
return token
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function requireText(rawValue: unknown): string {
|
|
145
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
146
|
+
if (!value) throw new OrganizationOnboardingTransportError('UNKNOWN')
|
|
147
|
+
return value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function positiveIdentifier(value: unknown): string {
|
|
151
|
+
const normalized =
|
|
152
|
+
typeof value === 'string'
|
|
153
|
+
? value.trim()
|
|
154
|
+
: typeof value === 'number' && Number.isSafeInteger(value)
|
|
155
|
+
? String(value)
|
|
156
|
+
: ''
|
|
157
|
+
return /^\d+$/.test(normalized) && normalized !== '0' ? normalized : ''
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
161
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
162
|
+
? (value as Record<string, unknown>)
|
|
163
|
+
: {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function text(value: unknown): string {
|
|
167
|
+
return typeof value === 'string' ? value : ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function number(value: unknown): number {
|
|
171
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeTransportErrorCode(value: unknown): OrganizationOnboardingTransportErrorCode {
|
|
175
|
+
if (
|
|
176
|
+
value === 'ENTERPRISE_NAME_CONFLICT' ||
|
|
177
|
+
value === 'CREDIT_CODE_CONFLICT' ||
|
|
178
|
+
value === 'TEAM_NAME_CONFLICT'
|
|
179
|
+
) {
|
|
180
|
+
return value
|
|
181
|
+
}
|
|
182
|
+
return 'UNKNOWN'
|
|
183
|
+
}
|
package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createUniplatBaseOrganizationMultiEmployeeSelectorTransport,
|
|
3
|
+
type UniplatBaseOrganizationMultiEmployeeSelectorTransportOptions,
|
|
4
|
+
} from './uniplat-base-organization-multi-employee-selector-transport'
|
|
5
|
+
import type { UniplatOrganizationSingleEmployeeSelectorTransport } from './organization-single-employee-selector'
|
|
6
|
+
|
|
7
|
+
export type UniplatBaseOrganizationSingleEmployeeSelectorTransportOptions =
|
|
8
|
+
UniplatBaseOrganizationMultiEmployeeSelectorTransportOptions
|
|
9
|
+
|
|
10
|
+
export function createUniplatBaseOrganizationSingleEmployeeSelectorTransport(
|
|
11
|
+
options: UniplatBaseOrganizationSingleEmployeeSelectorTransportOptions,
|
|
12
|
+
): UniplatOrganizationSingleEmployeeSelectorTransport {
|
|
13
|
+
return createUniplatBaseOrganizationMultiEmployeeSelectorTransport(options)
|
|
14
|
+
}
|