@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ApplicationLaunchResult } from './application-switcher-transport'
|
|
2
|
+
import { resolveUniplatBaseApplicationEntryUrl } from './uniplat-base-application-ticket'
|
|
3
|
+
|
|
4
|
+
export const UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY = 'uniplat_identity_ticket'
|
|
5
|
+
export const UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY = 'source_system'
|
|
6
|
+
|
|
7
|
+
export interface UniplatBaseIdentityTicketLoginOptions {
|
|
8
|
+
serviceBaseUrl: string
|
|
9
|
+
applicationName: string
|
|
10
|
+
setAccessToken(accessToken: string): void | Promise<void>
|
|
11
|
+
fetch?: typeof fetch
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LOGIN_PATH = 'anonymous/application.identity/login_with_ticket'
|
|
15
|
+
const MIN_TICKET_LENGTH = 32
|
|
16
|
+
const MAX_TICKET_LENGTH = 128
|
|
17
|
+
const TICKET_LIFETIME_SECONDS = 15
|
|
18
|
+
const MAX_SOURCE_SYSTEM_LENGTH = 128
|
|
19
|
+
|
|
20
|
+
export async function loginWithUniplatBaseIdentityTicket(
|
|
21
|
+
options: UniplatBaseIdentityTicketLoginOptions,
|
|
22
|
+
): Promise<boolean> {
|
|
23
|
+
if (!options || typeof options.setAccessToken !== 'function') {
|
|
24
|
+
throw new TypeError('Identity ticket login requires setAccessToken()')
|
|
25
|
+
}
|
|
26
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
27
|
+
const applicationName = requireText(options.applicationName, 'Application name', 255)
|
|
28
|
+
if (typeof window === 'undefined') return false
|
|
29
|
+
|
|
30
|
+
const fragment = readIdentityTicketFragment(window.location.hash)
|
|
31
|
+
if (!fragment.hasTicket) return false
|
|
32
|
+
let ticket = fragment.ticket
|
|
33
|
+
clearIdentityTicketFragment()
|
|
34
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
35
|
+
const endpoint = `${serviceBaseUrl}/${LOGIN_PATH}`
|
|
36
|
+
let accessToken = ''
|
|
37
|
+
let publicErrorMessage = ''
|
|
38
|
+
try {
|
|
39
|
+
ticket = requireTicket(ticket)
|
|
40
|
+
const sourceSystem = requireOptionalText(fragment.sourceSystem, MAX_SOURCE_SYSTEM_LENGTH)
|
|
41
|
+
if (!fetchImplementation) throw new Error('Fetch is unavailable')
|
|
42
|
+
const body: Record<string, string> = { application: applicationName, ticket }
|
|
43
|
+
if (sourceSystem) body.source_system = sourceSystem
|
|
44
|
+
const response = await fetchImplementation(endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
credentials: 'omit',
|
|
47
|
+
headers: {
|
|
48
|
+
accept: 'application/json',
|
|
49
|
+
'content-type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
})
|
|
53
|
+
if (!response.ok) throw new Error('Identity ticket login failed')
|
|
54
|
+
const envelope = asRecord(await response.json())
|
|
55
|
+
if (number(envelope.rescode) !== 0) {
|
|
56
|
+
publicErrorMessage = identityTicketPublicErrorMessage(envelope)
|
|
57
|
+
throw new Error('Identity ticket login failed')
|
|
58
|
+
}
|
|
59
|
+
const data = asRecord(envelope.data)
|
|
60
|
+
accessToken = text(data.token).trim()
|
|
61
|
+
if (!accessToken || text(data.application_name).trim() !== applicationName) {
|
|
62
|
+
throw new Error('Identity ticket login failed')
|
|
63
|
+
}
|
|
64
|
+
await options.setAccessToken(accessToken)
|
|
65
|
+
return true
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(publicErrorMessage || '身份票据登录失败')
|
|
68
|
+
} finally {
|
|
69
|
+
accessToken = ''
|
|
70
|
+
ticket = ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createIdentityTicketLaunchResult(
|
|
75
|
+
entryUrl: string,
|
|
76
|
+
rawTicket: unknown,
|
|
77
|
+
rawExpiresIn: unknown,
|
|
78
|
+
rawSourceSystem: unknown,
|
|
79
|
+
): ApplicationLaunchResult {
|
|
80
|
+
const ticket = requireTicket(rawTicket)
|
|
81
|
+
const expiresIn = number(rawExpiresIn)
|
|
82
|
+
if (expiresIn !== TICKET_LIFETIME_SECONDS) {
|
|
83
|
+
throw new Error('Identity ticket response is invalid')
|
|
84
|
+
}
|
|
85
|
+
const sourceSystem = text(rawSourceSystem).trim()
|
|
86
|
+
if (!sourceSystem || sourceSystem.length > MAX_SOURCE_SYSTEM_LENGTH) {
|
|
87
|
+
throw new Error('Identity ticket response is invalid')
|
|
88
|
+
}
|
|
89
|
+
const url = new URL(resolveUniplatBaseApplicationEntryUrl(entryUrl))
|
|
90
|
+
url.hash = new URLSearchParams([
|
|
91
|
+
[UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY, ticket],
|
|
92
|
+
[UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY, sourceSystem],
|
|
93
|
+
]).toString()
|
|
94
|
+
return { url: url.href }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readIdentityTicketFragment(hash: string): {
|
|
98
|
+
hasTicket: boolean
|
|
99
|
+
ticket: string
|
|
100
|
+
sourceSystem: string
|
|
101
|
+
} {
|
|
102
|
+
const fragment = hash.startsWith('#') ? hash.slice(1) : hash
|
|
103
|
+
const params = new URLSearchParams(fragment)
|
|
104
|
+
return {
|
|
105
|
+
hasTicket: params.has(UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY),
|
|
106
|
+
ticket: params.get(UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY)?.trim() ?? '',
|
|
107
|
+
sourceSystem: params.get(UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY) ?? '',
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clearIdentityTicketFragment(): void {
|
|
112
|
+
const fragment = window.location.hash.startsWith('#')
|
|
113
|
+
? window.location.hash.slice(1)
|
|
114
|
+
: window.location.hash
|
|
115
|
+
const params = new URLSearchParams(fragment)
|
|
116
|
+
params.delete(UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY)
|
|
117
|
+
params.delete(UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY)
|
|
118
|
+
const remainingFragment = params.toString()
|
|
119
|
+
const cleanUrl = `${window.location.pathname}${window.location.search}${remainingFragment ? `#${remainingFragment}` : ''}`
|
|
120
|
+
window.history.replaceState(window.history.state, '', cleanUrl)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function identityTicketPublicErrorMessage(envelope: Record<string, unknown>): string {
|
|
124
|
+
const allowedCodes = new Set([
|
|
125
|
+
'IDENTITY_TICKET_INVALID',
|
|
126
|
+
'IDENTITY_TICKET_SYSTEM_UNAVAILABLE',
|
|
127
|
+
'IDENTITY_TICKET_SOURCE_UNAVAILABLE',
|
|
128
|
+
'IDENTITY_TICKET_IDENTITY_NOT_READY',
|
|
129
|
+
'IDENTITY_TICKET_APPLICATION_ACCESS_DENIED',
|
|
130
|
+
])
|
|
131
|
+
const code = text(envelope.error_code)
|
|
132
|
+
const message = text(envelope.msg)
|
|
133
|
+
if (
|
|
134
|
+
!allowedCodes.has(code) ||
|
|
135
|
+
!message ||
|
|
136
|
+
message.length > 200 ||
|
|
137
|
+
[...message].some((character) => {
|
|
138
|
+
const codePoint = character.charCodeAt(0)
|
|
139
|
+
return codePoint <= 31 || codePoint === 127
|
|
140
|
+
})
|
|
141
|
+
) {
|
|
142
|
+
return ''
|
|
143
|
+
}
|
|
144
|
+
return message
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function requireTicket(rawValue: unknown): string {
|
|
148
|
+
const ticket = text(rawValue).trim()
|
|
149
|
+
if (
|
|
150
|
+
ticket.length < MIN_TICKET_LENGTH ||
|
|
151
|
+
ticket.length > MAX_TICKET_LENGTH ||
|
|
152
|
+
!/^[A-Za-z0-9_-]+$/.test(ticket)
|
|
153
|
+
) {
|
|
154
|
+
throw new Error('Identity ticket response is invalid')
|
|
155
|
+
}
|
|
156
|
+
return ticket
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requireText(rawValue: unknown, label: string, maxLength: number): string {
|
|
160
|
+
const value = text(rawValue).trim()
|
|
161
|
+
if (!value || value.length > maxLength) throw new TypeError(`${label} is required`)
|
|
162
|
+
return value
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function requireOptionalText(rawValue: unknown, maxLength: number): string {
|
|
166
|
+
const value = text(rawValue).trim()
|
|
167
|
+
if (value.length > maxLength) throw new Error('Identity ticket response is invalid')
|
|
168
|
+
return value
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateServiceBaseUrl(rawValue: string): string {
|
|
172
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
173
|
+
if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
|
|
174
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
175
|
+
let url: URL
|
|
176
|
+
try {
|
|
177
|
+
url = new URL(value, documentBase)
|
|
178
|
+
} catch {
|
|
179
|
+
throw new TypeError('UniplatBase serviceBaseUrl is invalid')
|
|
180
|
+
}
|
|
181
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
182
|
+
throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
|
|
183
|
+
}
|
|
184
|
+
if (url.search || url.hash) {
|
|
185
|
+
throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
|
|
186
|
+
}
|
|
187
|
+
return url.href.replace(/\/$/, '')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
191
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
192
|
+
? (value as Record<string, unknown>)
|
|
193
|
+
: {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function text(value: unknown): string {
|
|
197
|
+
return typeof value === 'string' ? value : ''
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function number(value: unknown): number {
|
|
201
|
+
return typeof value === 'number' ? value : Number.NaN
|
|
202
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationAttachment,
|
|
3
|
+
NotificationCenterTransport,
|
|
4
|
+
NotificationContentType,
|
|
5
|
+
NotificationDto,
|
|
6
|
+
NotificationInboxQuery,
|
|
7
|
+
NotificationInboxResult,
|
|
8
|
+
NotificationReadStatus,
|
|
9
|
+
NotificationRealtimeChange,
|
|
10
|
+
NotificationResource,
|
|
11
|
+
NotificationSource,
|
|
12
|
+
NotificationTypeSummary,
|
|
13
|
+
} from '@uniplat/ai-employee-client'
|
|
14
|
+
|
|
15
|
+
export interface UniplatBaseNotificationCenterTransportOptions {
|
|
16
|
+
serviceBaseUrl: string
|
|
17
|
+
getAccessToken(): string | Promise<string>
|
|
18
|
+
fetch?: typeof fetch
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const NOTIFICATION_SERVICE = 'messaging.notification_api'
|
|
22
|
+
const FILESYSTEM_SERVICE = 'filesystem.api'
|
|
23
|
+
const INBOX_ACTION = 'inbox'
|
|
24
|
+
const UNREAD_COUNT_ACTION = 'unread_count'
|
|
25
|
+
const MARK_READ_ACTION = 'mark_read'
|
|
26
|
+
const MARK_ALL_READ_ACTION = 'mark_all_read'
|
|
27
|
+
const ARCHIVE_ACTION = 'archive'
|
|
28
|
+
const CREATE_DOWNLOAD_TICKET_ACTION = 'create_download_ticket'
|
|
29
|
+
|
|
30
|
+
export function createUniplatBaseNotificationCenterTransport(
|
|
31
|
+
options: UniplatBaseNotificationCenterTransportOptions,
|
|
32
|
+
): NotificationCenterTransport {
|
|
33
|
+
if (!options || typeof options.getAccessToken !== 'function') {
|
|
34
|
+
throw new TypeError('Notification center transport requires getAccessToken()')
|
|
35
|
+
}
|
|
36
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
37
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
38
|
+
const request = async (
|
|
39
|
+
service: string,
|
|
40
|
+
action: string,
|
|
41
|
+
body: Record<string, unknown>,
|
|
42
|
+
): Promise<unknown> => {
|
|
43
|
+
const token = await resolveAccessToken(options.getAccessToken)
|
|
44
|
+
return requestData(fetchImplementation, `${serviceBaseUrl}/${service}/${action}`, token, body)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async listInbox(query: NotificationInboxQuery): Promise<NotificationInboxResult> {
|
|
49
|
+
const body: Record<string, unknown> = {
|
|
50
|
+
page_size: Math.max(1, Math.floor(query.pageSize)),
|
|
51
|
+
}
|
|
52
|
+
assignApplicationName(body, query.applicationName)
|
|
53
|
+
if (query.beforeId) body.before_id = requiredText(query.beforeId, 128)
|
|
54
|
+
if (query.includeTypeFacets) body.include_type_facets = true
|
|
55
|
+
const types = (query.notificationTypes ?? [])
|
|
56
|
+
.map((value) => optionalText(value, 64))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
if (types.length) body.notification_types = types
|
|
59
|
+
if (query.readStatus) body.read_status = query.readStatus
|
|
60
|
+
if (query.includeArchived) body.include_archived = true
|
|
61
|
+
const data = record(await request(NOTIFICATION_SERVICE, INBOX_ACTION, body))
|
|
62
|
+
return {
|
|
63
|
+
items: Array.isArray(data.items)
|
|
64
|
+
? data.items
|
|
65
|
+
.map(parseNotification)
|
|
66
|
+
.filter((value): value is NotificationDto => value !== null)
|
|
67
|
+
: [],
|
|
68
|
+
hasMore: data.has_more === true,
|
|
69
|
+
nextId: textOrNull(data.next_id),
|
|
70
|
+
...(Array.isArray(data.type_facets)
|
|
71
|
+
? { types: parseTypeFacets(data.type_facets) }
|
|
72
|
+
: undefined),
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
async getUnreadCount(params) {
|
|
76
|
+
const body: Record<string, unknown> = {}
|
|
77
|
+
assignApplicationName(body, params.applicationName)
|
|
78
|
+
const data = record(await request(NOTIFICATION_SERVICE, UNREAD_COUNT_ACTION, body))
|
|
79
|
+
return { unreadCount: nonNegativeInteger(data.unread_count) }
|
|
80
|
+
},
|
|
81
|
+
async markRead(params) {
|
|
82
|
+
const body: Record<string, unknown> = {
|
|
83
|
+
notification_id: requiredText(params.notificationId, 128),
|
|
84
|
+
}
|
|
85
|
+
assignApplicationName(body, params.applicationName)
|
|
86
|
+
const data = record(await request(NOTIFICATION_SERVICE, MARK_READ_ACTION, body))
|
|
87
|
+
return {
|
|
88
|
+
unreadCount: nonNegativeInteger(data.unread_count),
|
|
89
|
+
readAt: textOrNull(data.read_at),
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async markAllRead(params) {
|
|
93
|
+
const body: Record<string, unknown> = {}
|
|
94
|
+
assignApplicationName(body, params.applicationName)
|
|
95
|
+
if (params.beforeId) body.before_id = requiredText(params.beforeId, 128)
|
|
96
|
+
const types = (params.notificationTypes ?? [])
|
|
97
|
+
.map((value) => optionalText(value, 64))
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
if (types.length) body.notification_types = types
|
|
100
|
+
const data = record(await request(NOTIFICATION_SERVICE, MARK_ALL_READ_ACTION, body))
|
|
101
|
+
return {
|
|
102
|
+
markedCount: nonNegativeInteger(data.marked_count),
|
|
103
|
+
unreadCount: nonNegativeInteger(data.unread_count),
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async archive(params) {
|
|
107
|
+
const body: Record<string, unknown> = {
|
|
108
|
+
notification_id: requiredText(params.notificationId, 128),
|
|
109
|
+
}
|
|
110
|
+
assignApplicationName(body, params.applicationName)
|
|
111
|
+
const data = record(await request(NOTIFICATION_SERVICE, ARCHIVE_ACTION, body))
|
|
112
|
+
return { unreadCount: nonNegativeInteger(data.unread_count) }
|
|
113
|
+
},
|
|
114
|
+
async downloadAttachment(params) {
|
|
115
|
+
const fileId = requiredText(params.fileId, 128)
|
|
116
|
+
const ticketBody: Record<string, unknown> = { file_id: fileId, disposition: 'attachment' }
|
|
117
|
+
const ticketData = record(
|
|
118
|
+
await request(FILESYSTEM_SERVICE, CREATE_DOWNLOAD_TICKET_ACTION, ticketBody),
|
|
119
|
+
)
|
|
120
|
+
const downloadUrl = resolveDownloadUrl(text(ticketData.download_url), serviceBaseUrl)
|
|
121
|
+
if (!downloadUrl) throw new Error('Notification attachment download ticket is invalid')
|
|
122
|
+
if (!fetchImplementation) throw new Error('Fetch is unavailable')
|
|
123
|
+
const response = await fetchImplementation(downloadUrl, {
|
|
124
|
+
method: 'GET',
|
|
125
|
+
credentials: 'include',
|
|
126
|
+
})
|
|
127
|
+
if (!response.ok) throw new Error('Notification attachment download failed')
|
|
128
|
+
return response.blob()
|
|
129
|
+
},
|
|
130
|
+
parseRealtimeEvent(event): NotificationRealtimeChange | null {
|
|
131
|
+
const data = record(event)
|
|
132
|
+
const eventType = text(data.event_type)
|
|
133
|
+
const unreadRaw = data.unread_count
|
|
134
|
+
const unreadCount =
|
|
135
|
+
typeof unreadRaw === 'number' && Number.isFinite(unreadRaw)
|
|
136
|
+
? Math.max(0, Math.floor(unreadRaw))
|
|
137
|
+
: undefined
|
|
138
|
+
if (eventType === 'notification_created') {
|
|
139
|
+
const notification = parseNotification(data.notification)
|
|
140
|
+
if (!notification) return null
|
|
141
|
+
return { kind: 'created', notification, unreadCount }
|
|
142
|
+
}
|
|
143
|
+
if (eventType === 'notification_read') {
|
|
144
|
+
const ids = collectNotificationIds(data)
|
|
145
|
+
if (!ids.length) return null
|
|
146
|
+
return { kind: 'read', notificationIds: ids, unreadCount, readAt: textOrNull(data.read_at) }
|
|
147
|
+
}
|
|
148
|
+
if (eventType === 'notification_archived') {
|
|
149
|
+
const ids = collectNotificationIds(data)
|
|
150
|
+
if (!ids.length) return null
|
|
151
|
+
return { kind: 'archived', notificationIds: ids, unreadCount }
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function collectNotificationIds(data: Record<string, unknown>): string[] {
|
|
159
|
+
const ids: string[] = []
|
|
160
|
+
if (Array.isArray(data.notification_ids)) {
|
|
161
|
+
for (const value of data.notification_ids) {
|
|
162
|
+
const id = text(value)
|
|
163
|
+
if (id) ids.push(id)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const single = text(data.notification_id)
|
|
167
|
+
if (single && !ids.includes(single)) ids.push(single)
|
|
168
|
+
return ids
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 只取分类目录需要的 code 与展示名;total/unread 暂不进入 UI,不做无用投影。 */
|
|
172
|
+
function parseTypeFacets(value: readonly unknown[]): NotificationTypeSummary[] {
|
|
173
|
+
const summaries: NotificationTypeSummary[] = []
|
|
174
|
+
for (const entry of value) {
|
|
175
|
+
const data = record(entry)
|
|
176
|
+
const type = optionalText(data.notification_type, 64)
|
|
177
|
+
if (!type) continue
|
|
178
|
+
summaries.push({ type, name: optionalText(data.notification_type_name, 128) })
|
|
179
|
+
}
|
|
180
|
+
return summaries
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseNotification(value: unknown): NotificationDto | null {
|
|
184
|
+
const data = record(value)
|
|
185
|
+
const id = text(data.notification_id ?? data.id)
|
|
186
|
+
if (!id) return null
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
type: text(data.notification_type) || 'other',
|
|
190
|
+
typeName: text(data.notification_type_name),
|
|
191
|
+
title: text(data.title),
|
|
192
|
+
summary: text(data.summary),
|
|
193
|
+
content: text(data.content),
|
|
194
|
+
contentType: contentType(data.content_type),
|
|
195
|
+
priority: text(data.priority) || 'normal',
|
|
196
|
+
source: parseSource(data.source),
|
|
197
|
+
resources: parseResources(data.resources),
|
|
198
|
+
attachments: parseAttachments(data.attachments),
|
|
199
|
+
readStatus: readStatus(data.read_status),
|
|
200
|
+
readAt: textOrNull(data.read_at),
|
|
201
|
+
archivedAt: textOrNull(data.archived_at),
|
|
202
|
+
createdAt: text(data.created_at),
|
|
203
|
+
expiresAt: textOrNull(data.expires_at),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseSource(value: unknown): NotificationSource | null {
|
|
208
|
+
const data = record(value)
|
|
209
|
+
const type = text(data.type)
|
|
210
|
+
const code = text(data.code)
|
|
211
|
+
const id = text(data.id)
|
|
212
|
+
const name = text(data.name)
|
|
213
|
+
const avatarUrl = safeImageUrl(data.avatar_url)
|
|
214
|
+
if (!type && !code && !id && !name && !avatarUrl) return null
|
|
215
|
+
return { type, code, id, name, avatarUrl }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseResources(value: unknown): NotificationResource[] {
|
|
219
|
+
if (!Array.isArray(value)) return []
|
|
220
|
+
const resources: NotificationResource[] = []
|
|
221
|
+
for (const entry of value) {
|
|
222
|
+
const data = record(entry)
|
|
223
|
+
const type = text(data.type)
|
|
224
|
+
const id = text(data.id)
|
|
225
|
+
const title = text(data.title)
|
|
226
|
+
const aeCode = text(data.ae_code)
|
|
227
|
+
if (!type && !id && !title && !aeCode) continue
|
|
228
|
+
resources.push({ type, id, title, aeCode })
|
|
229
|
+
}
|
|
230
|
+
return resources
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseAttachments(value: unknown): NotificationAttachment[] {
|
|
234
|
+
if (!Array.isArray(value)) return []
|
|
235
|
+
const attachments: NotificationAttachment[] = []
|
|
236
|
+
for (const entry of value) {
|
|
237
|
+
const data = record(entry)
|
|
238
|
+
const fileId = text(data.file_id)
|
|
239
|
+
const name = text(data.name)
|
|
240
|
+
if (!fileId && !name) continue
|
|
241
|
+
attachments.push({
|
|
242
|
+
fileId,
|
|
243
|
+
versionId: text(data.version_id),
|
|
244
|
+
name,
|
|
245
|
+
sizeBytes: nonNegativeInteger(data.size_bytes),
|
|
246
|
+
mimeType: text(data.mime_type),
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
return attachments
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function requestData(
|
|
253
|
+
fetchImplementation: typeof fetch | undefined,
|
|
254
|
+
endpoint: string,
|
|
255
|
+
accessToken: string,
|
|
256
|
+
body: Record<string, unknown>,
|
|
257
|
+
): Promise<unknown> {
|
|
258
|
+
if (!fetchImplementation) throw new Error('Fetch is unavailable')
|
|
259
|
+
const response = await fetchImplementation(endpoint, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
credentials: 'include',
|
|
262
|
+
headers: {
|
|
263
|
+
accept: 'application/json',
|
|
264
|
+
authorization: `Bearer ${accessToken}`,
|
|
265
|
+
'content-type': 'application/json',
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify(body),
|
|
268
|
+
})
|
|
269
|
+
if (!response.ok) throw new Error('Notification request failed')
|
|
270
|
+
const payload: unknown = await response.json()
|
|
271
|
+
const envelope = record(payload)
|
|
272
|
+
if ('rescode' in envelope) {
|
|
273
|
+
if (Number(envelope.rescode) !== 0) throw new Error('Notification request failed')
|
|
274
|
+
return envelope.data
|
|
275
|
+
}
|
|
276
|
+
return payload
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolveDownloadUrl(rawUrl: string, serviceBaseUrl: string): string {
|
|
280
|
+
const value = rawUrl.trim()
|
|
281
|
+
if (!value) return ''
|
|
282
|
+
try {
|
|
283
|
+
const origin = new URL(serviceBaseUrl).origin
|
|
284
|
+
const url = new URL(value, origin)
|
|
285
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return ''
|
|
286
|
+
return url.href
|
|
287
|
+
} catch {
|
|
288
|
+
return ''
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function validateServiceBaseUrl(value: string): string {
|
|
293
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
294
|
+
if (!raw) throw new TypeError('Notification center serviceBaseUrl is required')
|
|
295
|
+
const base = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
296
|
+
let parsed: URL
|
|
297
|
+
try {
|
|
298
|
+
parsed = new URL(raw, base)
|
|
299
|
+
} catch {
|
|
300
|
+
throw new TypeError('Notification center serviceBaseUrl is invalid')
|
|
301
|
+
}
|
|
302
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
303
|
+
throw new TypeError('Notification center serviceBaseUrl must use safe HTTP(S)')
|
|
304
|
+
}
|
|
305
|
+
if (parsed.search || parsed.hash) {
|
|
306
|
+
throw new TypeError('Notification center serviceBaseUrl must not contain query or hash')
|
|
307
|
+
}
|
|
308
|
+
return parsed.href.replace(/\/$/, '')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function resolveAccessToken(provider: () => string | Promise<string>): Promise<string> {
|
|
312
|
+
let value = ''
|
|
313
|
+
try {
|
|
314
|
+
value = (await provider()).trim()
|
|
315
|
+
} catch {
|
|
316
|
+
// Provider details may contain credentials and are intentionally discarded.
|
|
317
|
+
}
|
|
318
|
+
if (!value) throw new Error('Access token is unavailable')
|
|
319
|
+
return value
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function requiredText(value: unknown, maximumLength: number): string {
|
|
323
|
+
const normalized = optionalText(value, maximumLength)
|
|
324
|
+
if (!normalized) throw new Error('Required notification field is missing')
|
|
325
|
+
return normalized
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function optionalText(value: unknown, maximumLength: number): string {
|
|
329
|
+
return typeof value === 'string' ? value.trim().slice(0, maximumLength) : ''
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function assignApplicationName(body: Record<string, unknown>, value: unknown): void {
|
|
333
|
+
const applicationName = optionalText(value, 128)
|
|
334
|
+
if (applicationName) body.application_name = applicationName
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function text(value: unknown): string {
|
|
338
|
+
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function textOrNull(value: unknown): string | null {
|
|
342
|
+
const normalized = text(value)
|
|
343
|
+
return normalized ? normalized : null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function record(value: unknown): Record<string, unknown> {
|
|
347
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
348
|
+
? (value as Record<string, unknown>)
|
|
349
|
+
: {}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function contentType(value: unknown): NotificationContentType {
|
|
353
|
+
return value === 'markdown' ? 'markdown' : 'text'
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readStatus(value: unknown): NotificationReadStatus {
|
|
357
|
+
return value === 'read' ? 'read' : 'unread'
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function nonNegativeInteger(value: unknown): number {
|
|
361
|
+
const parsed = Number(value)
|
|
362
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function safeImageUrl(value: unknown): string {
|
|
366
|
+
const raw = text(value)
|
|
367
|
+
if (!raw) return ''
|
|
368
|
+
try {
|
|
369
|
+
const base = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
370
|
+
const url = new URL(raw, base)
|
|
371
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
372
|
+
return ''
|
|
373
|
+
}
|
|
374
|
+
return url.href
|
|
375
|
+
} catch {
|
|
376
|
+
return ''
|
|
377
|
+
}
|
|
378
|
+
}
|