@endora-commerce/mod-pwa 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/admin/api/pwa-client.d.ts +15 -0
  4. package/dist/admin/api/pwa-client.d.ts.map +1 -0
  5. package/dist/admin/api/pwa-client.js +56 -0
  6. package/dist/admin/api/pwa-client.js.map +1 -0
  7. package/dist/admin/components/PushAudienceRuleBuilder.d.ts +19 -0
  8. package/dist/admin/components/PushAudienceRuleBuilder.d.ts.map +1 -0
  9. package/dist/admin/components/PushAudienceRuleBuilder.js +211 -0
  10. package/dist/admin/components/PushAudienceRuleBuilder.js.map +1 -0
  11. package/dist/admin/index.d.ts +39 -0
  12. package/dist/admin/index.d.ts.map +1 -0
  13. package/dist/admin/index.js +61 -0
  14. package/dist/admin/index.js.map +1 -0
  15. package/dist/admin/pages/PwaPage.d.ts +16 -0
  16. package/dist/admin/pages/PwaPage.d.ts.map +1 -0
  17. package/dist/admin/pages/PwaPage.js +207 -0
  18. package/dist/admin/pages/PwaPage.js.map +1 -0
  19. package/dist/backend/entities/push-message-delivery.entity.d.ts +18 -0
  20. package/dist/backend/entities/push-message-delivery.entity.d.ts.map +1 -0
  21. package/dist/backend/entities/push-message-delivery.entity.js +68 -0
  22. package/dist/backend/entities/push-message-delivery.entity.js.map +1 -0
  23. package/dist/backend/entities/push-message.entity.d.ts +26 -0
  24. package/dist/backend/entities/push-message.entity.d.ts.map +1 -0
  25. package/dist/backend/entities/push-message.entity.js +102 -0
  26. package/dist/backend/entities/push-message.entity.js.map +1 -0
  27. package/dist/backend/entities/push-subscription.entity.d.ts +49 -0
  28. package/dist/backend/entities/push-subscription.entity.d.ts.map +1 -0
  29. package/dist/backend/entities/push-subscription.entity.js +117 -0
  30. package/dist/backend/entities/push-subscription.entity.js.map +1 -0
  31. package/dist/backend/entities/pwa-icon-rendition.entity.d.ts +21 -0
  32. package/dist/backend/entities/pwa-icon-rendition.entity.d.ts.map +1 -0
  33. package/dist/backend/entities/pwa-icon-rendition.entity.js +69 -0
  34. package/dist/backend/entities/pwa-icon-rendition.entity.js.map +1 -0
  35. package/dist/backend/index.d.ts +100 -0
  36. package/dist/backend/index.d.ts.map +1 -0
  37. package/dist/backend/index.js +107 -0
  38. package/dist/backend/index.js.map +1 -0
  39. package/dist/backend/plugin.d.ts +119 -0
  40. package/dist/backend/plugin.d.ts.map +1 -0
  41. package/dist/backend/plugin.js +109 -0
  42. package/dist/backend/plugin.js.map +1 -0
  43. package/dist/backend/request-actor.d.ts +24 -0
  44. package/dist/backend/request-actor.d.ts.map +1 -0
  45. package/dist/backend/request-actor.js +29 -0
  46. package/dist/backend/request-actor.js.map +1 -0
  47. package/dist/backend/routes.admin.d.ts +52 -0
  48. package/dist/backend/routes.admin.d.ts.map +1 -0
  49. package/dist/backend/routes.admin.js +222 -0
  50. package/dist/backend/routes.admin.js.map +1 -0
  51. package/dist/backend/routes.storefront.d.ts +29 -0
  52. package/dist/backend/routes.storefront.d.ts.map +1 -0
  53. package/dist/backend/routes.storefront.js +66 -0
  54. package/dist/backend/routes.storefront.js.map +1 -0
  55. package/dist/backend/services/cross-module-context.d.ts +90 -0
  56. package/dist/backend/services/cross-module-context.d.ts.map +1 -0
  57. package/dist/backend/services/cross-module-context.js +84 -0
  58. package/dist/backend/services/cross-module-context.js.map +1 -0
  59. package/dist/backend/services/providers/web-push-provider.d.ts +31 -0
  60. package/dist/backend/services/providers/web-push-provider.d.ts.map +1 -0
  61. package/dist/backend/services/providers/web-push-provider.js +72 -0
  62. package/dist/backend/services/providers/web-push-provider.js.map +1 -0
  63. package/dist/backend/services/push-audience-evaluator.d.ts +24 -0
  64. package/dist/backend/services/push-audience-evaluator.d.ts.map +1 -0
  65. package/dist/backend/services/push-audience-evaluator.js +29 -0
  66. package/dist/backend/services/push-audience-evaluator.js.map +1 -0
  67. package/dist/backend/services/push-delivery-queue.d.ts +14 -0
  68. package/dist/backend/services/push-delivery-queue.d.ts.map +1 -0
  69. package/dist/backend/services/push-delivery-queue.js +30 -0
  70. package/dist/backend/services/push-delivery-queue.js.map +1 -0
  71. package/dist/backend/services/push-event-subscriber.d.ts +86 -0
  72. package/dist/backend/services/push-event-subscriber.d.ts.map +1 -0
  73. package/dist/backend/services/push-event-subscriber.js +109 -0
  74. package/dist/backend/services/push-event-subscriber.js.map +1 -0
  75. package/dist/backend/services/push-message-service.d.ts +58 -0
  76. package/dist/backend/services/push-message-service.d.ts.map +1 -0
  77. package/dist/backend/services/push-message-service.js +145 -0
  78. package/dist/backend/services/push-message-service.js.map +1 -0
  79. package/dist/backend/services/push-provider-registry.d.ts +17 -0
  80. package/dist/backend/services/push-provider-registry.d.ts.map +1 -0
  81. package/dist/backend/services/push-provider-registry.js +32 -0
  82. package/dist/backend/services/push-provider-registry.js.map +1 -0
  83. package/dist/backend/services/push-subscription-service.d.ts +67 -0
  84. package/dist/backend/services/push-subscription-service.d.ts.map +1 -0
  85. package/dist/backend/services/push-subscription-service.js +165 -0
  86. package/dist/backend/services/push-subscription-service.js.map +1 -0
  87. package/dist/backend/services/pwa-config-resolver.d.ts +29 -0
  88. package/dist/backend/services/pwa-config-resolver.d.ts.map +1 -0
  89. package/dist/backend/services/pwa-config-resolver.js +92 -0
  90. package/dist/backend/services/pwa-config-resolver.js.map +1 -0
  91. package/dist/backend/services/pwa-icon-service.d.ts +44 -0
  92. package/dist/backend/services/pwa-icon-service.d.ts.map +1 -0
  93. package/dist/backend/services/pwa-icon-service.js +105 -0
  94. package/dist/backend/services/pwa-icon-service.js.map +1 -0
  95. package/dist/backend/workers/push-delivery-worker.d.ts +17 -0
  96. package/dist/backend/workers/push-delivery-worker.d.ts.map +1 -0
  97. package/dist/backend/workers/push-delivery-worker.js +105 -0
  98. package/dist/backend/workers/push-delivery-worker.js.map +1 -0
  99. package/dist/manifest.d.ts +184 -0
  100. package/dist/manifest.d.ts.map +1 -0
  101. package/dist/manifest.js +207 -0
  102. package/dist/manifest.js.map +1 -0
  103. package/dist/migrations/20260625T144228_pwa_init.d.ts +18 -0
  104. package/dist/migrations/20260625T144228_pwa_init.d.ts.map +1 -0
  105. package/dist/migrations/20260625T144228_pwa_init.js +105 -0
  106. package/dist/migrations/20260625T144228_pwa_init.js.map +1 -0
  107. package/dist/migrations/20260830T172022_pwa_organization_attribution.d.ts +102 -0
  108. package/dist/migrations/20260830T172022_pwa_organization_attribution.d.ts.map +1 -0
  109. package/dist/migrations/20260830T172022_pwa_organization_attribution.js +169 -0
  110. package/dist/migrations/20260830T172022_pwa_organization_attribution.js.map +1 -0
  111. package/dist/migrations/index.d.ts +28 -0
  112. package/dist/migrations/index.d.ts.map +1 -0
  113. package/dist/migrations/index.js +31 -0
  114. package/dist/migrations/index.js.map +1 -0
  115. package/docs/pwa.md +93 -0
  116. package/i18n/en.json +43 -0
  117. package/i18n/pl.json +43 -0
  118. package/package.json +105 -0
  119. package/tailwind.css +14 -0
@@ -0,0 +1,90 @@
1
+ import type { AssetsLibraryPort, OrderReadPort } from '@endora-commerce/contracts';
2
+ import { type SalesChannelResolutionPort } from '@endora-commerce/platform/kernel';
3
+ import type { PushEventTarget } from './push-event-subscriber.js';
4
+ /**
5
+ * Everything `pwa` needs from outside itself that is not one of its own rows
6
+ * (`specs/110-instance-repository/` T118c).
7
+ *
8
+ * It replaces `PwaBridge`, which was the same set written as closures in both
9
+ * composition roots — so a composition had to know how an asset id becomes a
10
+ * URL, how a channel id becomes a code and what an order-status push says, and
11
+ * the two roots disagreed about two of the three. What is left is
12
+ * **module-private** and holds three published ports and no closure, so there
13
+ * is nothing for a root to build and nothing for the two of them to differ on.
14
+ *
15
+ * `backend/index.ts` supplies each one as a `lazyPort` proxy, which resolves per
16
+ * call — nothing here captures a registration, so a switched-off owner refuses
17
+ * at the call rather than through a gate frozen at composition time.
18
+ *
19
+ * The functions below are functions **of the ports** rather than of a
20
+ * `ModuleContext`, which is what lets the mapping be tested over stubs with no
21
+ * container composed (`cross-module-context.test.ts`).
22
+ */
23
+ export interface PwaCrossModulePorts {
24
+ /**
25
+ * `assets_library`, for the icon pipeline: the source and rendition uploads,
26
+ * and the URL the storefront icon route redirects to.
27
+ */
28
+ readonly assets: AssetsLibraryPort;
29
+ /**
30
+ * The kernel's channel resolver — a platform name, not a module's, so it puts
31
+ * nothing in this module's manifest `dependencies` (Constitution XII).
32
+ */
33
+ readonly channels: SalesChannelResolutionPort;
34
+ /** `orders`, for the FR-024 order-status auto-trigger. */
35
+ readonly orders: OrderReadPort;
36
+ }
37
+ /**
38
+ * How a stored asset id becomes the URL the storefront icon route redirects to.
39
+ *
40
+ * **`getAsset`, not `resolveUrl`** (D-223): `assets_library` resolves the public
41
+ * origin itself and every URL it returns is absolute, so `resolveUrl` is
42
+ * published on no port and a consumer that wants a URL takes the detail that
43
+ * already carries one. Both roots used to call `resolveUrl` and one of them used
44
+ * to wrap it in an `absolutizePublicUrl` the other did not, so a push payload's
45
+ * icon URL depended on which root composed the platform.
46
+ */
47
+ export declare function createAssetUrlResolver(assets: Pick<AssetsLibraryPort, 'getAsset'>): (assetId: string) => Promise<string | null>;
48
+ /**
49
+ * The channel a settings read or write falls back to when the admin surface
50
+ * names none: the deployment's system default.
51
+ *
52
+ * `null` is still in the return type and is still unreachable through this
53
+ * implementation — D-48 says exactly one row holds the flag on a booted
54
+ * deployment. The seam admits it because {@link PwaCrossModulePorts} is not the
55
+ * only thing that could ever answer, and because the option it feeds
56
+ * (`resolveScopeChannelId`) means "read platform-wide" by it.
57
+ */
58
+ export declare function createDefaultChannelIdResolver(channels: Pick<SalesChannelResolutionPort, 'getSystemDefault'>): () => Promise<string | null>;
59
+ /**
60
+ * A channel id to its code, because a settings subset write is keyed by code.
61
+ *
62
+ * `getById` is the resolver's `em.findOne(SalesChannel, { id })` with a cache
63
+ * populate on the way out — the same read the composition roots wrote by hand,
64
+ * with the same absence of narrowing, so an inactive channel still answers its
65
+ * code and an unknown id still answers `null` (which the admin reset route
66
+ * turns into `PWA_CHANNEL_UNKNOWN`).
67
+ */
68
+ export declare function createChannelCodeResolver(channels: Pick<SalesChannelResolutionPort, 'getById'>): (channelId: string) => Promise<string | null>;
69
+ /**
70
+ * What an `order.status_changed.v1` event becomes, as a push (FR-024).
71
+ *
72
+ * `null` means "no push": an order the platform will not read, and an order
73
+ * placed by nobody — a guest checkout has no account to notify, and there is no
74
+ * device to deliver to.
75
+ *
76
+ * The sentence is English and stays English: it is composed for a reader whose
77
+ * language this module cannot resolve here — one event, potentially many
78
+ * subscribed devices — which is case 2 of
79
+ * `specs/094-translation-boundary/contracts/translation-boundary.md` § 2 and is
80
+ * `specs/093-backend-delivered-prose/`'s subject rather than this drain's. It
81
+ * moved from a composition root unchanged, deliberately: a repair here would be
82
+ * a repair nobody asked for, in a merge request nobody would look for it in.
83
+ */
84
+ export declare function createOrderPushTargetResolver(orders: Pick<OrderReadPort, 'findById'>): (payload: {
85
+ orderId: string;
86
+ salesChannelId: string;
87
+ from: string;
88
+ to: string;
89
+ }) => Promise<PushEventTarget | null>;
90
+ //# sourceMappingURL=cross-module-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cross-module-context.d.ts","sourceRoot":"","sources":["../../../src/backend/services/cross-module-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAElE;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,0BAA0B,CAAC;IAC9C,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;CAChC;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,UAAU,CAAC,GAC1C,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgB7C;AAED;;;;;;;;;GASG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,IAAI,CAAC,0BAA0B,EAAE,kBAAkB,CAAC,GAC7D,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAE9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,IAAI,CAAC,0BAA0B,EAAE,SAAS,CAAC,GACpD,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAE/C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,GACtC,CAAC,OAAO,EAAE;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ,KAAK,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAYpC"}
@@ -0,0 +1,84 @@
1
+ import { rethrowIfModuleDisabled, } from '@endora-commerce/platform/kernel';
2
+ /**
3
+ * How a stored asset id becomes the URL the storefront icon route redirects to.
4
+ *
5
+ * **`getAsset`, not `resolveUrl`** (D-223): `assets_library` resolves the public
6
+ * origin itself and every URL it returns is absolute, so `resolveUrl` is
7
+ * published on no port and a consumer that wants a URL takes the detail that
8
+ * already carries one. Both roots used to call `resolveUrl` and one of them used
9
+ * to wrap it in an `absolutizePublicUrl` the other did not, so a push payload's
10
+ * icon URL depended on which root composed the platform.
11
+ */
12
+ export function createAssetUrlResolver(assets) {
13
+ return async (assetId) => {
14
+ try {
15
+ return (await assets.getAsset(assetId)).url;
16
+ }
17
+ catch (err) {
18
+ // The narrow tolerance both composition roots already carried, kept
19
+ // deliberately: `getAsset` throws 404 for a row that is gone, and an icon
20
+ // rendition pointing at a deleted asset answers 404 on that one size
21
+ // rather than 500. `rethrowIfModuleDisabled` is what stops the tolerance
22
+ // from also absorbing the owner's refusal (composition checklist item 7)
23
+ // — unreachable while `assets_library` declares `nonDeactivatable`, and
24
+ // the line that keeps this fail-closed on the day that changes.
25
+ rethrowIfModuleDisabled(err);
26
+ return null;
27
+ }
28
+ };
29
+ }
30
+ /**
31
+ * The channel a settings read or write falls back to when the admin surface
32
+ * names none: the deployment's system default.
33
+ *
34
+ * `null` is still in the return type and is still unreachable through this
35
+ * implementation — D-48 says exactly one row holds the flag on a booted
36
+ * deployment. The seam admits it because {@link PwaCrossModulePorts} is not the
37
+ * only thing that could ever answer, and because the option it feeds
38
+ * (`resolveScopeChannelId`) means "read platform-wide" by it.
39
+ */
40
+ export function createDefaultChannelIdResolver(channels) {
41
+ return async () => (await channels.getSystemDefault()).id;
42
+ }
43
+ /**
44
+ * A channel id to its code, because a settings subset write is keyed by code.
45
+ *
46
+ * `getById` is the resolver's `em.findOne(SalesChannel, { id })` with a cache
47
+ * populate on the way out — the same read the composition roots wrote by hand,
48
+ * with the same absence of narrowing, so an inactive channel still answers its
49
+ * code and an unknown id still answers `null` (which the admin reset route
50
+ * turns into `PWA_CHANNEL_UNKNOWN`).
51
+ */
52
+ export function createChannelCodeResolver(channels) {
53
+ return async (channelId) => (await channels.getById(channelId))?.code ?? null;
54
+ }
55
+ /**
56
+ * What an `order.status_changed.v1` event becomes, as a push (FR-024).
57
+ *
58
+ * `null` means "no push": an order the platform will not read, and an order
59
+ * placed by nobody — a guest checkout has no account to notify, and there is no
60
+ * device to deliver to.
61
+ *
62
+ * The sentence is English and stays English: it is composed for a reader whose
63
+ * language this module cannot resolve here — one event, potentially many
64
+ * subscribed devices — which is case 2 of
65
+ * `specs/094-translation-boundary/contracts/translation-boundary.md` § 2 and is
66
+ * `specs/093-backend-delivered-prose/`'s subject rather than this drain's. It
67
+ * moved from a composition root unchanged, deliberately: a repair here would be
68
+ * a repair nobody asked for, in a merge request nobody would look for it in.
69
+ */
70
+ export function createOrderPushTargetResolver(orders) {
71
+ return async (payload) => {
72
+ const order = await orders.findById(payload.orderId);
73
+ if (!order || !order.placedByCustomerAccountId)
74
+ return null;
75
+ return {
76
+ salesChannelId: payload.salesChannelId,
77
+ customerAccountId: order.placedByCustomerAccountId,
78
+ title: 'Order update',
79
+ body: `Order ${order.businessId} is now ${payload.to.replace(/_/g, ' ')}.`,
80
+ url: `/account/orders/${order.businessId}`,
81
+ };
82
+ };
83
+ }
84
+ //# sourceMappingURL=cross-module-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cross-module-context.js","sourceRoot":"","sources":["../../../src/backend/services/cross-module-context.ts"],"names":[],"mappings":"AACA,OAAO,EACL,uBAAuB,GAExB,MAAM,kCAAkC,CAAC;AAqC1C;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CACpC,MAA2C;IAE3C,OAAO,KAAK,EAAE,OAAO,EAAE,EAAE;QACvB,IAAI,CAAC;YACH,OAAO,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC;QAC9C,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,oEAAoE;YACpE,0EAA0E;YAC1E,qEAAqE;YACrE,yEAAyE;YACzE,yEAAyE;YACzE,wEAAwE;YACxE,gEAAgE;YAChE,uBAAuB,CAAC,GAAG,CAAC,CAAC;YAC7B,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,8BAA8B,CAC5C,QAA8D;IAE9D,OAAO,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,QAAQ,CAAC,gBAAgB,EAAE,CAAC,CAAC,EAAE,CAAC;AAC5D,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,yBAAyB,CACvC,QAAqD;IAErD,OAAO,KAAK,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,MAAM,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,IAAI,IAAI,IAAI,CAAC;AAChF,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,6BAA6B,CAC3C,MAAuC;IAOvC,OAAO,KAAK,EAAE,OAAO,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QACrD,IAAI,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,yBAAyB;YAAE,OAAO,IAAI,CAAC;QAC5D,OAAO;YACL,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,iBAAiB,EAAE,KAAK,CAAC,yBAAyB;YAClD,KAAK,EAAE,cAAc;YACrB,IAAI,EAAE,SAAS,KAAK,CAAC,UAAU,WAAW,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG;YAC1E,GAAG,EAAE,mBAAmB,KAAK,CAAC,UAAU,EAAE;SAC3C,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { type PushPayload, type PushProvider, type PushSendResult, type PushSubscriptionRef } from '@endora-commerce/contracts';
3
+ export interface WebPushSettingsPort {
4
+ get<T>(code: string, salesChannelId: string, schema: z.ZodType<T>): Promise<T>;
5
+ }
6
+ /**
7
+ * Default push provider (FR-019): standard Web-Push signed with VAPID via the
8
+ * `web-push` library. On Chrome/Edge the endpoints are transported by Google's
9
+ * free FCM, so this path is "FCM as backend" while keeping the storefront on
10
+ * standard browser APIs.
11
+ *
12
+ * Stateless and concurrency-safe: VAPID keys are resolved per call from the
13
+ * subscription's channel and passed via `sendNotification`'s `vapidDetails`
14
+ * option (never `setVapidDetails`, which is process-global).
15
+ *
16
+ * Status mapping (research §R1):
17
+ * 2xx → ok
18
+ * 404 / 410 → gone (caller prunes the subscription, FR-022)
19
+ * 429 / 5xx → retryable
20
+ * other 4xx → non-retryable failure
21
+ */
22
+ export declare class WebPushProvider implements PushProvider {
23
+ private readonly settings;
24
+ private readonly subject;
25
+ readonly key = "web_push";
26
+ constructor(settings: WebPushSettingsPort, subject: string);
27
+ private resolveKeys;
28
+ isConfigured(salesChannelId: string): Promise<boolean>;
29
+ send(sub: PushSubscriptionRef, payload: PushPayload): Promise<PushSendResult>;
30
+ }
31
+ //# sourceMappingURL=web-push-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-push-provider.d.ts","sourceRoot":"","sources":["../../../../src/backend/services/providers/web-push-provider.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,mBAAmB,EACzB,MAAM,4BAA4B,CAAC;AAEpC,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAChF;AAID;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,eAAgB,YAAW,YAAY;IAIhD,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJ1B,QAAQ,CAAC,GAAG,cAAc;gBAGP,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,MAAM;YAGpB,WAAW;IAenB,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAItD,IAAI,CAAC,GAAG,EAAE,mBAAmB,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC;CA+BpF"}
@@ -0,0 +1,72 @@
1
+ import webpush from 'web-push';
2
+ import { z } from 'zod';
3
+ import { PWA_SETTING_CODES, } from '@endora-commerce/contracts';
4
+ const StringSchema = z.string();
5
+ /**
6
+ * Default push provider (FR-019): standard Web-Push signed with VAPID via the
7
+ * `web-push` library. On Chrome/Edge the endpoints are transported by Google's
8
+ * free FCM, so this path is "FCM as backend" while keeping the storefront on
9
+ * standard browser APIs.
10
+ *
11
+ * Stateless and concurrency-safe: VAPID keys are resolved per call from the
12
+ * subscription's channel and passed via `sendNotification`'s `vapidDetails`
13
+ * option (never `setVapidDetails`, which is process-global).
14
+ *
15
+ * Status mapping (research §R1):
16
+ * 2xx → ok
17
+ * 404 / 410 → gone (caller prunes the subscription, FR-022)
18
+ * 429 / 5xx → retryable
19
+ * other 4xx → non-retryable failure
20
+ */
21
+ export class WebPushProvider {
22
+ settings;
23
+ subject;
24
+ key = 'web_push';
25
+ constructor(settings, subject) {
26
+ this.settings = settings;
27
+ this.subject = subject;
28
+ }
29
+ async resolveKeys(salesChannelId) {
30
+ try {
31
+ const [publicKey, privateKey] = await Promise.all([
32
+ this.settings.get(PWA_SETTING_CODES.VAPID_PUBLIC_KEY, salesChannelId, StringSchema),
33
+ this.settings.get(PWA_SETTING_CODES.VAPID_PRIVATE_KEY, salesChannelId, StringSchema),
34
+ ]);
35
+ if (!publicKey || !privateKey)
36
+ return null;
37
+ return { publicKey, privateKey };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ async isConfigured(salesChannelId) {
44
+ return (await this.resolveKeys(salesChannelId)) !== null;
45
+ }
46
+ async send(sub, payload) {
47
+ const keys = await this.resolveKeys(sub.salesChannelId);
48
+ if (!keys) {
49
+ return { ok: false, gone: false, error: 'vapid_unconfigured', retryable: false };
50
+ }
51
+ const body = JSON.stringify({
52
+ title: payload.title,
53
+ body: payload.body,
54
+ icon: payload.iconUrl,
55
+ url: payload.url,
56
+ tag: payload.tag,
57
+ });
58
+ try {
59
+ await webpush.sendNotification({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, body, { vapidDetails: { subject: this.subject, publicKey: keys.publicKey, privateKey: keys.privateKey } });
60
+ return { ok: true };
61
+ }
62
+ catch (err) {
63
+ const statusCode = err.statusCode ?? 0;
64
+ if (statusCode === 404 || statusCode === 410) {
65
+ return { ok: false, gone: true };
66
+ }
67
+ const retryable = statusCode === 429 || (statusCode >= 500 && statusCode < 600) || statusCode === 0;
68
+ return { ok: false, gone: false, error: `web-push status ${statusCode}`, retryable };
69
+ }
70
+ }
71
+ }
72
+ //# sourceMappingURL=web-push-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-push-provider.js","sourceRoot":"","sources":["../../../../src/backend/services/providers/web-push-provider.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ import type { PushAudienceRule } from '@endora-commerce/contracts';
2
+ /**
3
+ * Pure push-audience rule evaluator (Rule Builder targeting).
4
+ *
5
+ * Walks the AST defined in `@endora-commerce/contracts/pwa#PushAudienceRule` and returns
6
+ * whether a single subscriber (resolved into the context below) is in the
7
+ * target audience. Mirrors the price-list `evaluateApplicationRule` shape but
8
+ * keeps a boolean result — push targeting has no priority chain.
9
+ *
10
+ * Semantics:
11
+ * - `{ kind: 'all' }` and any criterion with an empty value list match
12
+ * everyone (an empty criterion adds no constraint).
13
+ * - Anonymous subscribers (`customerAccountId === null`) carry null
14
+ * organization/customer-group/customer context, so only `salesChannel`
15
+ * criteria (and `all`) can match them.
16
+ */
17
+ export interface PushAudienceContext {
18
+ salesChannelId: string;
19
+ organizationId: string | null;
20
+ customerGroupId: string | null;
21
+ customerAccountId: string | null;
22
+ }
23
+ export declare function evaluatePushAudienceRule(rule: PushAudienceRule, ctx: PushAudienceContext): boolean;
24
+ //# sourceMappingURL=push-audience-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-audience-evaluator.d.ts","sourceRoot":"","sources":["../../../src/backend/services/push-audience-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA6B,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAE9F;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,mBAAmB;IAClC,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,gBAAgB,EACtB,GAAG,EAAE,mBAAmB,GACvB,OAAO,CAcT"}
@@ -0,0 +1,29 @@
1
+ export function evaluatePushAudienceRule(rule, ctx) {
2
+ if (rule.kind === 'all')
3
+ return true;
4
+ if (rule.kind === 'criterion') {
5
+ if (rule.values.length === 0)
6
+ return true;
7
+ return matchesCriterion(rule.type, rule.values, ctx);
8
+ }
9
+ // group node
10
+ if (rule.children.length === 0)
11
+ return true; // defensive — schema disallows
12
+ if (rule.op === 'AND') {
13
+ return rule.children.every((c) => evaluatePushAudienceRule(c, ctx));
14
+ }
15
+ return rule.children.some((c) => evaluatePushAudienceRule(c, ctx));
16
+ }
17
+ function matchesCriterion(type, values, ctx) {
18
+ switch (type) {
19
+ case 'salesChannel':
20
+ return values.includes(ctx.salesChannelId);
21
+ case 'organization':
22
+ return ctx.organizationId !== null && values.includes(ctx.organizationId);
23
+ case 'customerGroup':
24
+ return ctx.customerGroupId !== null && values.includes(ctx.customerGroupId);
25
+ case 'customer':
26
+ return ctx.customerAccountId !== null && values.includes(ctx.customerAccountId);
27
+ }
28
+ }
29
+ //# sourceMappingURL=push-audience-evaluator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-audience-evaluator.js","sourceRoot":"","sources":["../../../src/backend/services/push-audience-evaluator.ts"],"names":[],"mappings":"AAwBA,MAAM,UAAU,wBAAwB,CACtC,IAAsB,EACtB,GAAwB;IAExB,IAAI,IAAI,CAAC,IAAI,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IAErC,IAAI,IAAI,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;QAC9B,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1C,OAAO,gBAAgB,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACvD,CAAC;IAED,aAAa;IACb,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,+BAA+B;IAC5E,IAAI,IAAI,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QACtB,OAAO,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,wBAAwB,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,wBAAwB,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;AACrE,CAAC;AAED,SAAS,gBAAgB,CACvB,IAA+B,EAC/B,MAAyB,EACzB,GAAwB;IAExB,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,cAAc;YACjB,OAAO,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;QAC7C,KAAK,cAAc;YACjB,OAAO,GAAG,CAAC,cAAc,KAAK,IAAI,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;QAC5E,KAAK,eAAe;YAClB,OAAO,GAAG,CAAC,eAAe,KAAK,IAAI,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;QAC9E,KAAK,UAAU;YACb,OAAO,GAAG,CAAC,iBAAiB,KAAK,IAAI,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACpF,CAAC;AACH,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { Queue, Worker, type Processor, type QueueOptions, type WorkerOptions } from 'bullmq';
2
+ import type { Redis } from 'ioredis';
3
+ /**
4
+ * BullMQ queue for push delivery fan-out (Principle X). One job per
5
+ * `push_message_deliveries` row; the unique (message_id, subscription_id)
6
+ * delivery row makes redelivery idempotent.
7
+ */
8
+ export interface PushDeliveryJobData {
9
+ deliveryId: string;
10
+ }
11
+ export declare const PUSH_DELIVERY_QUEUE_NAME = "pwa.push.deliver";
12
+ export declare function createPushDeliveryQueue(redis: Redis, overrides?: Partial<QueueOptions>): Queue<PushDeliveryJobData>;
13
+ export declare function createPushDeliveryWorker(redis: Redis, processor: Processor<PushDeliveryJobData>, overrides?: Partial<WorkerOptions>): Worker<PushDeliveryJobData>;
14
+ //# sourceMappingURL=push-delivery-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-delivery-queue.d.ts","sourceRoot":"","sources":["../../../src/backend/services/push-delivery-queue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE9F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,wBAAwB,qBAAqB,CAAC;AAK3D,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,KAAK,EACZ,SAAS,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAChC,KAAK,CAAC,mBAAmB,CAAC,CAY5B;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,SAAS,CAAC,mBAAmB,CAAC,EACzC,SAAS,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,GACjC,MAAM,CAAC,mBAAmB,CAAC,CAc7B"}
@@ -0,0 +1,30 @@
1
+ import { Queue, Worker } from 'bullmq';
2
+ import { enterSystemScope } from '@endora-commerce/platform/kernel';
3
+ export const PUSH_DELIVERY_QUEUE_NAME = 'pwa.push.deliver';
4
+ const DEFAULT_ATTEMPTS = 5;
5
+ const DEFAULT_BACKOFF = { type: 'exponential', delay: 2_000 };
6
+ export function createPushDeliveryQueue(redis, overrides) {
7
+ const options = {
8
+ connection: redis,
9
+ defaultJobOptions: {
10
+ attempts: DEFAULT_ATTEMPTS,
11
+ backoff: DEFAULT_BACKOFF,
12
+ removeOnComplete: { count: 1_000 },
13
+ removeOnFail: { count: 5_000 },
14
+ },
15
+ ...overrides,
16
+ };
17
+ return new Queue(PUSH_DELIVERY_QUEUE_NAME, options);
18
+ }
19
+ export function createPushDeliveryWorker(redis, processor, overrides) {
20
+ // Feature 072 (T033) — the scope moves here from inside the processor, so it
21
+ // sits at the entry point like every other queue in the tree; a processor
22
+ // called directly (a test, a future in-process caller) is no longer the only
23
+ // thing that decides whether a context exists.
24
+ return new Worker(PUSH_DELIVERY_QUEUE_NAME, (job) => enterSystemScope('pwa: push delivery', () => processor(job)), {
25
+ connection: redis,
26
+ concurrency: 8,
27
+ ...overrides,
28
+ });
29
+ }
30
+ //# sourceMappingURL=push-delivery-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-delivery-queue.js","sourceRoot":"","sources":["../../../src/backend/services/push-delivery-queue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAyD,MAAM,QAAQ,CAAC;AAC9F,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAYpE,MAAM,CAAC,MAAM,wBAAwB,GAAG,kBAAkB,CAAC;AAE3D,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAC3B,MAAM,eAAe,GAAG,EAAE,IAAI,EAAE,aAAsB,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAEvE,MAAM,UAAU,uBAAuB,CACrC,KAAY,EACZ,SAAiC;IAEjC,MAAM,OAAO,GAAiB;QAC5B,UAAU,EAAE,KAAK;QACjB,iBAAiB,EAAE;YACjB,QAAQ,EAAE,gBAAgB;YAC1B,OAAO,EAAE,eAAe;YACxB,gBAAgB,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;YAClC,YAAY,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;SAC/B;QACD,GAAG,SAAS;KACb,CAAC;IACF,OAAO,IAAI,KAAK,CAAsB,wBAAwB,EAAE,OAAO,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,wBAAwB,CACtC,KAAY,EACZ,SAAyC,EACzC,SAAkC;IAElC,6EAA6E;IAC7E,0EAA0E;IAC1E,6EAA6E;IAC7E,+CAA+C;IAC/C,OAAO,IAAI,MAAM,CACf,wBAAwB,EACxB,CAAC,GAAG,EAAE,EAAE,CAAC,gBAAgB,CAAC,oBAAoB,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EACrE;QACE,UAAU,EAAE,KAAK;QACjB,WAAW,EAAE,CAAC;QACd,GAAG,SAAS;KACb,CACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,86 @@
1
+ import { type PlatformLogger } from '@endora-commerce/platform/kernel';
2
+ import type { PushMessageService } from './push-message-service.js';
3
+ /**
4
+ * Where a skipped or failed auto-trigger push is written down.
5
+ *
6
+ * The structured-logger surface the kernel already defines, for the reason it
7
+ * exists: these two handlers run outside any request, so `request.log` does not
8
+ * exist and the line has to carry its own context. The alias is kept because it
9
+ * names this module's role for the field; the shape is the platform's.
10
+ */
11
+ export type PushEventLogger = PlatformLogger;
12
+ /**
13
+ * Target resolved for an auto-triggered push. Returning null (or no customer)
14
+ * means "no push" — anonymous orders / unsubscribed customers are skipped.
15
+ */
16
+ export interface PushEventTarget {
17
+ salesChannelId: string;
18
+ customerAccountId: string;
19
+ title: string;
20
+ body: string;
21
+ url?: string;
22
+ }
23
+ export interface PushEventSubscriberDeps {
24
+ messageService: PushMessageService;
25
+ /** Resolve an order-status event into a push target (channel + customer + copy). */
26
+ resolveOrderTarget?: (payload: {
27
+ orderId: string;
28
+ salesChannelId: string;
29
+ from: string;
30
+ to: string;
31
+ }) => Promise<PushEventTarget | null>;
32
+ /** Resolve a quote-request event into a push target. */
33
+ resolveQuoteTarget?: (payload: {
34
+ quoteRequestId: string;
35
+ sourceEventId: string;
36
+ }) => Promise<PushEventTarget | null>;
37
+ /** Gate: is push enabled for this channel? */
38
+ isPushEnabled: (salesChannelId: string) => Promise<boolean>;
39
+ /**
40
+ * Read per line rather than captured, because the logger these handlers
41
+ * should be writing to does not exist yet when they are built: composition
42
+ * runs before `buildServer`, so `ctx.log` is whatever the root could offer
43
+ * that early, and the application's own logger only arrives when the module's
44
+ * routes are registered. `plugin.ts` moves the holder along; the handlers ask
45
+ * for it at the moment they have something to say.
46
+ */
47
+ log: () => PushEventLogger;
48
+ }
49
+ /**
50
+ * The two auto-trigger handlers (FR-024). One push message per event,
51
+ * idempotent on the event id; producer only — nothing is sent inline
52
+ * (Principle X), and every handler absorbs its own failure so a push problem
53
+ * never breaks the business transaction that announced itself.
54
+ *
55
+ * The subscriptions live in this module's `backend.ts` and go through
56
+ * `ctx.subscribe` (issue #107). They were two bare `eventBus.on` calls here, so
57
+ * a switched-off `pwa` still wrote a `push_messages` row and still delivered a
58
+ * notification to a customer's device — the most visible of the writes that
59
+ * survived their module.
60
+ *
61
+ * ## Why the `catch` stays, and what it is no longer allowed to hide
62
+ *
63
+ * `PushMessageService` reaches `customer_accounts` and `organizations` through
64
+ * gated ports (feature 075, Phase C), so a rule-targeted send can raise
65
+ * `ModuleDisabledError` here. `rethrowIfModuleDisabled` — the usual remedy —
66
+ * is the wrong one at this seam: an EventBus subscriber has no caller to answer.
67
+ * `EventBus.dispatch` isolates each handler in its own `try` and turns a throw
68
+ * into one `console.warn` line naming neither the module nor the trigger, so
69
+ * re-throwing would hand the presence answer to a place that discards it, and a
70
+ * probe before the work would make `pwa` name the two owners the port exists to
71
+ * hide.
72
+ *
73
+ * So the presence answer is **decided here, by name** — the third of the four
74
+ * shapes `check-port-catches` accepts — and it is decided into a different
75
+ * sentence from a failure. The outcome is the same for both (no push, the
76
+ * announcing transaction untouched, per FR-024); the record is not, because an
77
+ * operator's own switch doing what it says and a broken enqueue are not one
78
+ * event. That is the rule under Principle XVII item 3: where nothing can catch
79
+ * the throw, a genuine failure and a switched-off module must not share one
80
+ * silent no-op.
81
+ */
82
+ export declare function createPushEventHandlers(deps: PushEventSubscriberDeps): {
83
+ onOrderStatusChanged: (payload: unknown) => Promise<void>;
84
+ onQuoteRequestUpdated: (payload: unknown) => Promise<void>;
85
+ };
86
+ //# sourceMappingURL=push-event-subscriber.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-event-subscriber.d.ts","sourceRoot":"","sources":["../../../src/backend/services/push-event-subscriber.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EACpB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAEpE;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC;AAE7C;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,uBAAuB;IACtC,cAAc,EAAE,kBAAkB,CAAC;IACnC,oFAAoF;IACpF,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE;QAC7B,OAAO,EAAE,MAAM,CAAC;QAChB,cAAc,EAAE,MAAM,CAAC;QACvB,IAAI,EAAE,MAAM,CAAC;QACb,EAAE,EAAE,MAAM,CAAC;KACZ,KAAK,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAC;IACtC,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE;QAC7B,cAAc,EAAE,MAAM,CAAC;QACvB,aAAa,EAAE,MAAM,CAAC;KACvB,KAAK,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAC;IACtC,8CAA8C;IAC9C,aAAa,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5D;;;;;;;OAOG;IACH,GAAG,EAAE,MAAM,eAAe,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,GAAG;IACtE,oBAAoB,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,qBAAqB,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D,CAyFA"}
@@ -0,0 +1,109 @@
1
+ import { ModuleDisabledError, } from '@endora-commerce/platform/kernel';
2
+ /**
3
+ * The two auto-trigger handlers (FR-024). One push message per event,
4
+ * idempotent on the event id; producer only — nothing is sent inline
5
+ * (Principle X), and every handler absorbs its own failure so a push problem
6
+ * never breaks the business transaction that announced itself.
7
+ *
8
+ * The subscriptions live in this module's `backend.ts` and go through
9
+ * `ctx.subscribe` (issue #107). They were two bare `eventBus.on` calls here, so
10
+ * a switched-off `pwa` still wrote a `push_messages` row and still delivered a
11
+ * notification to a customer's device — the most visible of the writes that
12
+ * survived their module.
13
+ *
14
+ * ## Why the `catch` stays, and what it is no longer allowed to hide
15
+ *
16
+ * `PushMessageService` reaches `customer_accounts` and `organizations` through
17
+ * gated ports (feature 075, Phase C), so a rule-targeted send can raise
18
+ * `ModuleDisabledError` here. `rethrowIfModuleDisabled` — the usual remedy —
19
+ * is the wrong one at this seam: an EventBus subscriber has no caller to answer.
20
+ * `EventBus.dispatch` isolates each handler in its own `try` and turns a throw
21
+ * into one `console.warn` line naming neither the module nor the trigger, so
22
+ * re-throwing would hand the presence answer to a place that discards it, and a
23
+ * probe before the work would make `pwa` name the two owners the port exists to
24
+ * hide.
25
+ *
26
+ * So the presence answer is **decided here, by name** — the third of the four
27
+ * shapes `check-port-catches` accepts — and it is decided into a different
28
+ * sentence from a failure. The outcome is the same for both (no push, the
29
+ * announcing transaction untouched, per FR-024); the record is not, because an
30
+ * operator's own switch doing what it says and a broken enqueue are not one
31
+ * event. That is the rule under Principle XVII item 3: where nothing can catch
32
+ * the throw, a genuine failure and a switched-off module must not share one
33
+ * silent no-op.
34
+ */
35
+ export function createPushEventHandlers(deps) {
36
+ const { messageService } = deps;
37
+ return {
38
+ // order.status_changed.v1 (orders, feature 038)
39
+ async onOrderStatusChanged(raw) {
40
+ const payload = raw;
41
+ try {
42
+ if (!deps.resolveOrderTarget)
43
+ return;
44
+ if (!(await deps.isPushEnabled(payload.salesChannelId)))
45
+ return;
46
+ const target = await deps.resolveOrderTarget(payload);
47
+ if (!target)
48
+ return;
49
+ await messageService.createAndEnqueue({
50
+ salesChannelId: target.salesChannelId,
51
+ title: target.title,
52
+ body: target.body,
53
+ ...(target.url ? { url: target.url } : {}),
54
+ audience: { kind: 'customers', customerAccountIds: [target.customerAccountId] },
55
+ trigger: 'order_status',
56
+ sourceEventId: payload.eventId,
57
+ });
58
+ }
59
+ catch (err) {
60
+ // Decided at the site, not delegated: the identifier below is the whole
61
+ // of what tells an operator's switch from an incident, and a helper
62
+ // holding it would let a later edit lose the distinction silently.
63
+ if (err instanceof ModuleDisabledError) {
64
+ deps.log().warn({ module: err.moduleId, trigger: 'order_status', eventId: payload.eventId }, 'pwa.push_skipped_module_disabled');
65
+ return;
66
+ }
67
+ deps
68
+ .log()
69
+ .error({ err, trigger: 'order_status', eventId: payload.eventId }, 'pwa.push_enqueue_failed');
70
+ }
71
+ },
72
+ // quote-request update events (quote_requests, feature 008)
73
+ async onQuoteRequestUpdated(raw) {
74
+ const payload = raw;
75
+ try {
76
+ if (!deps.resolveQuoteTarget)
77
+ return;
78
+ const target = await deps.resolveQuoteTarget({
79
+ quoteRequestId: payload.quoteRequestId,
80
+ sourceEventId: payload.eventId,
81
+ });
82
+ if (!target)
83
+ return;
84
+ if (!(await deps.isPushEnabled(target.salesChannelId)))
85
+ return;
86
+ await messageService.createAndEnqueue({
87
+ salesChannelId: target.salesChannelId,
88
+ title: target.title,
89
+ body: target.body,
90
+ ...(target.url ? { url: target.url } : {}),
91
+ audience: { kind: 'customers', customerAccountIds: [target.customerAccountId] },
92
+ trigger: 'quote_request',
93
+ sourceEventId: payload.eventId,
94
+ });
95
+ }
96
+ catch (err) {
97
+ // See the order-status twin above — same decision, same two sentences.
98
+ if (err instanceof ModuleDisabledError) {
99
+ deps.log().warn({ module: err.moduleId, trigger: 'quote_request', eventId: payload.eventId }, 'pwa.push_skipped_module_disabled');
100
+ return;
101
+ }
102
+ deps
103
+ .log()
104
+ .error({ err, trigger: 'quote_request', eventId: payload.eventId }, 'pwa.push_enqueue_failed');
105
+ }
106
+ },
107
+ };
108
+ }
109
+ //# sourceMappingURL=push-event-subscriber.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-event-subscriber.js","sourceRoot":"","sources":["../../../src/backend/services/push-event-subscriber.ts"],"names":[],"mappings":"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"}