@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.
Files changed (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. 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
  7. 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
  8. 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
  9. 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
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -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
+ }
@@ -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
+ }