@peanut-admin/admin 0.1.0-alpha.2

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 (67) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/api/client.ts +200 -0
  4. package/admin-core/src/api/problem.ts +67 -0
  5. package/admin-core/src/api/refresh.ts +122 -0
  6. package/admin-core/src/auth/stores.ts +121 -0
  7. package/admin-core/src/generated/api.d.ts +22106 -0
  8. package/admin-core/src/governance/audit.ts +56 -0
  9. package/admin-core/src/governance/catalog.ts +86 -0
  10. package/admin-core/src/governance/index.ts +26 -0
  11. package/admin-core/src/governance/menu.ts +63 -0
  12. package/admin-core/src/governance/roles.ts +144 -0
  13. package/admin-core/src/governance/types.ts +64 -0
  14. package/admin-core/src/index.ts +105 -0
  15. package/admin-core/src/lifecycle/tenant.ts +59 -0
  16. package/admin-core/src/module/contribution.ts +124 -0
  17. package/admin-core/src/runtime/config.ts +55 -0
  18. package/admin-core/src/runtime/errors.ts +81 -0
  19. package/admin-core/src/runtime/guard.ts +40 -0
  20. package/admin-core/src/runtime/navigation.ts +105 -0
  21. package/admin-core/src/runtime/overrides.ts +214 -0
  22. package/admin-core/src/targets/store.ts +153 -0
  23. package/admin-shell/src/config.ts +84 -0
  24. package/admin-shell/src/index.ts +40 -0
  25. package/admin-shell/src/layout.ts +332 -0
  26. package/admin-shell/src/overrides.ts +53 -0
  27. package/admin-shell/src/states.ts +93 -0
  28. package/admin-shell/src/targets.ts +128 -0
  29. package/admin-shell/src/theme.ts +15 -0
  30. package/client-core/src/index.ts +325 -0
  31. package/client-nuxt/src/index.ts +40 -0
  32. package/client-uniapp/src/index.ts +50 -0
  33. package/file-media/src/FileAssetSelector.vue +117 -0
  34. package/file-media/src/FileMediaPage.vue +158 -0
  35. package/file-media/src/contracts.ts +220 -0
  36. package/file-media/src/index.ts +19 -0
  37. package/file-media/src/runtime.ts +210 -0
  38. package/import-export/src/ImportExportPage.vue +155 -0
  39. package/import-export/src/contracts.ts +96 -0
  40. package/import-export/src/index.ts +3 -0
  41. package/import-export/src/runtime.ts +128 -0
  42. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  43. package/integration-security/src/contracts.ts +171 -0
  44. package/integration-security/src/index.ts +3 -0
  45. package/integration-security/src/runtime.ts +180 -0
  46. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  47. package/notification-sms/src/contracts.ts +195 -0
  48. package/notification-sms/src/index.ts +4 -0
  49. package/notification-sms/src/runtime.ts +143 -0
  50. package/ops-console/src/OpsConsolePage.vue +337 -0
  51. package/ops-console/src/contracts.ts +169 -0
  52. package/ops-console/src/index.ts +3 -0
  53. package/ops-console/src/runtime.ts +199 -0
  54. package/package.json +108 -0
  55. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  56. package/reference-codes/src/contracts.ts +484 -0
  57. package/reference-codes/src/index.ts +53 -0
  58. package/reference-codes/src/runtime.ts +855 -0
  59. package/settings/src/SettingsPage.vue +536 -0
  60. package/settings/src/contracts.ts +331 -0
  61. package/settings/src/index.ts +45 -0
  62. package/settings/src/runtime.ts +545 -0
  63. package/task-job/src/TaskJobPage.vue +120 -0
  64. package/task-job/src/contracts.ts +117 -0
  65. package/task-job/src/index.ts +2 -0
  66. package/task-job/src/runtime.ts +105 -0
  67. package/testing/src/index.ts +141 -0
@@ -0,0 +1,210 @@
1
+ import { defineAdminModule } from '@peanut-admin/admin/core'
2
+ import { inject, reactive } from 'vue'
3
+ import type { AdminModuleContribution } from '@peanut-admin/admin/core'
4
+ import type { InjectionKey } from 'vue'
5
+
6
+ import { parseAssetList, parseFileList, parseFileResponse } from './contracts'
7
+ import type { AssetCandidate, FileMediaTransport, FileObject, FileStatus, FileTransportResult } from './contracts'
8
+
9
+ export const FILE_MEDIA_MODULE_KEY = 'peanut.file-media' as const
10
+ export const FILE_MEDIA_ROUTE_NAME = 'peanut.file-media.list' as const
11
+ export const FILE_MEDIA_ROUTE_PATH = '/app/files' as const
12
+ export const FILE_MEDIA_READ_PERMISSION = 'peanut.file-media.read' as const
13
+ export const FILE_MEDIA_CREATE_PERMISSION = 'peanut.file-media.create' as const
14
+ export const FILE_MEDIA_DELETE_PERMISSION = 'peanut.file-media.delete' as const
15
+ export const FILE_MEDIA_STORE_KEY = 'peanut.file-media.runtime' as const
16
+
17
+ export interface FileMediaError {
18
+ readonly message: string
19
+ readonly requestId: string | null
20
+ readonly status: number | null
21
+ }
22
+
23
+ export interface FileMediaState {
24
+ items: FileObject[]
25
+ status: FileStatus
26
+ page: number
27
+ pageSize: number
28
+ total: number
29
+ assets: AssetCandidate[]
30
+ assetsLoading: boolean
31
+ assetsError: FileMediaError | null
32
+ loading: boolean
33
+ mutating: boolean
34
+ error: FileMediaError | null
35
+ }
36
+
37
+ export interface FileMediaRuntime {
38
+ readonly state: FileMediaState
39
+ readonly canCreate: () => boolean
40
+ readonly canDelete: () => boolean
41
+ load: () => Promise<void>
42
+ loadAssets: () => Promise<void>
43
+ setStatus: (status: FileStatus) => Promise<void>
44
+ upload: (file: File) => Promise<void>
45
+ download: (file: FileObject) => Promise<void>
46
+ archive: (file: FileObject) => Promise<void>
47
+ dispose: () => void
48
+ }
49
+
50
+ export interface FileMediaRuntimeOptions {
51
+ readonly transport: FileMediaTransport
52
+ readonly canRead: () => boolean
53
+ readonly canCreate: () => boolean
54
+ readonly canDelete: () => boolean
55
+ readonly saveDownload?: (response: Response, file: FileObject) => Promise<void>
56
+ }
57
+
58
+ const requestId = (result: FileTransportResult): string | null => {
59
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)
60
+ ? result.body as Record<string, unknown>
61
+ : {}
62
+ const value = body.request_id ?? result.headers.get('X-Request-Id')
63
+ return typeof value === 'string' && value !== '' ? value : null
64
+ }
65
+
66
+ const failure = (result: FileTransportResult): FileMediaError => {
67
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)
68
+ ? result.body as Record<string, unknown>
69
+ : {}
70
+ return {
71
+ message: typeof body.detail === 'string' && body.detail !== '' ? body.detail : `File request failed (${result.status}).`,
72
+ requestId: requestId(result),
73
+ status: result.status,
74
+ }
75
+ }
76
+
77
+ const defaultSave = async (response: Response, file: FileObject): Promise<void> => {
78
+ const blob = await response.blob()
79
+ const url = URL.createObjectURL(blob)
80
+ const anchor = document.createElement('a')
81
+ anchor.href = url
82
+ anchor.download = file.originalName
83
+ anchor.click()
84
+ URL.revokeObjectURL(url)
85
+ }
86
+
87
+ export const createFileMediaRuntime = (options: FileMediaRuntimeOptions): FileMediaRuntime => {
88
+ const state = reactive<FileMediaState>({
89
+ items: [], status: 'ready', page: 1, pageSize: 20, total: 0,
90
+ assets: [], assetsLoading: false, assetsError: null,
91
+ loading: false, mutating: false, error: null,
92
+ })
93
+ const controllers = new Set<AbortController>()
94
+ let generation = 0
95
+ const run = async <T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> => {
96
+ const controller = new AbortController()
97
+ controllers.add(controller)
98
+ try { return await operation(controller.signal) } finally { controllers.delete(controller) }
99
+ }
100
+
101
+ const load = async (): Promise<void> => {
102
+ const current = ++generation
103
+ state.loading = true
104
+ state.error = null
105
+ try {
106
+ if (!options.canRead()) throw new Error('FILE_MEDIA_PERMISSION_DENIED')
107
+ const result = await run(signal => options.transport.list(state.status, state.page, state.pageSize, signal))
108
+ if (current !== generation) return
109
+ if (result.status !== 200) { state.error = failure(result); return }
110
+ const list = parseFileList(result.body)
111
+ state.items = [...list.items]
112
+ state.page = list.page
113
+ state.pageSize = list.pageSize
114
+ state.total = list.total
115
+ } catch (error) {
116
+ if (current === generation && !(error instanceof DOMException && error.name === 'AbortError')) {
117
+ state.error = { message: 'The file service could not be reached.', requestId: null, status: null }
118
+ }
119
+ } finally {
120
+ if (current === generation) state.loading = false
121
+ }
122
+ }
123
+
124
+ const loadAssets = async (): Promise<void> => {
125
+ const current = generation
126
+ state.assetsLoading = true
127
+ state.assetsError = null
128
+ try {
129
+ if (!options.canRead()) throw new Error('FILE_MEDIA_PERMISSION_DENIED')
130
+ const result = await run(signal => options.transport.assets(1, 50, signal))
131
+ if (current !== generation) return
132
+ if (result.status !== 200) { state.assetsError = failure(result); return }
133
+ state.assets = [...parseAssetList(result.body).items]
134
+ } catch (error) {
135
+ if (current === generation && !(error instanceof DOMException && error.name === 'AbortError')) {
136
+ state.assetsError = { message: 'The media asset service could not be reached.', requestId: null, status: null }
137
+ }
138
+ } finally {
139
+ if (current === generation) state.assetsLoading = false
140
+ }
141
+ }
142
+
143
+ return {
144
+ state,
145
+ canCreate: options.canCreate,
146
+ canDelete: options.canDelete,
147
+ load,
148
+ loadAssets,
149
+ async setStatus(status) { state.status = status; state.page = 1; await load() },
150
+ async upload(file) {
151
+ if (!options.canCreate() || state.mutating) return
152
+ state.mutating = true
153
+ state.error = null
154
+ try {
155
+ const result = await run(signal => options.transport.upload(file, signal))
156
+ if (result.status !== 201) { state.error = failure(result); return }
157
+ parseFileResponse(result.body)
158
+ state.status = 'ready'
159
+ await Promise.all([load(), loadAssets()])
160
+ } catch { state.error = { message: 'The upload could not be completed.', requestId: null, status: null } }
161
+ finally { state.mutating = false }
162
+ },
163
+ async download(file) {
164
+ state.error = null
165
+ try {
166
+ const response = await run(signal => options.transport.download(file.fileKey, signal))
167
+ if (!response.ok) {
168
+ let body: unknown = null
169
+ try { body = await response.json() } catch { body = null }
170
+ state.error = failure({ body, headers: response.headers, status: response.status })
171
+ return
172
+ }
173
+ await (options.saveDownload ?? defaultSave)(response, file)
174
+ } catch { state.error = { message: 'The download could not be completed.', requestId: null, status: null } }
175
+ },
176
+ async archive(file) {
177
+ if (!options.canDelete() || state.mutating) return
178
+ state.mutating = true
179
+ state.error = null
180
+ try {
181
+ const result = await run(signal => options.transport.archive(file.fileKey, `"rev-${file.revision}"`, signal))
182
+ if (result.status !== 200) { state.error = failure(result); return }
183
+ parseFileResponse(result.body)
184
+ await load()
185
+ } catch { state.error = { message: 'The file could not be archived.', requestId: null, status: null } }
186
+ finally { state.mutating = false }
187
+ },
188
+ dispose() { generation += 1; for (const controller of controllers) controller.abort(); controllers.clear(); state.assets = [] },
189
+ }
190
+ }
191
+
192
+ export const fileMediaRuntimeKey: InjectionKey<FileMediaRuntime> = Symbol(FILE_MEDIA_STORE_KEY)
193
+
194
+ export const useFileMediaRuntime = (): FileMediaRuntime => {
195
+ const runtime = inject(fileMediaRuntimeKey)
196
+ if (runtime === undefined) throw new Error('FILE_MEDIA_RUNTIME_MISSING')
197
+ return runtime
198
+ }
199
+
200
+ export const createFileMediaModuleContribution = (runtime: FileMediaRuntime): AdminModuleContribution => defineAdminModule({
201
+ key: FILE_MEDIA_MODULE_KEY,
202
+ routes: [{
203
+ name: FILE_MEDIA_ROUTE_NAME,
204
+ path: FILE_MEDIA_ROUTE_PATH,
205
+ component: async () => ({ default: (await import('./FileMediaPage.vue')).default }),
206
+ access: { moduleKey: FILE_MEDIA_MODULE_KEY, permissionKeys: [FILE_MEDIA_READ_PERMISSION] },
207
+ }],
208
+ disposeOnTenantChange: true,
209
+ stores: [{ key: FILE_MEDIA_STORE_KEY, dispose: runtime.dispose }],
210
+ })
@@ -0,0 +1,155 @@
1
+ <script setup lang="ts">
2
+ import { EmptyState, ForbiddenState, ModuleUnavailableState, PageContent, PageHeader, PageToolbar, SessionExpiredState } from '@peanut-admin/admin/shell'
3
+ import { ElButton, ElInput } from 'element-plus'
4
+ import { computed, onMounted, ref } from 'vue'
5
+ import { useImportExportRuntime } from './runtime'
6
+
7
+ const runtime = useImportExportRuntime(); const state = runtime.state
8
+ const providerKey = ref(''); const inputFileKey = ref(''); const mappingJson = ref('{}')
9
+ const canCreate = computed(runtime.canCreate); const canCancel = computed(runtime.canCancel)
10
+ const statuses = ['queued', 'running', 'cancel_requested', 'succeeded', 'failed', 'cancelled', 'expired'] as const
11
+ const submitImport = async (): Promise<void> => { try { const mapping = JSON.parse(mappingJson.value) as unknown; if (typeof mapping !== 'object' || mapping === null || Array.isArray(mapping)) throw new Error(); await runtime.submitImport(providerKey.value, inputFileKey.value, mapping as Record<string, string>) } catch { /* Runtime validation reports request errors; malformed JSON stays local. */ } }
12
+ onMounted(runtime.load)
13
+ </script>
14
+
15
+ <template>
16
+ <PageContent class="import-export-page">
17
+ <PageHeader>
18
+ Import / Export<template #actions>
19
+ <ElButton
20
+ :loading="state.loading"
21
+ :disabled="state.mutating"
22
+ @click="runtime.load"
23
+ >
24
+ Reload
25
+ </ElButton>
26
+ </template>
27
+ </PageHeader>
28
+ <section
29
+ v-if="canCreate"
30
+ class="submission"
31
+ aria-label="Create import or export"
32
+ >
33
+ <ElInput
34
+ v-model="providerKey"
35
+ placeholder="Registered provider key"
36
+ aria-label="Provider key"
37
+ />
38
+ <ElInput
39
+ v-model="inputFileKey"
40
+ placeholder="Private CSV file key"
41
+ aria-label="Input file key"
42
+ />
43
+ <ElInput
44
+ v-model="mappingJson"
45
+ type="textarea"
46
+ placeholder="{&quot;CSV heading&quot;:&quot;column_key&quot;}"
47
+ aria-label="Column mapping JSON"
48
+ />
49
+ <div>
50
+ <ElButton
51
+ type="primary"
52
+ :disabled="state.mutating"
53
+ @click="submitImport"
54
+ >
55
+ Import CSV
56
+ </ElButton><ElButton
57
+ :disabled="state.mutating"
58
+ @click="runtime.submitExport(providerKey)"
59
+ >
60
+ Export CSV
61
+ </ElButton>
62
+ </div>
63
+ </section>
64
+ <PageToolbar label="Operation status">
65
+ <ElButton
66
+ v-for="status in statuses"
67
+ :key="status"
68
+ :type="state.status === status ? 'primary' : 'default'"
69
+ @click="runtime.setStatus(status)"
70
+ >
71
+ {{ status }}
72
+ </ElButton>
73
+ </PageToolbar>
74
+ <SessionExpiredState
75
+ v-if="state.error?.status === 401"
76
+ :message="state.error.message"
77
+ />
78
+ <ForbiddenState
79
+ v-else-if="state.error?.status === 403"
80
+ :message="state.error.message"
81
+ />
82
+ <ModuleUnavailableState
83
+ v-else-if="state.error?.status === 503"
84
+ :message="state.error.message"
85
+ @action="runtime.load"
86
+ />
87
+ <section
88
+ v-else-if="state.error"
89
+ role="alert"
90
+ >
91
+ <h2>Unable to complete the import/export request</h2><p>{{ state.error.message }}</p><p v-if="state.error.requestId">
92
+ Request ID: {{ state.error.requestId }}
93
+ </p>
94
+ </section>
95
+ <div
96
+ v-else-if="state.loading"
97
+ role="status"
98
+ >
99
+ Loading import/export operations...
100
+ </div>
101
+ <EmptyState
102
+ v-else-if="state.items.length === 0"
103
+ title="No operations"
104
+ message="No import/export operations match this status."
105
+ />
106
+ <div
107
+ v-else
108
+ class="table-wrap"
109
+ >
110
+ <table>
111
+ <thead><tr><th>Provider</th><th>Direction</th><th>Status</th><th>Progress</th><th>Rows</th><th>Expires</th><th>Actions</th></tr></thead><tbody>
112
+ <tr
113
+ v-for="operation in state.items"
114
+ :key="operation.operationKey"
115
+ >
116
+ <td>{{ operation.providerKey }}</td><td>{{ operation.direction }}</td><td>{{ operation.status }}</td><td>
117
+ <progress
118
+ :value="operation.processedRows"
119
+ :max="Math.max(operation.totalRows, operation.processedRows, 1)"
120
+ />
121
+ </td><td>{{ operation.acceptedRows }} accepted / {{ operation.rejectedRows }} rejected</td><td>{{ operation.retentionUntil }}</td><td>
122
+ <ElButton
123
+ v-if="['queued','running'].includes(operation.status)"
124
+ text
125
+ :disabled="!canCancel || state.mutating"
126
+ @click="runtime.cancel(operation)"
127
+ >
128
+ Cancel
129
+ </ElButton><ElButton
130
+ v-if="operation.resultFileKey"
131
+ text
132
+ :disabled="state.mutating"
133
+ @click="runtime.download(operation.resultFileKey)"
134
+ >
135
+ Result
136
+ </ElButton><ElButton
137
+ v-if="operation.errorFileKey"
138
+ text
139
+ :disabled="state.mutating"
140
+ @click="runtime.download(operation.errorFileKey)"
141
+ >
142
+ Errors
143
+ </ElButton>
144
+ </td>
145
+ </tr>
146
+ </tbody>
147
+ </table>
148
+ </div>
149
+ </PageContent>
150
+ </template>
151
+
152
+ <style scoped>
153
+ .submission { display: grid; gap: 8px; max-width: 720px; margin-bottom: 16px; }
154
+ .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: 10px 8px; border-bottom: 1px solid var(--el-border-color); text-align: left; } progress { min-width: 120px; }
155
+ </style>
@@ -0,0 +1,96 @@
1
+ export type ImportExportDirection = 'import' | 'export'
2
+ export type ImportExportStatus = 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled' | 'expired'
3
+
4
+ export interface ImportExportOperation {
5
+ operationKey: string
6
+ providerKey: string
7
+ direction: ImportExportDirection
8
+ status: ImportExportStatus
9
+ inputFileKey: string | null
10
+ resultFileKey: string | null
11
+ errorFileKey: string | null
12
+ taskJobKey: string | null
13
+ schemaRevision: string
14
+ processedRows: number
15
+ acceptedRows: number
16
+ rejectedRows: number
17
+ totalRows: number
18
+ revision: number
19
+ lastErrorCode: string | null
20
+ retentionUntil: string
21
+ createdAt: string
22
+ updatedAt: string
23
+ completedAt: string | null
24
+ }
25
+
26
+ export interface ImportExportList { items: ImportExportOperation[]; page: number; pageSize: number; total: number }
27
+ export interface ImportExportTransportResult { status: number; body: unknown; headers: Headers }
28
+ export interface ImportExportTransport {
29
+ list(status: ImportExportStatus, page: number, pageSize: number, signal: AbortSignal): Promise<ImportExportTransportResult>
30
+ submitImport(providerKey: string, fileKey: string, mapping: Record<string, string>, idempotencyKey: string, signal: AbortSignal): Promise<ImportExportTransportResult>
31
+ submitExport(providerKey: string, idempotencyKey: string, signal: AbortSignal): Promise<ImportExportTransportResult>
32
+ cancel(operationKey: string, revision: number, signal: AbortSignal): Promise<ImportExportTransportResult>
33
+ download(fileKey: string, signal: AbortSignal): Promise<Response>
34
+ }
35
+
36
+ const operationPattern = /^iox_[0-9a-f]{32}$/
37
+ const filePattern = /^file_[0-9a-f]{32}$/
38
+ const jobPattern = /^job_[0-9a-f]{32}$/
39
+ const providerPattern = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
40
+ const revisionPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/
41
+ const errorPattern = /^[A-Z][A-Z0-9_]{2,63}$/
42
+ const datePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
43
+
44
+ const record = (value: unknown): Record<string, unknown> => {
45
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
46
+ return value as Record<string, unknown>
47
+ }
48
+ const exact = (value: Record<string, unknown>, keys: readonly string[]): void => {
49
+ if (Object.keys(value).sort().join('|') !== [...keys].sort().join('|')) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
50
+ }
51
+ const nullablePattern = (value: unknown, pattern: RegExp): value is string | null => value === null || (typeof value === 'string' && pattern.test(value))
52
+ const integer = (value: unknown, minimum = 0): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum
53
+
54
+ export const parseOperation = (value: unknown): ImportExportOperation => {
55
+ const item = record(value)
56
+ exact(item, ['operation_key', 'provider_key', 'direction', 'format', 'status', 'input_file_key', 'result_file_key', 'error_file_key', 'task_job_key', 'schema_revision', 'mapping', 'processed_rows', 'accepted_rows', 'rejected_rows', 'total_rows', 'revision', 'last_error_code', 'retention_until', 'created_at', 'updated_at', 'completed_at'])
57
+ if (!operationPattern.test(String(item.operation_key)) || !providerPattern.test(String(item.provider_key))
58
+ || (item.direction !== 'import' && item.direction !== 'export') || item.format !== 'csv'
59
+ || !['queued', 'running', 'cancel_requested', 'succeeded', 'failed', 'cancelled', 'expired'].includes(String(item.status))
60
+ || !nullablePattern(item.input_file_key, filePattern) || !nullablePattern(item.result_file_key, filePattern)
61
+ || !nullablePattern(item.error_file_key, filePattern) || !nullablePattern(item.task_job_key, jobPattern)
62
+ || !revisionPattern.test(String(item.schema_revision)) || !integer(item.processed_rows) || !integer(item.accepted_rows)
63
+ || !integer(item.rejected_rows) || !integer(item.total_rows) || !integer(item.revision, 1)
64
+ || (item.accepted_rows as number) + (item.rejected_rows as number) > (item.processed_rows as number)
65
+ || (item.last_error_code !== null && (typeof item.last_error_code !== 'string' || !errorPattern.test(item.last_error_code)))
66
+ || !datePattern.test(String(item.retention_until)) || !datePattern.test(String(item.created_at)) || !datePattern.test(String(item.updated_at))
67
+ || (item.completed_at !== null && (typeof item.completed_at !== 'string' || !datePattern.test(item.completed_at)))) {
68
+ throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
69
+ }
70
+ const terminal = ['succeeded', 'failed', 'cancelled', 'expired'].includes(String(item.status))
71
+ if ((item.direction === 'import') !== (item.input_file_key !== null) || terminal !== (item.completed_at !== null)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
72
+ const mapping = record(item.mapping)
73
+ for (const [source, target] of Object.entries(mapping)) if (source === '' || source.length > 120 || typeof target !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(target)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
74
+ return {
75
+ operationKey: item.operation_key as string, providerKey: item.provider_key as string,
76
+ direction: item.direction as ImportExportDirection, status: item.status as ImportExportStatus,
77
+ inputFileKey: item.input_file_key as string | null, resultFileKey: item.result_file_key as string | null,
78
+ errorFileKey: item.error_file_key as string | null, taskJobKey: item.task_job_key as string | null,
79
+ schemaRevision: item.schema_revision as string, processedRows: item.processed_rows as number,
80
+ acceptedRows: item.accepted_rows as number, rejectedRows: item.rejected_rows as number,
81
+ totalRows: item.total_rows as number, revision: item.revision as number,
82
+ lastErrorCode: item.last_error_code as string | null, retentionUntil: item.retention_until as string,
83
+ createdAt: item.created_at as string, updatedAt: item.updated_at as string, completedAt: item.completed_at as string | null,
84
+ }
85
+ }
86
+
87
+ export const parseOperationResponse = (value: unknown): ImportExportOperation => {
88
+ const body = record(value); exact(body, ['data', 'meta']); record(body.meta); return parseOperation(body.data)
89
+ }
90
+ export const parseOperationList = (value: unknown): ImportExportList => {
91
+ const body = record(value); exact(body, ['data', 'meta'])
92
+ const data = record(body.data); exact(data, ['items']); if (!Array.isArray(data.items)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
93
+ const meta = record(body.meta); exact(meta, ['request_id', 'page', 'page_size', 'total'])
94
+ if (typeof meta.request_id !== 'string' || meta.request_id === '' || !integer(meta.page, 1) || !integer(meta.page_size, 1) || !integer(meta.total)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
95
+ return { items: data.items.map(parseOperation), page: meta.page, pageSize: meta.page_size, total: meta.total }
96
+ }
@@ -0,0 +1,3 @@
1
+ export * from './contracts'
2
+ export * from './runtime'
3
+ export { default as ImportExportPage } from './ImportExportPage.vue'
@@ -0,0 +1,128 @@
1
+ import { defineAdminModule } from '@peanut-admin/admin/core'
2
+ import type { AdminModuleContribution } from '@peanut-admin/admin/core'
3
+ import { inject, reactive } from 'vue'
4
+ import type { InjectionKey } from 'vue'
5
+ import { parseOperationList, parseOperationResponse } from './contracts'
6
+ import type { ImportExportOperation, ImportExportStatus, ImportExportTransport, ImportExportTransportResult } from './contracts'
7
+
8
+ export const IMPORT_EXPORT_MODULE_KEY = 'peanut.import-export' as const
9
+ export const IMPORT_EXPORT_ROUTE_NAME = 'peanut.import-export.list' as const
10
+ export const IMPORT_EXPORT_ROUTE_PATH = '/app/import-export' as const
11
+ export const IMPORT_EXPORT_READ_PERMISSION = 'peanut.import-export.read' as const
12
+ export const IMPORT_EXPORT_CREATE_PERMISSION = 'peanut.import-export.create' as const
13
+ export const IMPORT_EXPORT_CANCEL_PERMISSION = 'peanut.import-export.cancel' as const
14
+ export const IMPORT_EXPORT_STORE_KEY = 'peanut.import-export.runtime' as const
15
+
16
+ export interface ImportExportError { message: string; requestId: string | null; status: number | null }
17
+ export interface ImportExportState {
18
+ items: ImportExportOperation[]; status: ImportExportStatus; page: number; pageSize: number; total: number
19
+ loading: boolean; mutating: boolean; error: ImportExportError | null
20
+ }
21
+ export interface ImportExportRuntime {
22
+ state: ImportExportState
23
+ canCreate(): boolean
24
+ canCancel(): boolean
25
+ load(): Promise<void>
26
+ setStatus(status: ImportExportStatus): Promise<void>
27
+ submitImport(providerKey: string, fileKey: string, mapping: Record<string, string>): Promise<void>
28
+ submitExport(providerKey: string): Promise<void>
29
+ cancel(operation: ImportExportOperation): Promise<void>
30
+ download(fileKey: string): Promise<void>
31
+ dispose(): void
32
+ }
33
+ export interface ImportExportRuntimeOptions {
34
+ transport: ImportExportTransport
35
+ canRead(): boolean
36
+ canCreate(): boolean
37
+ canCancel(): boolean
38
+ saveDownload?: (response: Response, fileKey: string, signal: AbortSignal) => Promise<void>
39
+ idempotencyKey?: () => string
40
+ }
41
+
42
+ const requestId = (result: ImportExportTransportResult): string | null => {
43
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body) ? result.body as Record<string, unknown> : {}
44
+ const value = body.request_id ?? result.headers.get('X-Request-Id')
45
+ return typeof value === 'string' && value !== '' ? value : null
46
+ }
47
+ const failure = (result: ImportExportTransportResult): ImportExportError => {
48
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body) ? result.body as Record<string, unknown> : {}
49
+ return { message: typeof body.detail === 'string' && body.detail !== '' ? body.detail : `Import/export request failed (${result.status}).`, requestId: requestId(result), status: result.status }
50
+ }
51
+ const key = (): string => `web-${crypto.randomUUID()}`
52
+ const save = async (response: Response, fileKey: string, signal: AbortSignal): Promise<void> => {
53
+ const blob = await response.blob(); if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
54
+ const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
55
+ anchor.href = url; anchor.download = `${fileKey}.csv`; anchor.click(); URL.revokeObjectURL(url)
56
+ }
57
+
58
+ export const createImportExportRuntime = (options: ImportExportRuntimeOptions): ImportExportRuntime => {
59
+ const state = reactive<ImportExportState>({ items: [], status: 'queued', page: 1, pageSize: 20, total: 0, loading: false, mutating: false, error: null })
60
+ let disposeEpoch = 0; let readEpoch = 0; let downloadEpoch = 0
61
+ let readController: AbortController | null = null; let commandController: AbortController | null = null; let downloadController: AbortController | null = null
62
+ const aborted = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError'
63
+ const load = async (): Promise<void> => {
64
+ const currentDispose = disposeEpoch; const currentRead = ++readEpoch
65
+ readController?.abort(); const controller = new AbortController(); readController = controller
66
+ state.loading = true; state.error = null
67
+ try {
68
+ if (!options.canRead()) throw new Error('IMPORT_EXPORT_PERMISSION_DENIED')
69
+ const status = state.status; const page = state.page; const pageSize = state.pageSize
70
+ const result = await options.transport.list(status, page, pageSize, controller.signal)
71
+ if (currentDispose !== disposeEpoch || currentRead !== readEpoch) return
72
+ if (result.status !== 200) { state.error = failure(result); return }
73
+ const list = parseOperationList(result.body); state.items = [...list.items]; state.page = list.page; state.pageSize = list.pageSize; state.total = list.total
74
+ } catch (error) { if (currentDispose === disposeEpoch && currentRead === readEpoch && !aborted(error)) state.error = { message: 'The import/export service could not be reached.', requestId: null, status: null } }
75
+ finally {
76
+ if (readController === controller) readController = null
77
+ if (currentDispose === disposeEpoch && currentRead === readEpoch) state.loading = false
78
+ }
79
+ }
80
+ const mutate = async (operation: (signal: AbortSignal) => Promise<ImportExportTransportResult>): Promise<void> => {
81
+ if (state.mutating || commandController !== null) return
82
+ const currentDispose = disposeEpoch; const controller = new AbortController(); commandController = controller
83
+ state.mutating = true; state.error = null
84
+ try {
85
+ const result = await operation(controller.signal); if (currentDispose !== disposeEpoch) return
86
+ if (result.status !== 201 && result.status !== 200) { state.error = failure(result); return }
87
+ parseOperationResponse(result.body); await load()
88
+ } catch (error) { if (currentDispose === disposeEpoch && !aborted(error)) state.error = { message: 'The import/export request could not be completed.', requestId: null, status: null } }
89
+ finally {
90
+ if (commandController === controller) commandController = null
91
+ if (currentDispose === disposeEpoch) state.mutating = false
92
+ }
93
+ }
94
+ return {
95
+ state, canCreate: options.canCreate, canCancel: options.canCancel, load,
96
+ async setStatus(status) { state.status = status; state.page = 1; await load() },
97
+ async submitImport(providerKey, fileKey, mapping) { if (!options.canCreate()) return; await mutate(signal => options.transport.submitImport(providerKey, fileKey, mapping, (options.idempotencyKey ?? key)(), signal)) },
98
+ async submitExport(providerKey) { if (!options.canCreate()) return; await mutate(signal => options.transport.submitExport(providerKey, (options.idempotencyKey ?? key)(), signal)) },
99
+ async cancel(operation) { if (!options.canCancel() || !['queued', 'running'].includes(operation.status)) return; await mutate(signal => options.transport.cancel(operation.operationKey, operation.revision, signal)) },
100
+ async download(fileKey) {
101
+ const currentDispose = disposeEpoch; const currentDownload = ++downloadEpoch
102
+ downloadController?.abort(); const controller = new AbortController(); downloadController = controller; state.error = null
103
+ try {
104
+ const response = await options.transport.download(fileKey, controller.signal)
105
+ if (currentDispose !== disposeEpoch || currentDownload !== downloadEpoch) return
106
+ if (!response.ok) { let body: unknown = null; try { body = await response.json() } catch { body = null }; if (currentDispose === disposeEpoch && currentDownload === downloadEpoch) state.error = failure({ status: response.status, body, headers: response.headers }); return }
107
+ await (options.saveDownload ?? save)(response, fileKey, controller.signal)
108
+ } catch (error) { if (currentDispose === disposeEpoch && currentDownload === downloadEpoch && !aborted(error)) state.error = { message: 'The CSV file could not be downloaded.', requestId: null, status: null } }
109
+ finally { if (downloadController === controller) downloadController = null }
110
+ },
111
+ dispose() {
112
+ disposeEpoch += 1; readEpoch += 1; downloadEpoch += 1
113
+ readController?.abort(); commandController?.abort(); downloadController?.abort()
114
+ readController = null; commandController = null; downloadController = null
115
+ state.items = []; state.status = 'queued'; state.page = 1; state.pageSize = 20; state.total = 0
116
+ state.loading = false; state.mutating = false; state.error = null
117
+ },
118
+ }
119
+ }
120
+
121
+ export const importExportRuntimeKey: InjectionKey<ImportExportRuntime> = Symbol(IMPORT_EXPORT_STORE_KEY)
122
+ export const useImportExportRuntime = (): ImportExportRuntime => { const runtime = inject(importExportRuntimeKey); if (runtime === undefined) throw new Error('IMPORT_EXPORT_RUNTIME_MISSING'); return runtime }
123
+ export const createImportExportModuleContribution = (runtime: ImportExportRuntime): AdminModuleContribution => defineAdminModule({
124
+ key: IMPORT_EXPORT_MODULE_KEY,
125
+ routes: [{ name: IMPORT_EXPORT_ROUTE_NAME, path: IMPORT_EXPORT_ROUTE_PATH, component: async () => ({ default: (await import('./ImportExportPage.vue')).default }), access: { moduleKey: IMPORT_EXPORT_MODULE_KEY, permissionKeys: [IMPORT_EXPORT_READ_PERMISSION] } }],
126
+ disposeOnTenantChange: true,
127
+ stores: [{ key: IMPORT_EXPORT_STORE_KEY, dispose: runtime.dispose }],
128
+ })