cabloy 5.1.134 → 5.1.136

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 (185) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-backend-scaffold/SKILL.md +9 -0
  3. package/.claude/skills/cabloy-zova-source-reading/SKILL.md +24 -1
  4. package/.claude/skills/cabloy-zova-source-reading/references/analysis-modes.md +12 -0
  5. package/.claude/skills/cabloy-zova-source-reading/references/core-reading-paths.md +28 -0
  6. package/.github/workflows/playwright-e2e.yml +2 -2
  7. package/.github/workflows/vona-test-pg.yml +1 -1
  8. package/.github/workflows/vona-test-sqlite3.yml +1 -1
  9. package/CHANGELOG.md +38 -0
  10. package/CLAUDE.md +1 -0
  11. package/cabloy-docs/.vitepress/config.mjs +7 -0
  12. package/cabloy-docs/backend/markdown-guide.md +189 -0
  13. package/cabloy-docs/frontend/component-guide.md +6 -0
  14. package/cabloy-docs/frontend/css-in-js-guide.md +6 -0
  15. package/cabloy-docs/frontend/markdown-guide.md +258 -0
  16. package/cabloy-docs/frontend/page-guide.md +48 -0
  17. package/cabloy-docs/frontend/page-params-guide.md +4 -2
  18. package/cabloy-docs/frontend/page-route-guide.md +2 -2
  19. package/cabloy-docs/frontend/zova-router-under-the-hood.md +2 -0
  20. package/cabloy-docs/frontend/zova-source-reading-map.md +53 -9
  21. package/cabloy-docs/fullstack/a-pay-payment-suite.md +333 -0
  22. package/cabloy-docs/fullstack/payment-sandbox-configuration.md +285 -0
  23. package/e2e/specs/a-commerce/commerce.spec.ts +503 -47
  24. package/e2e/specs/cabloy-basic/basic.spec.ts +30 -0
  25. package/package.json +1 -1
  26. package/vona/env/.env +2 -0
  27. package/vona/packages-cli/cli/package.json +1 -1
  28. package/vona/packages-cli/cli-set-api/cli/templates/init/error/boilerplate/config/errors.ts +6 -1
  29. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/entity/{{resourceName}}.tsx_ +2 -0
  30. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/entity/{{resourceName}}.tsx_ +2 -0
  31. package/vona/packages-cli/cli-set-api/package.json +1 -1
  32. package/vona/packages-utils/localeutil/package.json +1 -1
  33. package/vona/packages-utils/localeutil/src/util.ts +1 -1
  34. package/vona/packages-vona/vona/package.json +1 -1
  35. package/vona/packages-vona/vona-core/package.json +1 -1
  36. package/vona/packages-vona/vona-mock/package.json +1 -1
  37. package/vona/patches/{zova-core@5.1.82.patch → zova-core@5.1.84.patch} +2 -2
  38. package/vona/pnpm-lock.yaml +826 -270
  39. package/vona/pnpm-workspace.yaml +1 -1
  40. package/vona/src/suite/a-commerce/modules/commerce-catalog/package.json +1 -1
  41. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +99 -1
  42. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.index.ts +1 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +19 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
  45. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
  46. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/controller/product.ts +3 -3
  47. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productContentUpdate.tsx +14 -0
  48. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productContentView.tsx +19 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productCreate.tsx +48 -3
  50. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productPublicDetail.tsx +15 -0
  51. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productUpdate.tsx +48 -3
  52. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productView.tsx +52 -3
  53. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/productContent.tsx +36 -0
  54. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/model/product.ts +6 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/model/productContent.ts +22 -0
  56. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/product.ts +82 -6
  57. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/productContent.test.ts +271 -0
  58. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/productPresentation.test.ts +81 -2
  59. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +4 -1
  60. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +32 -15
  61. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +8 -2
  62. package/vona/src/suite/a-commerce/modules/commerce-siteweb/src/.metadata/index.ts +8 -2
  63. package/vona/src/suite/a-commerce/modules/commerce-siteweb/src/.metadata/locales.ts +18 -0
  64. package/vona/src/suite/a-commerce/modules/commerce-siteweb/src/bean/ssrMenu.home.ts +3 -1
  65. package/vona/src/suite/a-commerce/modules/commerce-siteweb/src/config/locale/en-us.ts +3 -0
  66. package/vona/src/suite/a-commerce/modules/commerce-siteweb/src/config/locale/zh-cn.ts +3 -0
  67. package/vona/src/suite/a-commerce/modules/commerce-siteweb/src/index.ts +1 -0
  68. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +13 -2
  69. package/vona/src/suite/a-commerce/modules/commerce-trade/src/lib/providerReference.ts +18 -0
  70. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +43 -8
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +74 -0
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/test/providerReference.test.ts +33 -0
  73. package/vona/src/suite/a-commerce/modules/commerce-trade/test/refundLifecycle.test.ts +73 -0
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +2 -0
  75. package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +30 -0
  76. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +7 -2
  77. package/vona/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +4 -1
  78. package/vona/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +4 -1
  79. package/vona/src/suite/a-home/modules/home-user/src/config/roles.ts +4 -4
  80. package/vona/src/suite/a-home/modules/home-user/src/entity/role.ts +2 -2
  81. package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +8 -1
  82. package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +6 -2
  83. package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +1 -0
  84. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +4 -4
  85. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/package.json +64 -0
  86. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/src/.metadata/index.ts +54 -0
  87. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/src/.metadata/this.ts +2 -0
  88. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/src/bean/bean.markdown.ts +137 -0
  89. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/src/index.ts +1 -0
  90. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/tsconfig.build.json +11 -0
  91. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/tsconfig.json +7 -0
  92. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
  93. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/bean/bean.ssr.ts +17 -0
  94. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/beanSsrSiteBase.ts +11 -3
  95. package/vona/src/suite-vendor/a-cabloy/package.json +2 -1
  96. package/vona/src/suite-vendor/a-cabloy/tsconfig.json +3 -0
  97. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  98. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.version.ts +1 -0
  99. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/providerOperation.tsx +8 -1
  100. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/lib/index.ts +1 -0
  101. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/lib/providerOperationFailure.ts +8 -0
  102. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +187 -29
  103. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payment.ts +4 -0
  104. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  105. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
  106. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +0 -6
  107. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/index.ts +1 -0
  108. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +2 -1
  109. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/bean/payProvider.stripe.ts +458 -22
  110. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/index.ts +1 -0
  111. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/lib/stripeGateway.ts +85 -0
  112. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/types/index.ts +2 -0
  113. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  114. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  116. package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +1 -0
  117. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  118. package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/bean.passport.ts +14 -0
  119. package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/guard.passport.ts +4 -0
  120. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/role.ts +3 -2
  121. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +4 -0
  122. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  123. package/zova/packages-utils/logger/package.json +2 -2
  124. package/zova/packages-utils/zova-jsx/package.json +2 -2
  125. package/zova/packages-zova/zova/package.json +3 -3
  126. package/zova/packages-zova/zova-core/package.json +3 -3
  127. package/zova/packages-zova/zova-core/src/core/sys/resource.ts +1 -1
  128. package/zova/packages-zova/zova-core/src/core/sys/util.ts +8 -0
  129. package/zova/pnpm-lock.yaml +570 -11
  130. package/zova/src/front/config/config/config.cabloyCommerce.ts +0 -6
  131. package/zova/src/suite/a-commerce/modules/commerce-catalog/package.json +3 -1
  132. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/api/openapi/schemas.ts +236 -36
  133. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/api/openapi/types.ts +4962 -2402
  134. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +1 -0
  135. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +1 -0
  136. package/zova/src/suite/a-commerce/modules/commerce-catalog/src/page/product/controller.tsx +8 -3
  137. package/zova/src/suite/a-commerce/modules/commerce-member/src/.metadata/index.ts +6 -7
  138. package/zova/src/suite/a-commerce/modules/commerce-member/src/page/address/controller.tsx +1 -3
  139. package/zova/src/suite/a-commerce/modules/commerce-member/src/routes.ts +2 -3
  140. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +13 -20
  141. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/cart/controller.tsx +3 -9
  142. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/checkout/controller.tsx +9 -11
  143. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/order/controller.tsx +0 -1
  144. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/orders/controller.tsx +6 -4
  145. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/payment/controller.tsx +78 -17
  146. package/zova/src/suite/a-commerce/modules/commerce-trade/src/routes.ts +10 -13
  147. package/zova/src/suite/a-home/modules/home-api/mock/passport.fake.ts +3 -3
  148. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +31 -1
  149. package/zova/src/suite/a-home/modules/home-api/src/api/homeBaseSiteCatalog.ts +24 -0
  150. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +329 -17
  151. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +5866 -1829
  152. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeBaseSiteCatalog.ts +13 -0
  153. package/zova/src/suite/a-home/modules/home-base/src/component/page/controller.tsx +1 -0
  154. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.header.tsx +0 -1
  155. package/zova/src/suite/cabloy-basic/modules/basic-details/src/types/detail.ts +1 -0
  156. package/zova/src/suite/cabloy-basic/modules/basic-markdown/package.json +61 -0
  157. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/.metadata/component/formFieldMarkdown.ts +36 -0
  158. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/.metadata/component/markdownHtml.ts +36 -0
  159. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/.metadata/index.ts +147 -0
  160. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/.metadata/this.ts +2 -0
  161. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/component/formFieldMarkdown/controller.tsx +111 -0
  162. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/component/formFieldMarkdown/render.tsx +40 -0
  163. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/component/formFieldMarkdown/style.ts +13 -0
  164. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/component/markdownHtml/controller.tsx +17 -0
  165. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/component/markdownHtml/render.tsx +9 -0
  166. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/component/markdownHtml/style.ts +13 -0
  167. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/index.ts +1 -0
  168. package/zova/src/suite/cabloy-basic/modules/basic-markdown/src/lib/richTextContentStyle.ts +142 -0
  169. package/zova/src/suite/cabloy-basic/modules/basic-markdown/tsconfig.build.json +13 -0
  170. package/zova/src/suite/cabloy-basic/modules/basic-markdown/tsconfig.json +5 -0
  171. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockPage/controller.tsx +25 -3
  172. package/zova/src/suite/cabloy-basic/modules/basic-page/src/types/page.ts +1 -0
  173. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/pageEntry.ts +1 -0
  174. package/zova/src/suite/cabloy-basic/package.json +1 -0
  175. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  176. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/model/resource.ts +7 -0
  177. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  178. package/zova/src/suite-vendor/a-zova/modules/a-logger/package.json +2 -2
  179. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  180. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/sys.router.ts +3 -2
  181. package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
  182. package/zova/src/suite-vendor/a-zova/modules/a-ssr/test/lib/ssrProfile.test.ts +88 -0
  183. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  184. package/zova/src/suite-vendor/a-zova/package.json +5 -5
  185. package/.claude/scheduled_tasks.lock +0 -1
@@ -0,0 +1,36 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IDecoratorEntityOptions } from 'vona-module-a-orm';
3
+
4
+ import { $makeMetadata, Api, v } from 'vona-module-a-openapiutils';
5
+ import { Entity, EntityBase } from 'vona-module-a-orm';
6
+ import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
7
+
8
+ import { $locale } from '../.metadata/locales.ts';
9
+
10
+ export interface IEntityOptionsProductContent extends IDecoratorEntityOptions {}
11
+
12
+ @Entity<IEntityOptionsProductContent>('commerceCatalogProductContent', {
13
+ openapi: { title: $locale('ProductContent') },
14
+ fields: {
15
+ id: $makeMetadata(ZovaRender.visible(false)),
16
+ iid: $makeMetadata(ZovaRender.visible(false)),
17
+ deleted: $makeMetadata(ZovaRender.visible(false)),
18
+ createdAt: $makeMetadata(ZovaRender.visible(false)),
19
+ updatedAt: $makeMetadata(ZovaRender.visible(false)),
20
+ },
21
+ })
22
+ export class EntityProductContent extends EntityBase {
23
+ @Api.field(v.tableIdentity(), ZovaRender.visible(false))
24
+ productId: TableIdentity;
25
+
26
+ @Api.field(
27
+ v.title($locale('DescriptionMarkdown')),
28
+ v.optional(),
29
+ ZovaRender.order(1),
30
+ ZovaRender.field('basic-markdown:formFieldMarkdown'),
31
+ )
32
+ descriptionMarkdown?: string;
33
+
34
+ @Api.field(v.title($locale('DescriptionHtml')), v.optional(), ZovaRender.visible(false))
35
+ descriptionHtml?: string;
36
+ }
@@ -15,6 +15,12 @@ export interface IModelOptionsProduct extends IDecoratorModelOptions<EntityProdu
15
15
  'categoryId',
16
16
  { columns: ['id', 'name', 'published'] },
17
17
  ),
18
+ productContent: $relation.hasOne('commerce-catalog:productContent', 'productId', {
19
+ columns: ['id', 'productId', 'descriptionMarkdown', 'descriptionHtml'],
20
+ }),
21
+ productContentForm: $relation.hasOne('commerce-catalog:productContent', 'productId', {
22
+ columns: ['id', 'productId', 'descriptionMarkdown'],
23
+ }),
18
24
  skus: $relation.hasMany('commerce-catalog:sku', 'productId'),
19
25
  skuAvailables: $relation.hasMany(
20
26
  'commerce-catalog:sku',
@@ -0,0 +1,22 @@
1
+ import type { IDecoratorModelOptions } from 'vona-module-a-orm';
2
+
3
+ import { $relation, BeanModelBase, Model } from 'vona-module-a-orm';
4
+
5
+ import { EntityProductContent } from '../entity/productContent.tsx';
6
+
7
+ export interface IModelOptionsProductContent extends IDecoratorModelOptions<EntityProductContent> {}
8
+
9
+ @Model<IModelOptionsProductContent>({
10
+ entity: EntityProductContent,
11
+ relations: {
12
+ product: $relation.belongsTo(
13
+ 'commerce-catalog:productContent',
14
+ 'commerce-catalog:product',
15
+ 'productId',
16
+ ),
17
+ },
18
+ cache: {
19
+ modelsClear: 'commerce-catalog:product',
20
+ },
21
+ })
22
+ export class ModelProductContent extends BeanModelBase<EntityProductContent> {}
@@ -4,9 +4,11 @@ import type { IQueryParams } from 'vona-module-a-orm';
4
4
 
5
5
  import { BeanBase } from 'vona';
6
6
  import { Service } from 'vona-module-a-bean';
7
+ import { Core } from 'vona-module-a-core';
7
8
 
8
9
  import type { DtoProductCreate } from '../dto/productCreate.tsx';
9
10
  import type { DtoProductPublic } from '../dto/productPublic.tsx';
11
+ import type { DtoProductPublicDetail } from '../dto/productPublicDetail.tsx';
10
12
  import type { DtoProductPublicSelectRes } from '../dto/productPublicSelectRes.tsx';
11
13
  import type { DtoProductSelectRes } from '../dto/productSelectRes.tsx';
12
14
  import type { DtoProductUpdate } from '../dto/productUpdate.tsx';
@@ -14,11 +16,35 @@ import type { DtoProductView } from '../dto/productView.tsx';
14
16
  import type { EntityProduct } from '../entity/product.tsx';
15
17
  import type { ModelProduct } from '../model/product.ts';
16
18
 
19
+ const serializationRetryOptions = {
20
+ retries: 3,
21
+ factor: 1,
22
+ minTimeout: 10,
23
+ maxTimeout: 10,
24
+ randomize: false,
25
+ errorCodes: [
26
+ '40001',
27
+ 'ER_LOCK_DEADLOCK',
28
+ 'ER_LOCK_WAIT_TIMEOUT',
29
+ 'SQLITE_BUSY',
30
+ 'SQLITE_BUSY_SNAPSHOT',
31
+ ],
32
+ ownerOnly: true,
33
+ };
34
+
17
35
  @Service()
18
36
  export class ServiceProduct extends BeanBase {
37
+ @Core.transaction()
19
38
  async create(product: DtoProductCreate): Promise<EntityProduct> {
20
39
  await this._ensureCategoryExists(product.categoryId);
21
- return await this.scope.model.product.insert(product);
40
+ const { productContentForm, ...productFields } = product as DtoProductCreate & {
41
+ productContentForm?: { descriptionMarkdown?: string };
42
+ };
43
+ const created = await this.scope.model.product.insert(productFields);
44
+ if (productContentForm !== undefined) {
45
+ await this._updateProductContent(created.id, productContentForm.descriptionMarkdown);
46
+ }
47
+ return created;
22
48
  }
23
49
 
24
50
  async select(params?: IQueryParams<ModelProduct>): Promise<DtoProductSelectRes> {
@@ -54,15 +80,19 @@ export class ServiceProduct extends BeanBase {
54
80
 
55
81
  async view(id: TableIdentity): Promise<DtoProductView | undefined> {
56
82
  return await this.scope.model.product.getById(id, {
57
- include: { category: true },
83
+ include: {
84
+ category: true,
85
+ productContentForm: { columns: ['id', 'productId', 'descriptionMarkdown'] },
86
+ },
58
87
  });
59
88
  }
60
89
 
61
- async viewPublic(id: TableIdentity): Promise<DtoProductPublic | undefined> {
90
+ async viewPublic(id: TableIdentity): Promise<DtoProductPublicDetail | undefined> {
62
91
  const product = await this.scope.model.product.get(this._getPublicProductWhere({ id }), {
63
92
  columns: ['id', 'title', 'description', 'categoryId'],
64
93
  include: {
65
94
  category: { columns: ['id', 'name'] },
95
+ productContent: { columns: ['descriptionHtml'] },
66
96
  skuAvailables: true,
67
97
  },
68
98
  });
@@ -70,16 +100,37 @@ export class ServiceProduct extends BeanBase {
70
100
  const balancesBySkuId = await this._getBalancesBySkuId(
71
101
  product.skuAvailables.map(sku => sku.id),
72
102
  );
73
- return this._toPublicProduct(product, balancesBySkuId);
103
+ const publicProduct = this._toPublicProduct(product, balancesBySkuId);
104
+ if (!publicProduct) return undefined;
105
+ return {
106
+ ...publicProduct,
107
+ descriptionHtml: product.productContent?.descriptionHtml,
108
+ };
74
109
  }
75
110
 
111
+ @Core.transaction()
112
+ @Core.retryable(serializationRetryOptions)
76
113
  async update(id: TableIdentity, product: DtoProductUpdate) {
114
+ const productForUpdate = await this.scope.model.product.getByIdForUpdate(id);
115
+ if (!productForUpdate) {
116
+ this.app.throw(404, 'Product not found');
117
+ }
77
118
  if (product.categoryId !== undefined) await this._ensureCategoryExists(product.categoryId);
78
- return await this.scope.model.product.updateById(id, product);
119
+ const { productContentForm, ...productFields } = product as DtoProductUpdate & {
120
+ productContentForm?: { descriptionMarkdown?: string };
121
+ };
122
+ await this.scope.model.product.updateById(id, productFields);
123
+ if (productContentForm !== undefined) {
124
+ await this._updateProductContent(id, productContentForm.descriptionMarkdown);
125
+ }
79
126
  }
80
127
 
128
+ @Core.transaction()
81
129
  async delete(id: TableIdentity) {
82
- return await this.scope.model.product.deleteById(id);
130
+ const product = await this.scope.model.product.getByIdForUpdate(id);
131
+ if (!product) return;
132
+ await this.scope.model.productContent.delete({ productId: id });
133
+ return await this.scope.model.product.deleteById(product.id);
83
134
  }
84
135
 
85
136
  private _getPublicProductWhere(where?: Record<string, unknown>) {
@@ -159,6 +210,31 @@ export class ServiceProduct extends BeanBase {
159
210
  };
160
211
  }
161
212
 
213
+ private async _updateProductContent(
214
+ productId: TableIdentity,
215
+ descriptionMarkdown?: string,
216
+ ): Promise<void> {
217
+ const markdown = descriptionMarkdown?.trim();
218
+ const productContent = await this.scope.model.productContent.getForUpdate({ productId });
219
+ if (!markdown) {
220
+ if (productContent) await this.scope.model.productContent.deleteById(productContent.id);
221
+ return;
222
+ }
223
+ const descriptionHtml = this.bean.markdown.renderHtml(markdown);
224
+ if (productContent) {
225
+ await this.scope.model.productContent.updateById(productContent.id, {
226
+ descriptionMarkdown: markdown,
227
+ descriptionHtml,
228
+ });
229
+ } else {
230
+ await this.scope.model.productContent.insert({
231
+ productId,
232
+ descriptionMarkdown: markdown,
233
+ descriptionHtml,
234
+ });
235
+ }
236
+ }
237
+
162
238
  private async _ensureCategoryExists(categoryId: TableIdentity) {
163
239
  if (!(await this.scope.model.category.getById(categoryId))) {
164
240
  this.app.throw(404, 'Product category not found');
@@ -0,0 +1,271 @@
1
+ import assert from 'node:assert';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { after, before, describe, it } from 'node:test';
4
+ import { acquireTestLock, app } from 'vona-mock';
5
+
6
+ interface IFixture {
7
+ categoryId?: number | string;
8
+ productIds: Array<number | string>;
9
+ skuIds: Array<number | string>;
10
+ }
11
+
12
+ async function cleanupFixture(fixture: IFixture) {
13
+ const scopeCatalog = app.scope('commerce-catalog');
14
+ const scopeTrade = app.scope('commerce-trade');
15
+ for (const skuId of fixture.skuIds) {
16
+ await scopeTrade.model.stockAudit.delete({ skuId });
17
+ await scopeTrade.model.stockReservation.delete({ skuId });
18
+ await scopeTrade.model.stockBalance.delete({ skuId });
19
+ await scopeCatalog.model.sku.delete({ id: skuId });
20
+ }
21
+ for (const productId of fixture.productIds) {
22
+ await scopeCatalog.model.productContent.delete({ productId });
23
+ await scopeCatalog.model.product.delete({ id: productId });
24
+ }
25
+ if (fixture.categoryId !== undefined) {
26
+ await scopeCatalog.model.category.delete({ id: fixture.categoryId });
27
+ }
28
+ }
29
+
30
+ async function runInMockCtx<TResult>(operation: () => Promise<TResult>): Promise<TResult> {
31
+ return await app.bean.executor.mockCtx(async () => {
32
+ await app.bean.passport.signinMock();
33
+ try {
34
+ return await operation();
35
+ } finally {
36
+ await app.bean.passport.signout();
37
+ }
38
+ });
39
+ }
40
+
41
+ describe('productContent.test.ts', { concurrency: false }, () => {
42
+ let releaseTestLock: (() => void) | undefined;
43
+
44
+ before(async () => {
45
+ releaseTestLock = await acquireTestLock('a-commerce');
46
+ });
47
+
48
+ after(() => {
49
+ releaseTestLock?.();
50
+ });
51
+
52
+ it('stores Markdown and server-rendered safe HTML through the Admin contract', async () => {
53
+ await app.bean.executor.mockCtx(async () => {
54
+ const fixture: IFixture = { productIds: [], skuIds: [] };
55
+ const suffix = randomUUID().slice(0, 12);
56
+ const scopeCatalog = app.scope('commerce-catalog');
57
+ await app.bean.passport.signinMock();
58
+ try {
59
+ const category = await scopeCatalog.model.category.insert({
60
+ name: `__ProductContentCategory-${suffix}__`,
61
+ published: true,
62
+ });
63
+ fixture.categoryId = category.id;
64
+ const product = await scopeCatalog.model.product.insert({
65
+ categoryId: category.id,
66
+ title: `__ProductContent-${suffix}__`,
67
+ description: 'Short summary',
68
+ published: true,
69
+ });
70
+ fixture.productIds.push(product.id);
71
+ const sku = await scopeCatalog.model.sku.insert({
72
+ code: `__ProductContentSku-${suffix}__`,
73
+ productId: product.id,
74
+ priceCents: 1999,
75
+ lifecycle: 'active',
76
+ });
77
+ fixture.skuIds.push(sku.id);
78
+ await app.scope('commerce-trade').service.stockBalance.adjustStock({
79
+ skuId: sku.id,
80
+ delta: 1,
81
+ reason: 'product content fixture',
82
+ correlationId: `product-content-${suffix}`,
83
+ });
84
+
85
+ const markdown =
86
+ '# Product details\n\nA **durable** product.\n\n- [ ] Open task\n- [x] Completed task\n\n<a href="javascript:alert(\'unsafe\')">unsafe</a>';
87
+ const updateResult = await app.bean.executor.performAction(
88
+ 'patch',
89
+ '/commerce/catalog/product/:id',
90
+ {
91
+ params: { id: product.id },
92
+ body: {
93
+ title: product.title,
94
+ categoryId: product.categoryId,
95
+ published: product.published,
96
+ productContentForm: { descriptionMarkdown: markdown },
97
+ },
98
+ },
99
+ );
100
+ assert.equal(updateResult, null);
101
+
102
+ const stored = await scopeCatalog.model.productContent.get({ productId: product.id });
103
+ assert.ok(stored);
104
+ assert.equal(stored.descriptionMarkdown, markdown);
105
+ assert.equal(stored.descriptionHtml, app.bean.markdown.renderHtml(markdown));
106
+ assert.match(stored.descriptionHtml!, /<h1>Product details<\/h1>/);
107
+ assert.match(stored.descriptionHtml!, /<strong>durable<\/strong>/);
108
+ assert.match(stored.descriptionHtml!, /<ul data-type="taskList">/);
109
+ assert.match(stored.descriptionHtml!, /<input type="checkbox" disabled="disabled" \/>/);
110
+ assert.match(
111
+ stored.descriptionHtml!,
112
+ /<input type="checkbox" checked="checked" disabled="disabled" \/>/,
113
+ );
114
+ assert.doesNotMatch(stored.descriptionHtml!, /<a[^>]+javascript:/i);
115
+
116
+ const adminProduct: any = await app.bean.executor.performAction(
117
+ 'get',
118
+ '/commerce/catalog/product/:id',
119
+ { params: { id: product.id } },
120
+ );
121
+ assert.equal(adminProduct.productContentForm.descriptionMarkdown, markdown);
122
+
123
+ const publicDetailWithTasks: any = await app.bean.executor.performAction(
124
+ 'get',
125
+ '/commerce/catalog/product/public/:id',
126
+ { params: { id: product.id }, innerAccess: false },
127
+ );
128
+ assert.equal(publicDetailWithTasks.descriptionMarkdown, undefined);
129
+ assert.equal(publicDetailWithTasks.descriptionHtml, stored.descriptionHtml);
130
+
131
+ await scopeCatalog.service.product.update(product.id, {
132
+ productContentForm: {
133
+ descriptionMarkdown: '## Server-derived HTML',
134
+ descriptionHtml: '<script>forged</script>',
135
+ },
136
+ } as any);
137
+ const forgedHtmlIgnored = await scopeCatalog.model.productContent.get({
138
+ productId: product.id,
139
+ });
140
+ assert.equal(forgedHtmlIgnored?.descriptionHtml, '<h2>Server-derived HTML</h2>');
141
+
142
+ const publicDetail: any = await app.bean.executor.performAction(
143
+ 'get',
144
+ '/commerce/catalog/product/public/:id',
145
+ { params: { id: product.id }, innerAccess: false },
146
+ );
147
+ assert.equal(publicDetail.descriptionMarkdown, undefined);
148
+ assert.equal(publicDetail.descriptionHtml, '<h2>Server-derived HTML</h2>');
149
+ assert.equal(publicDetail.description, 'Short summary');
150
+
151
+ const publicList: any = await app.bean.executor.performAction(
152
+ 'get',
153
+ '/commerce/catalog/product/public',
154
+ { innerAccess: false },
155
+ );
156
+ const publicListItem = publicList.list.find(
157
+ (item: any) => String(item.id) === String(product.id),
158
+ );
159
+ assert.ok(publicListItem);
160
+ assert.equal(publicListItem.descriptionHtml, undefined);
161
+
162
+ await app.bean.executor.performAction('patch', '/commerce/catalog/product/:id', {
163
+ params: { id: product.id },
164
+ body: {
165
+ title: product.title,
166
+ categoryId: product.categoryId,
167
+ published: product.published,
168
+ productContentForm: { descriptionMarkdown: ' \n\t' },
169
+ },
170
+ });
171
+ assert.equal(
172
+ await scopeCatalog.model.productContent.get({ productId: product.id }),
173
+ undefined,
174
+ );
175
+ const productWithoutContent: any = await app.bean.executor.performAction(
176
+ 'get',
177
+ '/commerce/catalog/product/:id',
178
+ { params: { id: product.id } },
179
+ );
180
+ assert.equal(productWithoutContent.productContentForm, undefined);
181
+ } finally {
182
+ await cleanupFixture(fixture);
183
+ await app.bean.passport.signout();
184
+ }
185
+ });
186
+ });
187
+
188
+ it('removes ProductContent when its Product is deleted', async () => {
189
+ await app.bean.executor.mockCtx(async () => {
190
+ const fixture: IFixture = { productIds: [], skuIds: [] };
191
+ const suffix = randomUUID().slice(0, 12);
192
+ const scopeCatalog = app.scope('commerce-catalog');
193
+ await app.bean.passport.signinMock();
194
+ try {
195
+ const category = await scopeCatalog.model.category.insert({
196
+ name: `__ProductContentDeleteCategory-${suffix}__`,
197
+ published: false,
198
+ });
199
+ fixture.categoryId = category.id;
200
+ const product = await scopeCatalog.model.product.insert({
201
+ categoryId: category.id,
202
+ title: `__ProductContentDelete-${suffix}__`,
203
+ published: false,
204
+ });
205
+ fixture.productIds.push(product.id);
206
+ await scopeCatalog.service.product.update(product.id, {
207
+ productContentForm: { descriptionMarkdown: 'Content to delete' },
208
+ } as any);
209
+
210
+ await app.bean.executor.performAction('delete', '/commerce/catalog/product/:id', {
211
+ params: { id: product.id },
212
+ });
213
+ fixture.productIds = fixture.productIds.filter(id => String(id) !== String(product.id));
214
+ assert.equal(
215
+ await scopeCatalog.model.productContent.get({ productId: product.id }),
216
+ undefined,
217
+ );
218
+ } finally {
219
+ await cleanupFixture(fixture);
220
+ await app.bean.passport.signout();
221
+ }
222
+ });
223
+ });
224
+
225
+ it('keeps the final Markdown and HTML pair consistent across competing updates', async () => {
226
+ const fixture: IFixture = { productIds: [], skuIds: [] };
227
+ const suffix = randomUUID().slice(0, 12);
228
+ const markdowns = ['# First update', '## Second update'];
229
+ try {
230
+ const productId = await runInMockCtx(async () => {
231
+ const scopeCatalog = app.scope('commerce-catalog');
232
+ const category = await scopeCatalog.model.category.insert({
233
+ name: `__ProductContentRaceCategory-${suffix}__`,
234
+ published: false,
235
+ });
236
+ fixture.categoryId = category.id;
237
+ const product = await scopeCatalog.model.product.insert({
238
+ categoryId: category.id,
239
+ title: `__ProductContentRace-${suffix}__`,
240
+ published: false,
241
+ });
242
+ fixture.productIds.push(product.id);
243
+ return product.id;
244
+ });
245
+
246
+ await Promise.all(
247
+ markdowns.map(descriptionMarkdown =>
248
+ runInMockCtx(async () => {
249
+ await app.scope('commerce-catalog').service.product.update(productId, {
250
+ productContentForm: { descriptionMarkdown },
251
+ } as any);
252
+ }),
253
+ ),
254
+ );
255
+
256
+ await runInMockCtx(async () => {
257
+ const stored = await app.scope('commerce-catalog').model.productContent.get({ productId });
258
+ assert.ok(stored);
259
+ assert.ok(markdowns.includes(stored.descriptionMarkdown!));
260
+ assert.equal(
261
+ stored.descriptionHtml,
262
+ app.bean.markdown.renderHtml(stored.descriptionMarkdown),
263
+ );
264
+ });
265
+ } finally {
266
+ await runInMockCtx(async () => {
267
+ await cleanupFixture(fixture);
268
+ });
269
+ }
270
+ });
271
+ });
@@ -4,6 +4,8 @@ import { describe, it } from 'node:test';
4
4
  import { app } from 'vona-mock';
5
5
  import {
6
6
  DtoProductCreate,
7
+ DtoProductPublic,
8
+ DtoProductPublicDetail,
7
9
  DtoProductSelectResItem,
8
10
  DtoProductUpdate,
9
11
  DtoProductView,
@@ -15,14 +17,38 @@ function findComponent(apiJson: any, predicate: (properties: any) => boolean) {
15
17
  }) as any;
16
18
  }
17
19
 
18
- function getEntryForm(component: any) {
20
+ function getEntryBlocks(component: any) {
19
21
  const pageEntry = component?.rest?.blocks?.[0];
20
22
  const formBlock = pageEntry?.options?.blocks?.[0];
21
23
  const toolbarBlock = pageEntry?.options?.blocks?.[1];
22
24
  assert.equal(pageEntry?.render, 'basic-pageentry:blockPageEntry');
23
25
  assert.equal(formBlock?.render, 'basic-pageentry:blockForm');
24
26
  assert.equal(toolbarBlock?.render, 'basic-pageentry:blockToolbarRow');
25
- return toolbarBlock;
27
+ return { formBlock, toolbarBlock };
28
+ }
29
+
30
+ function getEntryForm(component: any) {
31
+ return getEntryBlocks(component).toolbarBlock;
32
+ }
33
+
34
+ function assertProductFormGroups(component: any, productFields: string[]) {
35
+ const { formBlock } = getEntryBlocks(component);
36
+ const layoutBlock = formBlock?.options?.blocks?.[0];
37
+ assert.equal(layoutBlock?.render, 'basic-form:blockFormLayout');
38
+ const groups = layoutBlock?.options?.formLayout?.children;
39
+ assert.equal(groups?.length, 2);
40
+ assert.deepEqual(
41
+ groups.map((group: any) => group.type),
42
+ ['group', 'group'],
43
+ );
44
+ assert.deepEqual(
45
+ groups[0].children[0].children.map((field: any) => field.name),
46
+ productFields,
47
+ );
48
+ assert.deepEqual(
49
+ groups[1].children[0].children.map((field: any) => field.name),
50
+ ['productContentForm'],
51
+ );
26
52
  }
27
53
 
28
54
  describe('productPresentation.test.ts', () => {
@@ -35,6 +61,7 @@ describe('productPresentation.test.ts', () => {
35
61
  assert.deepEqual(Object.keys(createComponent.properties).sort(), [
36
62
  'categoryId',
37
63
  'description',
64
+ 'productContentForm',
38
65
  'published',
39
66
  'title',
40
67
  ]);
@@ -54,6 +81,16 @@ describe('productPresentation.test.ts', () => {
54
81
  createComponent.properties.published.rest.form.options.items.map((item: any) => item.value),
55
82
  [false, true],
56
83
  );
84
+ assert.equal(
85
+ createComponent.properties.productContentForm.rest.fieldSource,
86
+ 'productContentForm.descriptionMarkdown',
87
+ );
88
+ assert.equal(
89
+ createComponent.properties.productContentForm.rest.form.render,
90
+ 'basic-markdown:formFieldMarkdown',
91
+ );
92
+ assert.equal(createComponent.properties.published.default, false);
93
+ assertProductFormGroups(createComponent, ['title', 'categoryId', 'published', 'description']);
57
94
  const createToolbar = getEntryForm(createComponent);
58
95
  assert.deepEqual(
59
96
  createToolbar.options.actions.map((action: any) => action.render),
@@ -67,6 +104,7 @@ describe('productPresentation.test.ts', () => {
67
104
  assert.deepEqual(Object.keys(updateComponent.properties).sort(), [
68
105
  'categoryId',
69
106
  'description',
107
+ 'productContentForm',
70
108
  'published',
71
109
  'title',
72
110
  ]);
@@ -78,6 +116,15 @@ describe('productPresentation.test.ts', () => {
78
116
  updateComponent.properties.published.rest.form.render,
79
117
  'basic-select:formFieldSelect',
80
118
  );
119
+ assert.equal(
120
+ updateComponent.properties.productContentForm.rest.fieldSource,
121
+ 'productContentForm.descriptionMarkdown',
122
+ );
123
+ assert.equal(
124
+ updateComponent.properties.productContentForm.rest.form.render,
125
+ 'basic-markdown:formFieldMarkdown',
126
+ );
127
+ assertProductFormGroups(updateComponent, ['title', 'categoryId', 'published', 'description']);
81
128
 
82
129
  const viewJson = await app.bean.openapi.generateJsonOfClass(DtoProductView);
83
130
  const viewComponent = findComponent(viewJson, properties => {
@@ -104,6 +151,23 @@ describe('productPresentation.test.ts', () => {
104
151
  viewComponent.properties.published.rest.form.render,
105
152
  'basic-select:formFieldSelect',
106
153
  );
154
+ assert.equal(
155
+ viewComponent.properties.productContentForm.rest.fieldSource,
156
+ 'productContentForm.descriptionMarkdown',
157
+ );
158
+ assert.equal(
159
+ viewComponent.properties.productContentForm.rest.form.render,
160
+ 'basic-markdown:formFieldMarkdown',
161
+ );
162
+ assertProductFormGroups(viewComponent, [
163
+ 'id',
164
+ 'title',
165
+ 'categoryId',
166
+ 'published',
167
+ 'description',
168
+ 'createdAt',
169
+ 'updatedAt',
170
+ ]);
107
171
  const viewToolbar = getEntryForm(viewComponent);
108
172
  assert.deepEqual(
109
173
  viewToolbar.options.actions.map((action: any) => action.render),
@@ -139,6 +203,21 @@ describe('productPresentation.test.ts', () => {
139
203
  ['basic-table:actionUpdate', 'basic-table:actionDelete'],
140
204
  );
141
205
 
206
+ const publicProductJson = await app.bean.openapi.generateJsonOfClass(DtoProductPublic);
207
+ const publicProductComponent = findComponent(
208
+ publicProductJson,
209
+ properties => properties?.skuAvailables && properties?.description,
210
+ );
211
+ assert.equal(publicProductComponent.properties.descriptionHtml, undefined);
212
+
213
+ const publicDetailJson = await app.bean.openapi.generateJsonOfClass(DtoProductPublicDetail);
214
+ const publicDetailComponent = findComponent(
215
+ publicDetailJson,
216
+ properties => properties?.skuAvailables && properties?.descriptionHtml,
217
+ );
218
+ assert.deepEqual(publicDetailComponent.properties.descriptionHtml.type, ['string', 'null']);
219
+ assert.equal(publicDetailComponent.properties.descriptionMarkdown, undefined);
220
+
142
221
  const filterBlock = listComponent.rest.blocks[0].options.blocks[0];
143
222
  const filterLayout = filterBlock.options.blocks[0].options.formLayout;
144
223
  const section = filterLayout.children[0];
@@ -32,7 +32,9 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "vona-module-a-pay": "workspace:*",
35
- "vona-module-pay-paypal": "workspace:*"
35
+ "vona-module-a-ssr": "workspace:*",
36
+ "vona-module-pay-paypal": "workspace:*",
37
+ "vona-module-pay-stripe": "workspace:*"
36
38
  },
37
39
  "devDependencies": {
38
40
  "clean-package": "^2.2.0",
@@ -50,6 +52,7 @@
50
52
  "vonaModule": {
51
53
  "dependencies": {
52
54
  "a-pay": "5.0.0",
55
+ "a-ssr": "5.0.0",
53
56
  "a-vona": "5.0.0"
54
57
  },
55
58
  "fileVersion": 1