cabloy 5.1.102 → 5.1.103
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/.cabloy-version +1 -1
- package/CHANGELOG.md +29 -0
- package/CLAUDE.local.md +4 -0
- package/cabloy-docs/.vitepress/config.mjs +3 -0
- package/cabloy-docs/backend/file-guide.md +148 -0
- package/cabloy-docs/backend/image-guide.md +40 -40
- package/cabloy-docs/backend/jwt-guide.md +21 -0
- package/cabloy-docs/frontend/file-guide.md +147 -0
- package/cabloy-docs/frontend/image-guide.md +18 -9
- package/cabloy-docs/frontend/model-state-guide.md +12 -0
- package/cabloy-docs/frontend/use-state-data-best-practices.md +54 -0
- package/cabloy-docs/fullstack/file-workflow.md +135 -0
- package/cabloy-docs/fullstack/image-workflow.md +12 -3
- package/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/src/suite/a-training/modules/training-record/src/.metadata/index.ts +30 -4
- package/vona/src/suite/a-training/modules/training-record/src/bean/fileScene.dossierFile.ts +1 -1
- package/vona/src/suite/a-training/modules/training-record/src/bean/imageScene.sceneImage.ts +1 -0
- package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +8 -6
- package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +8 -6
- package/vona/src/suite/a-training/modules/training-record/src/index.ts +0 -1
- package/vona/src/suite/a-training/modules/training-record/src/lib/index.ts +0 -1
- package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +10 -12
- package/vona/src/suite/a-training/modules/training-student/src/bean/imageScene.studentImage.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordBase.tsx +9 -10
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +2 -2
- package/vona/src/suite-vendor/a-file/modules/a-file/package.json +3 -2
- package/vona/src/suite-vendor/a-file/modules/a-file/src/.metadata/index.ts +155 -35
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.file.ts +283 -54
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.fileUploadPolicy.ts +46 -63
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.index.ts +12 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.redlock.ts +1 -1
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.version.ts +3 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/schedule.fileDraftPrune.ts +29 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/serializerTransform.resolveView.ts +45 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/serializerTransform.resolveViews.ts +45 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/config/config.ts +6 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/controller/file.ts +64 -56
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadFinalizeRequest.ts +13 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadFinalizeResponse.ts +10 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadRequest.ts +0 -3
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadResponse.ts +2 -14
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDownloadRequest.ts +3 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadPolicyResponse.ts +3 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadResponse.ts +0 -25
- package/vona/src/{suite/a-training/modules/training-record/src/dto/recordDossierFileView.ts → suite-vendor/a-file/modules/a-file/src/dto/fileView.ts} +3 -15
- package/vona/src/suite-vendor/a-file/modules/a-file/src/entity/file.ts +10 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/fileUploadUrl.ts +329 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/index.ts +1 -0
- package/vona/src/suite-vendor/a-file/modules/a-file/src/types/file.ts +51 -10
- package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileProvider.ts +8 -3
- package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileScene.ts +1 -1
- package/vona/src/suite-vendor/a-file/modules/file-cloudflare/package.json +1 -1
- package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/bean/fileProvider.cloudflare.ts +12 -4
- package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/service/fileCloudflare.ts +52 -18
- package/vona/src/suite-vendor/a-file/modules/file-native/package.json +1 -1
- package/vona/src/suite-vendor/a-file/modules/file-native/src/bean/fileProvider.native.ts +5 -5
- package/vona/src/suite-vendor/a-file/modules/file-native/src/service/fileNative.ts +7 -8
- package/vona/src/suite-vendor/a-file/package.json +4 -4
- package/vona/src/suite-vendor/a-image/modules/a-image/package.json +1 -1
- package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/index.ts +16 -19
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.image.ts +99 -74
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageUploadPolicy.ts +28 -68
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.version.ts +1 -1
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveView.ts +3 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveViews.ts +3 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/config/config.ts +4 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/controller/image.ts +78 -34
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDeliveryRequest.ts +3 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadRequest.ts +0 -3
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadResponse.ts +1 -27
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadPolicyResponse.ts +6 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadResponse.ts +2 -36
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadUrlRequest.ts +0 -3
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageView.ts +4 -27
- package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/image.ts +1 -1
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/image.ts +36 -29
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageProvider.ts +4 -4
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageScene.ts +1 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/bean/imageProvider.cloudflare.ts +8 -11
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/lib/cloudflareImageUrl.ts +4 -16
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +17 -16
- package/vona/src/suite-vendor/a-image/modules/image-native/package.json +2 -6
- package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/index.ts +0 -37
- package/vona/src/suite-vendor/a-image/modules/image-native/src/bean/imageProvider.native.ts +13 -36
- package/vona/src/suite-vendor/a-image/modules/image-native/src/service/imageNative.ts +100 -113
- package/vona/src/suite-vendor/a-image/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/config/config.ts +3 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +9 -4
- package/vona/src/suite-vendor/a-vona/modules/a-upload/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-upload/src/bean/interceptor.upload.ts +113 -64
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +50 -12
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +610 -114
- package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +44 -1
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +50 -12
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +610 -114
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/.metadata/index.ts +16 -12
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/file.ts +25 -25
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/schemas.ts +18 -24
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/types.ts +228 -291
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/apiSchema/file.ts +5 -5
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/bean/tableCell.file.tsx +23 -5
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/component/formFieldFile/controller.tsx +72 -43
- package/zova/src/suite/cabloy-basic/modules/basic-file/src/lib/file.ts +22 -4
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/index.ts +16 -42
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/image.ts +5 -25
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/schemas.ts +10 -24
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/types.ts +216 -333
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/image.ts +0 -5
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/bean/tableCell.image.tsx +38 -12
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx +79 -32
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/preview.ts +22 -4
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/image.ts +3 -4
- package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/utils.ts +29 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
- package/vona/src/suite/a-training/modules/training-record/src/lib/resolveDossierFiles.ts +0 -34
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenRequest.ts +0 -24
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenResponse.ts +0 -16
- package/vona/src/suite-vendor/a-image/modules/image-native/src/controller/image.ts +0 -57
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/imageNativeImage.ts +0 -31
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/imageNativeImage.ts +0 -13
|
@@ -220,6 +220,60 @@ The better middle ground is:
|
|
|
220
220
|
|
|
221
221
|
That is very different from rebuilding a second fetch lifecycle.
|
|
222
222
|
|
|
223
|
+
### Choose the wait semantic: loaded versus fresh
|
|
224
|
+
|
|
225
|
+
Waiting for an existing query can mean two different things:
|
|
226
|
+
|
|
227
|
+
- **Loaded** means a value exists.
|
|
228
|
+
`$QueryEnsureLoaded(...)` waits only when `query.data === undefined`, so it can continue with an older cached value.
|
|
229
|
+
- **Fresh** means the model's domain-specific validity rule accepts the value.
|
|
230
|
+
`$QueryGetFresh(...)` and `$QueryEnsureFresh(...)` receive an `isStale(query)` predicate, so the model decides whether loaded data is still safe to consume.
|
|
231
|
+
|
|
232
|
+
| Need | Helper | Missing or stale behavior | Return shape |
|
|
233
|
+
| ------------------------------------ | ------------------------------- | ------------------------------------------------------------------------ | --------------------------- |
|
|
234
|
+
| An interaction needs any loaded data | `await $QueryEnsureLoaded(...)` | Waits only while `query.data === undefined` | Query object or `undefined` |
|
|
235
|
+
| Render may consume only fresh data | `$QueryGetFresh(...)` | Starts `query.suspense()` and returns no data for the current render | `TData \| undefined` |
|
|
236
|
+
| An interaction requires fresh data | `await $QueryEnsureFresh(...)` | Waits for `query.suspense()` and throws a query error when refresh fails | `TData \| undefined` |
|
|
237
|
+
|
|
238
|
+
Loaded is an availability condition. Fresh is a validity condition defined by the model; it might use `dataUpdatedAt`, response expiry metadata, or another business rule. `a-model` accepts the predicate and does not prescribe that policy.
|
|
239
|
+
|
|
240
|
+
For example, `ModelPassport` keeps the temporary-token query and its 30-second reuse rule in the model, while the generic helpers provide the consumption mechanics:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { $QueryEnsureFresh, $QueryGetFresh, BeanModelBase, Model } from 'zova-module-a-model';
|
|
244
|
+
|
|
245
|
+
@Model()
|
|
246
|
+
export class ModelPassport extends BeanModelBase {
|
|
247
|
+
getFreshTempAuthToken(options: TempTokenOptions): string | undefined {
|
|
248
|
+
return $QueryGetFresh(
|
|
249
|
+
() => this.getTempAuthToken(options),
|
|
250
|
+
query => this._isTempAuthTokenExpired(query.data, query.dataUpdatedAt, options.staleTime),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async ensureFreshTempAuthToken(options: TempTokenOptions): Promise<string | undefined> {
|
|
255
|
+
return await $QueryEnsureFresh(
|
|
256
|
+
() => this.getTempAuthToken(options),
|
|
257
|
+
query => this._isTempAuthTokenExpired(query.data, query.dataUpdatedAt, options.staleTime),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Use the non-blocking facade during render, where stale data should not build a protected URL:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const passportCode = this.$passport.getFreshTempAuthToken(options);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Use the awaited facade at an interaction boundary, where a download or preview must not continue with an expired token:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const passportCode = await this.$passport.ensureFreshTempAuthToken(options);
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Both paths reuse the same model-owned query. When `$QueryGetFresh(...)` finds stale data, it starts `query.suspense()` and returns `undefined` for the current render; reactive query state provides the replacement value on a later render. `$QueryEnsureFresh(...)` instead waits for that refresh and propagates query errors to the interaction flow.
|
|
276
|
+
|
|
223
277
|
## Practical rule 7: derive render-time state once per render when possible
|
|
224
278
|
|
|
225
279
|
Even when the query object is reused, a controller can still become noisy if it repeatedly derives the same values in several helper calls.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Fullstack File Workflow
|
|
2
|
+
|
|
3
|
+
This page follows a Cabloy Basic file field from Vona contract truth to Zova interaction. It is the forward contract loop for `a-file` and `basic-file`.
|
|
4
|
+
|
|
5
|
+
> Read this with [Backend File Guide](/backend/file-guide), [Frontend File Guide](/frontend/file-guide), and [Backend OpenAPI to Frontend SDK](/fullstack/openapi-to-sdk).
|
|
6
|
+
|
|
7
|
+
## One business field, two consumer surfaces
|
|
8
|
+
|
|
9
|
+
A file-backed business field persists IDs, while a DTO relation carries completed-file presentation data. The training-record module is the reference pattern:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// Vona scene: one trusted business capability
|
|
13
|
+
@FileScene({
|
|
14
|
+
public: false,
|
|
15
|
+
upload: {
|
|
16
|
+
maxSize: 20 * 1024 * 1024,
|
|
17
|
+
mimeTypes: ['application/pdf', 'application/zip', 'text/plain'],
|
|
18
|
+
extensions: ['.pdf', '.zip', '.txt'],
|
|
19
|
+
multiple: true,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
export class FileSceneDossierFile extends BeanBase {}
|
|
23
|
+
|
|
24
|
+
// Entity: persist only IDs and attach Zova resource renderers
|
|
25
|
+
@Api.field(
|
|
26
|
+
ZovaRender.field('basic-file:formFieldFile', {
|
|
27
|
+
fileScene: 'training-record:dossierFile',
|
|
28
|
+
relationName: 'dossierFiles',
|
|
29
|
+
maxCount: 3,
|
|
30
|
+
}),
|
|
31
|
+
ZovaRender.cell('basic-file:file', { relationName: 'dossierFiles' }),
|
|
32
|
+
v.array(v.tableIdentity()),
|
|
33
|
+
)
|
|
34
|
+
dossierFileIds?: TableIdentity[];
|
|
35
|
+
|
|
36
|
+
// Consumer DTO: resolve completed display/delivery data
|
|
37
|
+
@Api.field(
|
|
38
|
+
v.optional(),
|
|
39
|
+
v.serializerTransform('a-file:resolveViews', {
|
|
40
|
+
fieldName: 'dossierFileIds',
|
|
41
|
+
fileScene: 'training-record:dossierFile',
|
|
42
|
+
}),
|
|
43
|
+
v.array(DtoFileView),
|
|
44
|
+
)
|
|
45
|
+
dossierFiles?: DtoFileView[];
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Keep the persisted field, resolved relation, `relationName`, and scene name aligned. `DtoFileView` is a consumer projection; it does not replace ID persistence. The serializer verifies the optional scene while resolving IDs, preventing a relation from silently consuming a file from another business capability.
|
|
49
|
+
|
|
50
|
+
## Three ingestion paths and readiness
|
|
51
|
+
|
|
52
|
+
### 1. Ordinary local upload
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
Zova field
|
|
56
|
+
→ authenticated POST /file/upload(fileScene, file)
|
|
57
|
+
→ Vona validates received multipart file and resolves scene/provider
|
|
58
|
+
→ provider storage
|
|
59
|
+
→ ready action response
|
|
60
|
+
→ ID field and relation update
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The backend owns validation and storage. The response is ready immediately after backend storage; this is the portable Native-compatible path and needs no file upload token.
|
|
64
|
+
|
|
65
|
+
### 2. Remote HTTPS URL import
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
authorized server/API consumer
|
|
69
|
+
→ POST /file/upload-url
|
|
70
|
+
→ provider capability check
|
|
71
|
+
→ secure HTTPS remote-fetch boundary
|
|
72
|
+
→ provider storage
|
|
73
|
+
→ ready action response
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Remote import is not built into the browser field. It is a server/provider operation: the selected provider validates remote URL safety and actual content before storage. Native does not support this path; Cloudflare R2 does. A browser must not implement its own unrestricted remote fetch.
|
|
77
|
+
|
|
78
|
+
### 3. Third-party direct upload
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
policy.directUpload
|
|
82
|
+
→ POST /file/direct-upload
|
|
83
|
+
→ Cabloy draft + raw-body provider target
|
|
84
|
+
→ browser transfers raw File without Cabloy credentials
|
|
85
|
+
→ POST /file/direct-upload/finalize
|
|
86
|
+
→ provider metadata verification + ready state
|
|
87
|
+
→ ID field and relation update
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A direct target is draft-only, not a completed file. Finalization is the only promotion to `ready`; drafts cannot resolve views, be downloaded, or enter business IDs/relations. The frontend uses returned method/headers for raw file transfer and stores only the finalized response.
|
|
91
|
+
|
|
92
|
+
## Forward contract loop
|
|
93
|
+
|
|
94
|
+
When a File contract changes, work from backend truth toward generated consumers:
|
|
95
|
+
|
|
96
|
+
1. define or update the Vona `@FileScene`, persisted ID field, controller/DTO policy, and `a-file:resolveView(s)` relation;
|
|
97
|
+
2. regenerate Vona metadata and inspect the emitted Swagger/OpenAPI contract;
|
|
98
|
+
3. regenerate the filtered `basic-file` OpenAPI consumer;
|
|
99
|
+
4. keep hand-authored Zova behavior in `ModelFile`, `ControllerFormFieldFile`, and `TableCellFile` thin and policy-driven;
|
|
100
|
+
5. build the affected Admin and Web flavors, then run `npm run deps:vona` for the reverse dependency handoff.
|
|
101
|
+
|
|
102
|
+
Generated contract outputs include:
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
zova/.../basic-file/src/api/file.ts
|
|
106
|
+
zova/.../basic-file/src/apiSchema/file.ts
|
|
107
|
+
zova/.../basic-file/src/api/openapi/types.ts
|
|
108
|
+
zova/.../basic-file/src/api/openapi/schemas.ts
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Do not hand-edit these generated files. `ModelFile`, the form-field controller, the table cell, and Vona render metadata are hand-authored extension points.
|
|
112
|
+
|
|
113
|
+
## Cross-layer invariants
|
|
114
|
+
|
|
115
|
+
| Layer | Owns | Must not own |
|
|
116
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
117
|
+
| Vona `a-file` | scene policy, provider/client selection, actual validation, lifecycle, storage, visibility, and delivery | browser-controlled provider or storage choices |
|
|
118
|
+
| DTO serializer | safe `DtoFileView` projection and scene matching | persistence replacement or storage metadata disclosure |
|
|
119
|
+
| Zova `basic-file` | picker UX, local UX checks, policy-driven transport, ID/relation synchronization | provider selection, storage URL construction, or draft persistence |
|
|
120
|
+
| Provider transfer | raw body transfer for a direct target | Cabloy session credential forwarding |
|
|
121
|
+
|
|
122
|
+
The frontend reads `downloadUrl` from a resolved view; it never constructs delivery URLs from IDs, buckets, or object keys. Upload-policy capability is semantic (`directUpload`), so frontend code never branches on provider identity.
|
|
123
|
+
|
|
124
|
+
## Verification checklist
|
|
125
|
+
|
|
126
|
+
For a File contract or workflow change, verify:
|
|
127
|
+
|
|
128
|
+
1. the scene policy and persisted ID field match the intended business capability;
|
|
129
|
+
2. create/view/list DTO schemas expose the matching `DtoFileView` relation and serializer transform;
|
|
130
|
+
3. Swagger/OpenAPI contains the expected policy, multipart, direct-finalize, and URL-import actions;
|
|
131
|
+
4. regenerated `basic-file` API/schema output is used rather than hand-patched;
|
|
132
|
+
5. Native reports no direct/URL-import capability while the configured Cloudflare R2 scene advertises direct upload only when creation and finalization are both available;
|
|
133
|
+
6. a draft cannot resolve a view or download URL, and the form updates only after finalization;
|
|
134
|
+
7. private/public delivery still uses resolved URLs without leaking provider/storage data;
|
|
135
|
+
8. run the affected Admin and Web builds and then `npm run deps:vona` after a generated frontend handoff.
|
|
@@ -291,11 +291,20 @@ The form-side resource is responsible for:
|
|
|
291
291
|
- selecting files
|
|
292
292
|
- performing frontend validation
|
|
293
293
|
- optionally cropping and resizing in the browser
|
|
294
|
-
-
|
|
295
|
-
-
|
|
296
|
-
-
|
|
294
|
+
- selecting the policy-supported ordinary or direct upload transport
|
|
295
|
+
- binding the finalized stored image ID field
|
|
296
|
+
- synchronizing the relation preview only after upload completion
|
|
297
297
|
- synchronizing the resolved relation field for immediate UI refresh
|
|
298
298
|
|
|
299
|
+
### Upload transport selection
|
|
300
|
+
|
|
301
|
+
The form field waits for the backend image-scene upload policy and selects one of two server-authorized paths:
|
|
302
|
+
|
|
303
|
+
- ordinary scenes submit authenticated multipart `imageScene` and `image` data to Cabloy;
|
|
304
|
+
- direct-capable scenes create a direct-upload session, send the image to the returned provider URL without Cabloy credentials, and finalize using the returned Cabloy image ID.
|
|
305
|
+
|
|
306
|
+
The browser does not branch on provider identity. In both paths, it updates the stored ID field and resolved relation preview only after it has a completed image response; a direct-upload draft is never used as the form value.
|
|
307
|
+
|
|
299
308
|
### `basic-image:image`
|
|
300
309
|
|
|
301
310
|
The table-side resource is responsible for:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.73",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
|
@@ -388,20 +388,46 @@ declare module 'vona-module-training-record' {
|
|
|
388
388
|
}
|
|
389
389
|
}
|
|
390
390
|
/** ssrMenu: end */
|
|
391
|
+
/** fileScene: begin */
|
|
392
|
+
export * from '../bean/fileScene.dossierFile.ts';
|
|
393
|
+
|
|
394
|
+
import { type IDecoratorFileSceneOptions } from 'vona-module-a-file';
|
|
395
|
+
declare module 'vona-module-a-file' {
|
|
396
|
+
|
|
397
|
+
export interface IFileSceneRecord {
|
|
398
|
+
'training-record:dossierFile': IDecoratorFileSceneOptions;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
}
|
|
403
|
+
declare module 'vona-module-training-record' {
|
|
404
|
+
|
|
405
|
+
export interface FileSceneDossierFile {
|
|
406
|
+
/** @internal */
|
|
407
|
+
get scope(): ScopeModuleTrainingRecord;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface FileSceneDossierFile {
|
|
411
|
+
get $beanFullName(): 'training-record.fileScene.dossierFile';
|
|
412
|
+
get $onionName(): 'training-record:dossierFile';
|
|
413
|
+
get $onionOptions(): IDecoratorFileSceneOptions;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** fileScene: end */
|
|
391
417
|
/** imageScene: begin */
|
|
392
418
|
export * from '../bean/imageScene.sceneImage.ts';
|
|
393
419
|
|
|
394
420
|
import { type IDecoratorImageSceneOptions } from 'vona-module-a-image';
|
|
395
421
|
declare module 'vona-module-a-image' {
|
|
396
|
-
|
|
422
|
+
|
|
397
423
|
export interface IImageSceneRecord {
|
|
398
424
|
'training-record:sceneImage': IDecoratorImageSceneOptions;
|
|
399
425
|
}
|
|
400
426
|
|
|
401
|
-
|
|
427
|
+
|
|
402
428
|
}
|
|
403
429
|
declare module 'vona-module-training-record' {
|
|
404
|
-
|
|
430
|
+
|
|
405
431
|
export interface ImageSceneSceneImage {
|
|
406
432
|
/** @internal */
|
|
407
433
|
get scope(): ScopeModuleTrainingRecord;
|
|
@@ -411,7 +437,7 @@ declare module 'vona-module-training-record' {
|
|
|
411
437
|
get $beanFullName(): 'training-record.imageScene.sceneImage';
|
|
412
438
|
get $onionName(): 'training-record:sceneImage';
|
|
413
439
|
get $onionOptions(): IDecoratorImageSceneOptions;
|
|
414
|
-
}
|
|
440
|
+
}
|
|
415
441
|
}
|
|
416
442
|
/** imageScene: end */
|
|
417
443
|
/** locale: begin */
|
|
@@ -2,6 +2,7 @@ import { BeanBase } from 'vona';
|
|
|
2
2
|
import { FileScene } from 'vona-module-a-file';
|
|
3
3
|
|
|
4
4
|
@FileScene({
|
|
5
|
+
public: false,
|
|
5
6
|
upload: {
|
|
6
7
|
maxSize: 20 * 1024 * 1024,
|
|
7
8
|
mimeTypes: [
|
|
@@ -16,7 +17,6 @@ import { FileScene } from 'vona-module-a-file';
|
|
|
16
17
|
],
|
|
17
18
|
extensions: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.zip', '.txt'],
|
|
18
19
|
multiple: true,
|
|
19
|
-
public: false,
|
|
20
20
|
},
|
|
21
21
|
})
|
|
22
22
|
export class FileSceneDossierFile extends BeanBase {}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IDecoratorDtoOptions } from 'vona-module-a-web';
|
|
2
2
|
|
|
3
|
+
import { DtoFileView } from 'vona-module-a-file';
|
|
3
4
|
import { DtoImageView } from 'vona-module-a-image';
|
|
4
5
|
import { Api, v } from 'vona-module-a-openapiutils';
|
|
5
6
|
import { $Dto } from 'vona-module-a-orm';
|
|
@@ -7,9 +8,7 @@ import { Dto } from 'vona-module-a-web';
|
|
|
7
8
|
import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
|
|
8
9
|
|
|
9
10
|
import { $locale } from '../.metadata/locales.ts';
|
|
10
|
-
import { resolveDossierFiles } from '../lib/resolveDossierFiles.ts';
|
|
11
11
|
import { ModelRecord } from '../model/record.ts';
|
|
12
|
-
import { DtoRecordDossierFileView } from './recordDossierFileView.ts';
|
|
13
12
|
|
|
14
13
|
export interface IDtoOptionsRecordSelectResItem extends IDecoratorDtoOptions {}
|
|
15
14
|
|
|
@@ -34,6 +33,7 @@ export class DtoRecordSelectResItem extends $Dto.get(() => ModelRecord) {
|
|
|
34
33
|
v.serializerTransform('a-image:resolveViews', {
|
|
35
34
|
fieldName: 'sceneImageIds',
|
|
36
35
|
imageScene: 'training-record:sceneImage',
|
|
36
|
+
deliveryOptions: { audience: true },
|
|
37
37
|
}),
|
|
38
38
|
v.array(DtoImageView),
|
|
39
39
|
)
|
|
@@ -43,12 +43,14 @@ export class DtoRecordSelectResItem extends $Dto.get(() => ModelRecord) {
|
|
|
43
43
|
v.title($locale('DossierFiles')),
|
|
44
44
|
ZovaRender.visible(false),
|
|
45
45
|
v.optional(),
|
|
46
|
-
v.serializerTransform('a-
|
|
47
|
-
|
|
46
|
+
v.serializerTransform('a-file:resolveViews', {
|
|
47
|
+
fieldName: 'dossierFileIds',
|
|
48
|
+
fileScene: 'training-record:dossierFile',
|
|
49
|
+
deliveryOptions: { audience: true },
|
|
48
50
|
}),
|
|
49
|
-
v.array(
|
|
51
|
+
v.array(DtoFileView),
|
|
50
52
|
)
|
|
51
|
-
dossierFiles?:
|
|
53
|
+
dossierFiles?: DtoFileView[];
|
|
52
54
|
|
|
53
55
|
@Api.field(
|
|
54
56
|
v.title($locale('Operations')),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IDecoratorDtoOptions } from 'vona-module-a-web';
|
|
2
2
|
|
|
3
|
+
import { DtoFileView } from 'vona-module-a-file';
|
|
3
4
|
import { DtoImageView } from 'vona-module-a-image';
|
|
4
5
|
import { $makeMetadata, Api, v } from 'vona-module-a-openapiutils';
|
|
5
6
|
import { $Dto } from 'vona-module-a-orm';
|
|
@@ -7,11 +8,9 @@ import { Dto } from 'vona-module-a-web';
|
|
|
7
8
|
import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
|
|
8
9
|
|
|
9
10
|
import { $locale } from '../.metadata/locales.ts';
|
|
10
|
-
import { resolveDossierFiles } from '../lib/resolveDossierFiles.ts';
|
|
11
11
|
import { ModelRecord } from '../model/record.ts';
|
|
12
12
|
import { DtoDetailRecordSubjectResItem } from './detailRecordSubjectResItem.tsx';
|
|
13
13
|
import { DtoDetailRecordSubjectView } from './detailRecordSubjectView.tsx';
|
|
14
|
-
import { DtoRecordDossierFileView } from './recordDossierFileView.ts';
|
|
15
14
|
|
|
16
15
|
export interface IDtoOptionsRecordView extends IDecoratorDtoOptions {}
|
|
17
16
|
|
|
@@ -46,6 +45,7 @@ export class DtoRecordView extends $Dto.get(() => ModelRecord, {
|
|
|
46
45
|
v.serializerTransform('a-image:resolveViews', {
|
|
47
46
|
fieldName: 'sceneImageIds',
|
|
48
47
|
imageScene: 'training-record:sceneImage',
|
|
48
|
+
deliveryOptions: { audience: true },
|
|
49
49
|
}),
|
|
50
50
|
v.array(DtoImageView),
|
|
51
51
|
)
|
|
@@ -55,12 +55,14 @@ export class DtoRecordView extends $Dto.get(() => ModelRecord, {
|
|
|
55
55
|
v.title($locale('DossierFiles')),
|
|
56
56
|
ZovaRender.visible(false),
|
|
57
57
|
v.optional(),
|
|
58
|
-
v.serializerTransform('a-
|
|
59
|
-
|
|
58
|
+
v.serializerTransform('a-file:resolveViews', {
|
|
59
|
+
fieldName: 'dossierFileIds',
|
|
60
|
+
fileScene: 'training-record:dossierFile',
|
|
61
|
+
deliveryOptions: { audience: true },
|
|
60
62
|
}),
|
|
61
|
-
v.array(
|
|
63
|
+
v.array(DtoFileView),
|
|
62
64
|
)
|
|
63
|
-
dossierFiles?:
|
|
65
|
+
dossierFiles?: DtoFileView[];
|
|
64
66
|
|
|
65
67
|
@Api.field(ZovaRender.visible(false), v.optional(), v.array(DtoDetailRecordSubjectResItem))
|
|
66
68
|
_trainingRecordSubjects?: DtoDetailRecordSubjectResItem[];
|
|
@@ -43,10 +43,6 @@ describe('record.test.ts', () => {
|
|
|
43
43
|
filename: 'attendance.txt',
|
|
44
44
|
contentType: 'text/plain',
|
|
45
45
|
public: false,
|
|
46
|
-
meta: {
|
|
47
|
-
category: 'attendanceSheet',
|
|
48
|
-
source: 'upload',
|
|
49
|
-
},
|
|
50
46
|
},
|
|
51
47
|
{
|
|
52
48
|
clientName: 'default',
|
|
@@ -96,18 +92,24 @@ describe('record.test.ts', () => {
|
|
|
96
92
|
assert.equal(String(record.studentId), String(studentId));
|
|
97
93
|
assert.equal(record.subjectCount, recordData.subjectCount);
|
|
98
94
|
assert.equal(record.totalScore, recordData.totalScore);
|
|
99
|
-
assert.equal(record.averageScore, recordData.averageScore);
|
|
95
|
+
assert.equal(Number(record.averageScore), recordData.averageScore);
|
|
100
96
|
assert.equal(new Date(record.trainingTime).toISOString(), trainingTime.toISOString());
|
|
101
97
|
assert.equal(record.dossierFileIds?.length, 1);
|
|
102
98
|
assert.equal(record.dossierFiles?.length, 1);
|
|
103
99
|
assert.equal(String(recordDossierFile?.id), String(dossierFileAttendance.id));
|
|
104
100
|
assert.equal(recordDossierFile?.filename, 'attendance.txt');
|
|
105
101
|
assert.equal(recordDossierFile?.signed, true);
|
|
106
|
-
assert.equal(recordDossierFile
|
|
102
|
+
assert.equal('meta' in (recordDossierFile ?? {}), false);
|
|
103
|
+
assert.equal('fileScene' in (recordDossierFile ?? {}), false);
|
|
107
104
|
assert.equal(record.trainingRecordSubjects?.length, 1);
|
|
108
105
|
assert.equal(recordSubject?.name, '__Math__');
|
|
109
106
|
assert.equal(recordSubject?.score, 95);
|
|
110
|
-
const
|
|
107
|
+
const passportCode = await app.bean.passport.createTempAuthToken({
|
|
108
|
+
path: '/api/file/download',
|
|
109
|
+
});
|
|
110
|
+
const downloadUrl = new URL(recordDossierFile.downloadUrl);
|
|
111
|
+
downloadUrl.searchParams.set('x-vona-passport-code', passportCode);
|
|
112
|
+
const downloadResponse = await fetch(downloadUrl);
|
|
111
113
|
assert.equal(downloadResponse.ok, true);
|
|
112
114
|
assert.equal(await downloadResponse.text(), dossierTextAttendance.toString());
|
|
113
115
|
|
|
@@ -129,10 +131,6 @@ describe('record.test.ts', () => {
|
|
|
129
131
|
filename: 'assessment.txt',
|
|
130
132
|
contentType: 'text/plain',
|
|
131
133
|
public: false,
|
|
132
|
-
meta: {
|
|
133
|
-
category: 'assessmentReport',
|
|
134
|
-
source: 'upload',
|
|
135
|
-
},
|
|
136
134
|
},
|
|
137
135
|
{
|
|
138
136
|
clientName: 'default',
|
|
@@ -174,7 +172,7 @@ describe('record.test.ts', () => {
|
|
|
174
172
|
assert.equal(record.name, dataUpdate.name);
|
|
175
173
|
assert.equal(record.subjectCount, dataUpdate.subjectCount);
|
|
176
174
|
assert.equal(record.totalScore, dataUpdate.totalScore);
|
|
177
|
-
assert.equal(record.averageScore, dataUpdate.averageScore);
|
|
175
|
+
assert.equal(Number(record.averageScore), dataUpdate.averageScore);
|
|
178
176
|
assert.equal(new Date(record.trainingTime).toISOString(), trainingTimeUpdate.toISOString());
|
|
179
177
|
assert.equal(record.dossierFileIds?.length, 2);
|
|
180
178
|
assert.equal(record.dossierFiles?.length, 2);
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import type { IDecoratorDtoOptions } from 'vona-module-a-web';
|
|
2
2
|
|
|
3
3
|
import { $Class } from 'vona';
|
|
4
|
+
import { DtoFileView } from 'vona-module-a-file';
|
|
4
5
|
import { DtoImageView } from 'vona-module-a-image';
|
|
5
6
|
import { $makeMetadata, Api, v } from 'vona-module-a-openapiutils';
|
|
6
7
|
import { $Dto } from 'vona-module-a-orm';
|
|
7
8
|
import { Dto } from 'vona-module-a-web';
|
|
8
|
-
import {
|
|
9
|
-
DtoRecordDossierFileView,
|
|
10
|
-
ModelRecord,
|
|
11
|
-
onEffectForTrainingRecordSubjects,
|
|
12
|
-
resolveDossierFiles,
|
|
13
|
-
} from 'vona-module-training-record';
|
|
9
|
+
import { ModelRecord, onEffectForTrainingRecordSubjects } from 'vona-module-training-record';
|
|
14
10
|
import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
|
|
15
11
|
|
|
16
12
|
import { $locale } from '../.metadata/locales.ts';
|
|
@@ -50,6 +46,7 @@ export class DtoDetailRecordBase extends $Dto.get(() => ModelRecord, {
|
|
|
50
46
|
v.serializerTransform('a-image:resolveViews', {
|
|
51
47
|
fieldName: 'sceneImageIds',
|
|
52
48
|
imageScene: 'training-record:sceneImage',
|
|
49
|
+
deliveryOptions: { audience: true },
|
|
53
50
|
}),
|
|
54
51
|
v.array($Class.partial(DtoImageView)),
|
|
55
52
|
)
|
|
@@ -59,10 +56,12 @@ export class DtoDetailRecordBase extends $Dto.get(() => ModelRecord, {
|
|
|
59
56
|
v.title($locale('DossierFiles')),
|
|
60
57
|
ZovaRender.visible(false),
|
|
61
58
|
v.optional(),
|
|
62
|
-
v.serializerTransform('a-
|
|
63
|
-
|
|
59
|
+
v.serializerTransform('a-file:resolveViews', {
|
|
60
|
+
fieldName: 'dossierFileIds',
|
|
61
|
+
fileScene: 'training-record:dossierFile',
|
|
62
|
+
deliveryOptions: { audience: true },
|
|
64
63
|
}),
|
|
65
|
-
v.array($Class.partial(
|
|
64
|
+
v.array($Class.partial(DtoFileView)),
|
|
66
65
|
)
|
|
67
|
-
dossierFiles?: Partial<
|
|
66
|
+
dossierFiles?: Partial<DtoFileView>[];
|
|
68
67
|
}
|
|
@@ -87,7 +87,7 @@ describe('student.test.ts', () => {
|
|
|
87
87
|
assert.equal(record?.name, '__Record__');
|
|
88
88
|
assert.equal(record?.subjectCount, 1);
|
|
89
89
|
assert.equal(record?.totalScore, 88);
|
|
90
|
-
assert.equal(record?.averageScore, 88);
|
|
90
|
+
assert.equal(Number(record?.averageScore), 88);
|
|
91
91
|
assert.equal(new Date(record?.trainingTime).toISOString(), trainingTime.toISOString());
|
|
92
92
|
assert.equal(record?.trainingRecordSubjects?.length, 1);
|
|
93
93
|
assert.equal(recordSubject?.name, '__Math__');
|
|
@@ -141,7 +141,7 @@ describe('student.test.ts', () => {
|
|
|
141
141
|
assert.equal(updatedRecord?.name, '__RecordNew__');
|
|
142
142
|
assert.equal(updatedRecord?.subjectCount, 2);
|
|
143
143
|
assert.equal(updatedRecord?.totalScore, 183);
|
|
144
|
-
assert.equal(updatedRecord?.averageScore, 91.5);
|
|
144
|
+
assert.equal(Number(updatedRecord?.averageScore), 91.5);
|
|
145
145
|
assert.equal(
|
|
146
146
|
new Date(updatedRecord?.trainingTime).toISOString(),
|
|
147
147
|
trainingTimeUpdate.toISOString(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-module-a-file",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Vona Module"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"assets",
|
|
11
11
|
"dist",
|
|
12
|
-
"src"
|
|
12
|
+
"src",
|
|
13
|
+
"cli"
|
|
13
14
|
],
|
|
14
15
|
"type": "module",
|
|
15
16
|
"exports": {
|