@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,58 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { Queue } from 'bullmq';
3
+ import type { CustomerAccountReadPort, OrganizationDetailsPort, PushAudience, PushTrigger } from '@endora-commerce/contracts';
4
+ import type { PushDeliveryJobData } from './push-delivery-queue.js';
5
+ export interface CreateMessageInput {
6
+ salesChannelId: string;
7
+ title: string;
8
+ body: string;
9
+ url?: string | null;
10
+ iconUrl?: string | null;
11
+ audience: PushAudience;
12
+ trigger: PushTrigger;
13
+ sourceEventId?: string | null;
14
+ createdByAdminUserId?: string | null;
15
+ }
16
+ export interface CreateMessageResult {
17
+ messageId: string;
18
+ queuedDeliveries: number;
19
+ }
20
+ /**
21
+ * Push message lifecycle (US4). Creates a message, expands the audience into
22
+ * delivery rows, and enqueues one BullMQ job per delivery (producer-only — it
23
+ * never sends inline, Principle X). Event-triggered messages are idempotent on
24
+ * `(trigger, source_event_id)` via the partial unique index.
25
+ */
26
+ export declare class PushMessageService {
27
+ private readonly emFactory;
28
+ private readonly queue;
29
+ /**
30
+ * The two rows rule targeting needs, asked of the modules that own them
31
+ * (feature 075, Phase C). Both are gated: with `customer_accounts` or
32
+ * `organizations` switched off the send refuses rather than pushing to an
33
+ * audience it could not resolve.
34
+ */
35
+ private readonly customerAccounts;
36
+ private readonly organizations;
37
+ constructor(emFactory: () => EntityManager, queue: Queue<PushDeliveryJobData>,
38
+ /**
39
+ * The two rows rule targeting needs, asked of the modules that own them
40
+ * (feature 075, Phase C). Both are gated: with `customer_accounts` or
41
+ * `organizations` switched off the send refuses rather than pushing to an
42
+ * audience it could not resolve.
43
+ */
44
+ customerAccounts: CustomerAccountReadPort, organizations: OrganizationDetailsPort);
45
+ createAndEnqueue(input: CreateMessageInput): Promise<CreateMessageResult>;
46
+ private resolveAudience;
47
+ /**
48
+ * Batch-resolve `{ organizationId, effective customerGroupId }` for each
49
+ * linked customer account. The effective group follows the same chain the
50
+ * pricing engine uses: `account.customerGroupId ?? organization.customerGroupId`.
51
+ *
52
+ * Both rows arrive over their owners' published ports, so a switched-off
53
+ * `customer_accounts` or `organizations` refuses here rather than reporting
54
+ * an audience of nobody — which reads as a legitimate answer and is not one.
55
+ */
56
+ private loadCustomerContexts;
57
+ }
58
+ //# sourceMappingURL=push-message-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-message-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/push-message-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AACpC,OAAO,KAAK,EACV,uBAAuB,EACvB,uBAAuB,EACvB,YAAY,EACZ,WAAW,EACZ,MAAM,4BAA4B,CAAC;AAKpC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAQpE,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,YAAY,CAAC;IACvB,OAAO,EAAE,WAAW,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,qBAAa,kBAAkB;IAE3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,aAAa;gBATb,SAAS,EAAE,MAAM,aAAa,EAC9B,KAAK,EAAE,KAAK,CAAC,mBAAmB,CAAC;IAClD;;;;;OAKG;IACc,gBAAgB,EAAE,uBAAuB,EACzC,aAAa,EAAE,uBAAuB;IAGnD,gBAAgB,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC;YA0DjE,eAAe;IAsC7B;;;;;;;;OAQG;YACW,oBAAoB;CA6BnC"}
@@ -0,0 +1,145 @@
1
+ import { PushMessage } from '../entities/push-message.entity.js';
2
+ import { PushMessageDelivery } from '../entities/push-message-delivery.entity.js';
3
+ import { PushSubscription } from '../entities/push-subscription.entity.js';
4
+ import { evaluatePushAudienceRule } from './push-audience-evaluator.js';
5
+ /**
6
+ * Push message lifecycle (US4). Creates a message, expands the audience into
7
+ * delivery rows, and enqueues one BullMQ job per delivery (producer-only — it
8
+ * never sends inline, Principle X). Event-triggered messages are idempotent on
9
+ * `(trigger, source_event_id)` via the partial unique index.
10
+ */
11
+ export class PushMessageService {
12
+ emFactory;
13
+ queue;
14
+ customerAccounts;
15
+ organizations;
16
+ constructor(emFactory, queue,
17
+ /**
18
+ * The two rows rule targeting needs, asked of the modules that own them
19
+ * (feature 075, Phase C). Both are gated: with `customer_accounts` or
20
+ * `organizations` switched off the send refuses rather than pushing to an
21
+ * audience it could not resolve.
22
+ */
23
+ customerAccounts, organizations) {
24
+ this.emFactory = emFactory;
25
+ this.queue = queue;
26
+ this.customerAccounts = customerAccounts;
27
+ this.organizations = organizations;
28
+ }
29
+ async createAndEnqueue(input) {
30
+ // command-coverage-ignore: push-notification infrastructure — device
31
+ // subscription / message delivery / icon asset, not audited domain state.
32
+ const em = this.emFactory();
33
+ // Idempotency guard for event-triggered sends: a duplicate event is a no-op.
34
+ if (input.sourceEventId) {
35
+ const dup = await em.findOne(PushMessage, {
36
+ trigger: input.trigger,
37
+ sourceEventId: input.sourceEventId,
38
+ });
39
+ if (dup)
40
+ return { messageId: dup.id, queuedDeliveries: 0 };
41
+ }
42
+ // Expanded **before** the message row is written. Rule targeting now asks
43
+ // two other modules for the rows it resolves a subscriber's context from,
44
+ // so it can refuse (503 `MODULE_DISABLED`); persisting first would leave a
45
+ // `queued` message behind that nothing will ever deliver.
46
+ const subscriptions = await this.resolveAudience(em, input.salesChannelId, input.audience);
47
+ const message = em.create(PushMessage, {
48
+ salesChannelId: input.salesChannelId,
49
+ title: input.title,
50
+ body: input.body,
51
+ url: input.url ?? null,
52
+ iconUrl: input.iconUrl ?? null,
53
+ audience: input.audience,
54
+ trigger: input.trigger,
55
+ sourceEventId: input.sourceEventId ?? null,
56
+ status: 'queued',
57
+ sentCount: 0,
58
+ failedCount: 0,
59
+ createdByAdminUserId: input.createdByAdminUserId ?? null,
60
+ });
61
+ await em.persistAndFlush(message);
62
+ const deliveries = subscriptions.map((sub) => em.create(PushMessageDelivery, {
63
+ messageId: message.id,
64
+ subscriptionId: sub.id,
65
+ status: 'pending',
66
+ attempts: 0,
67
+ }));
68
+ if (deliveries.length > 0) {
69
+ await em.persistAndFlush(deliveries);
70
+ await this.queue.addBulk(deliveries.map((d) => ({
71
+ name: 'deliver',
72
+ data: { deliveryId: d.id },
73
+ opts: { jobId: d.id },
74
+ })));
75
+ }
76
+ return { messageId: message.id, queuedDeliveries: deliveries.length };
77
+ }
78
+ async resolveAudience(em, salesChannelId, audience) {
79
+ if (audience.kind === 'all') {
80
+ return em.find(PushSubscription, { salesChannelId, status: 'active' });
81
+ }
82
+ if (audience.kind === 'customers') {
83
+ return em.find(PushSubscription, {
84
+ salesChannelId,
85
+ status: 'active',
86
+ customerAccountId: { $in: audience.customerAccountIds },
87
+ });
88
+ }
89
+ // kind === 'rule' — narrow active channel subscribers by their resolved
90
+ // customer context. Anonymous subscribers only match salesChannel/all.
91
+ const subscriptions = await em.find(PushSubscription, { salesChannelId, status: 'active' });
92
+ const accountIds = [
93
+ ...new Set(subscriptions
94
+ .map((s) => s.customerAccountId)
95
+ .filter((id) => typeof id === 'string')),
96
+ ];
97
+ const contexts = await this.loadCustomerContexts(accountIds);
98
+ return subscriptions.filter((sub) => {
99
+ const ctx = sub.customerAccountId ? contexts.get(sub.customerAccountId) : undefined;
100
+ return evaluatePushAudienceRule(audience.rule, {
101
+ salesChannelId,
102
+ customerAccountId: sub.customerAccountId ?? null,
103
+ organizationId: ctx?.organizationId ?? null,
104
+ customerGroupId: ctx?.customerGroupId ?? null,
105
+ });
106
+ });
107
+ }
108
+ /**
109
+ * Batch-resolve `{ organizationId, effective customerGroupId }` for each
110
+ * linked customer account. The effective group follows the same chain the
111
+ * pricing engine uses: `account.customerGroupId ?? organization.customerGroupId`.
112
+ *
113
+ * Both rows arrive over their owners' published ports, so a switched-off
114
+ * `customer_accounts` or `organizations` refuses here rather than reporting
115
+ * an audience of nobody — which reads as a legitimate answer and is not one.
116
+ */
117
+ async loadCustomerContexts(accountIds) {
118
+ const map = new Map();
119
+ if (accountIds.length === 0)
120
+ return map;
121
+ const accounts = await this.customerAccounts.findByIds(accountIds);
122
+ const orgIds = [
123
+ ...new Set(accounts
124
+ .map((a) => a.organizationId)
125
+ .filter((id) => typeof id === 'string')),
126
+ ];
127
+ const orgGroups = new Map();
128
+ if (orgIds.length > 0) {
129
+ const orgs = await this.organizations.findByIds(orgIds);
130
+ for (const org of orgs)
131
+ orgGroups.set(org.id, org.customerGroupId ?? null);
132
+ }
133
+ for (const account of accounts) {
134
+ const orgGroup = account.organizationId
135
+ ? orgGroups.get(account.organizationId) ?? null
136
+ : null;
137
+ map.set(account.id, {
138
+ organizationId: account.organizationId ?? null,
139
+ customerGroupId: account.customerGroupId ?? orgGroup,
140
+ });
141
+ }
142
+ return map;
143
+ }
144
+ }
145
+ //# sourceMappingURL=push-message-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-message-service.js","sourceRoot":"","sources":["../../../src/backend/services/push-message-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { PushProvider, PushProviderRegistryPort } from '@endora-commerce/contracts';
2
+ /**
3
+ * Provider-agnostic push registry (FR-019). Mirrors the payment/shipping adapter
4
+ * registries: providers register at composition time; the default ('web_push')
5
+ * is resolved per channel. Swapping the default backend is a server-side change
6
+ * that leaves the storefront subscription flow untouched.
7
+ */
8
+ export declare class PushProviderRegistry implements PushProviderRegistryPort {
9
+ private readonly providers;
10
+ private defaultKey;
11
+ register(provider: PushProvider): void;
12
+ /** Set which provider key is used as the default for new sends. */
13
+ setDefaultKey(key: string): void;
14
+ get(key: string): PushProvider | undefined;
15
+ resolveDefault(_salesChannelId: string): Promise<PushProvider>;
16
+ }
17
+ //# sourceMappingURL=push-provider-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-provider-registry.d.ts","sourceRoot":"","sources":["../../../src/backend/services/push-provider-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEzF;;;;;GAKG;AACH,qBAAa,oBAAqB,YAAW,wBAAwB;IACnE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,UAAU,CAAc;IAEhC,QAAQ,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI;IAQtC,mEAAmE;IACnE,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIhC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAIpC,cAAc,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAOrE"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Provider-agnostic push registry (FR-019). Mirrors the payment/shipping adapter
3
+ * registries: providers register at composition time; the default ('web_push')
4
+ * is resolved per channel. Swapping the default backend is a server-side change
5
+ * that leaves the storefront subscription flow untouched.
6
+ */
7
+ export class PushProviderRegistry {
8
+ providers = new Map();
9
+ defaultKey = 'web_push';
10
+ register(provider) {
11
+ if (this.providers.has(provider.key)) {
12
+ // Last-writer-wins, with a visible signal (same posture as the adapter registries).
13
+ console.warn(`[pwa] push provider "${provider.key}" re-registered; overriding.`);
14
+ }
15
+ this.providers.set(provider.key, provider);
16
+ }
17
+ /** Set which provider key is used as the default for new sends. */
18
+ setDefaultKey(key) {
19
+ this.defaultKey = key;
20
+ }
21
+ get(key) {
22
+ return this.providers.get(key);
23
+ }
24
+ async resolveDefault(_salesChannelId) {
25
+ const provider = this.providers.get(this.defaultKey);
26
+ if (!provider) {
27
+ throw new Error(`[pwa] no push provider registered for default key "${this.defaultKey}".`);
28
+ }
29
+ return provider;
30
+ }
31
+ }
32
+ //# sourceMappingURL=push-provider-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-provider-registry.js","sourceRoot":"","sources":["../../../src/backend/services/push-provider-registry.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,OAAO,oBAAoB;IACd,SAAS,GAAG,IAAI,GAAG,EAAwB,CAAC;IACrD,UAAU,GAAG,UAAU,CAAC;IAEhC,QAAQ,CAAC,QAAsB;QAC7B,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACrC,oFAAoF;YACpF,OAAO,CAAC,IAAI,CAAC,wBAAwB,QAAQ,CAAC,GAAG,8BAA8B,CAAC,CAAC;QACnF,CAAC;QACD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IAC7C,CAAC;IAED,mEAAmE;IACnE,aAAa,CAAC,GAAW;QACvB,IAAI,CAAC,UAAU,GAAG,GAAG,CAAC;IACxB,CAAC;IAED,GAAG,CAAC,GAAW;QACb,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACjC,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,eAAuB;QAC1C,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACrD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,IAAI,KAAK,CAAC,sDAAsD,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;QAC7F,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;CACF"}
@@ -0,0 +1,67 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { CustomerAccountReadPort, PushSubscriptionInput } from '@endora-commerce/contracts';
3
+ export interface RegisterSubscriptionInput extends PushSubscriptionInput {
4
+ salesChannelId: string;
5
+ customerAccountId?: string | null;
6
+ }
7
+ export interface RegisterSubscriptionResult {
8
+ id: string;
9
+ status: 'active';
10
+ created: boolean;
11
+ }
12
+ /**
13
+ * Push subscription registry (US4). Upsert on `endpoint` (idempotent re-subscribe,
14
+ * FR-017, multi-device edge case), revoke (FR-018), and prune dead endpoints
15
+ * reported by the provider (FR-022).
16
+ */
17
+ export declare class PushSubscriptionService {
18
+ #private;
19
+ private readonly emFactory;
20
+ /**
21
+ * `customer_accounts`' read model, for the one field the owner stamp needs:
22
+ * the organisation the owning account belongs to (feature 087, D-187).
23
+ *
24
+ * Required rather than optional, because this module has exactly one
25
+ * construction site (`plugin.ts`) and it holds the gated port already. A
26
+ * composition that cannot answer "which organisation owns this account"
27
+ * cannot write this table at all, and saying so in the constructor is
28
+ * cheaper than a runtime refusal at the write.
29
+ *
30
+ * Resolved through `lazyPort`, so a switched-off `customer_accounts`
31
+ * refuses at the call with the 503 `MODULE_DISABLED` envelope rather than
32
+ * at composition time.
33
+ */
34
+ private readonly customerAccounts;
35
+ constructor(emFactory: () => EntityManager,
36
+ /**
37
+ * `customer_accounts`' read model, for the one field the owner stamp needs:
38
+ * the organisation the owning account belongs to (feature 087, D-187).
39
+ *
40
+ * Required rather than optional, because this module has exactly one
41
+ * construction site (`plugin.ts`) and it holds the gated port already. A
42
+ * composition that cannot answer "which organisation owns this account"
43
+ * cannot write this table at all, and saying so in the constructor is
44
+ * cheaper than a runtime refusal at the write.
45
+ *
46
+ * Resolved through `lazyPort`, so a switched-off `customer_accounts`
47
+ * refuses at the call with the 503 `MODULE_DISABLED` envelope rather than
48
+ * at composition time.
49
+ */
50
+ customerAccounts: CustomerAccountReadPort);
51
+ register(input: RegisterSubscriptionInput): Promise<RegisterSubscriptionResult>;
52
+ /** Revoke by endpoint. Idempotent — returns false if nothing was deleted. */
53
+ revoke(endpoint: string): Promise<boolean>;
54
+ /** Mark a subscription invalid and delete it (provider reported the endpoint gone). */
55
+ prune(subscriptionId: string): Promise<void>;
56
+ /**
57
+ * `null` = the admin asked for a scope this deployment has no channel for, so
58
+ * there is nothing subscribed to count. The column is `NOT NULL`, so the
59
+ * query matches no row and the honest answer is zero — where the `'default'`
60
+ * sentinel used to make PostgreSQL reject the comparison outright.
61
+ */
62
+ statsForChannel(salesChannelId: string | null): Promise<{
63
+ active: number;
64
+ invalid: number;
65
+ }>;
66
+ }
67
+ //# sourceMappingURL=push-subscription-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-subscription-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/push-subscription-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAGjG,MAAM,WAAW,yBAA0B,SAAQ,qBAAqB;IACtE,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,QAAQ,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB;AAkDD;;;;GAIG;AACH,qBAAa,uBAAuB;;IAEhC,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,QAAQ,CAAC,gBAAgB;gBAfhB,SAAS,EAAE,MAAM,aAAa;IAC/C;;;;;;;;;;;;;OAaG;IACc,gBAAgB,EAAE,uBAAuB;IAoCtD,QAAQ,CAAC,KAAK,EAAE,yBAAyB,GAAG,OAAO,CAAC,0BAA0B,CAAC;IAoDrF,6EAA6E;IACvE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAUhD,uFAAuF;IACjF,KAAK,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASlD;;;;;OAKG;IACG,eAAe,CACnB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAQhD"}
@@ -0,0 +1,165 @@
1
+ import { PushSubscription } from '../entities/push-subscription.entity.js';
2
+ /**
3
+ * The two attribution columns, written together from one resolved owner.
4
+ *
5
+ * Both directions of the upsert go through this function, which is the whole
6
+ * point: signing in on a subscribed device stamps the account and the
7
+ * organisation, and re-subscribing that same device anonymously clears both.
8
+ */
9
+ function ownerColumns(owner) {
10
+ return owner.kind === 'customer'
11
+ ? { customerAccountId: owner.customerAccountId, organizationId: owner.organizationId }
12
+ : {
13
+ customerAccountId: null,
14
+ // FR-011 / FR-023 — an anonymous device belongs to no organisation, and
15
+ // the constraint says nothing about it. Who it *should* belong to is
16
+ // R-6's open question and is not answered here. What matters is that
17
+ // the organisation is cleared rather than left behind: a row with no
18
+ // account and a stale organisation is disclosed to a representative of
19
+ // an organisation that no longer owns the device.
20
+ organizationId: null,
21
+ };
22
+ }
23
+ /**
24
+ * Push subscription registry (US4). Upsert on `endpoint` (idempotent re-subscribe,
25
+ * FR-017, multi-device edge case), revoke (FR-018), and prune dead endpoints
26
+ * reported by the provider (FR-022).
27
+ */
28
+ export class PushSubscriptionService {
29
+ emFactory;
30
+ customerAccounts;
31
+ constructor(emFactory,
32
+ /**
33
+ * `customer_accounts`' read model, for the one field the owner stamp needs:
34
+ * the organisation the owning account belongs to (feature 087, D-187).
35
+ *
36
+ * Required rather than optional, because this module has exactly one
37
+ * construction site (`plugin.ts`) and it holds the gated port already. A
38
+ * composition that cannot answer "which organisation owns this account"
39
+ * cannot write this table at all, and saying so in the constructor is
40
+ * cheaper than a runtime refusal at the write.
41
+ *
42
+ * Resolved through `lazyPort`, so a switched-off `customer_accounts`
43
+ * refuses at the call with the 503 `MODULE_DISABLED` envelope rather than
44
+ * at composition time.
45
+ */
46
+ customerAccounts) {
47
+ this.emFactory = emFactory;
48
+ this.customerAccounts = customerAccounts;
49
+ }
50
+ /**
51
+ * The organisation that owns a subscription belonging to this account —
52
+ * feature 087 Group B, D-187.
53
+ *
54
+ * Total, and refuses rather than returning `null`:
55
+ * `customer_accounts.organization_id` is `NOT NULL` (D-178), so an account
56
+ * that resolves always has one, and an account that does not resolve is a
57
+ * caller naming a row that is not there. Either way there is no organisation
58
+ * to stamp, and a subscription written without one is a device hidden from
59
+ * the representative who serves its buyer — which is the whole defect this
60
+ * column exists to close. So the write stops here, where the message can name
61
+ * the account, instead of at the constraint.
62
+ */
63
+ async #organizationOf(customerAccountId) {
64
+ const account = await this.customerAccounts.findById(customerAccountId);
65
+ if (!account) {
66
+ throw new Error(`PushSubscriptionService: customer account ${customerAccountId} does not resolve, so the push subscription it would own has no organisation to carry.`);
67
+ }
68
+ return account.organizationId;
69
+ }
70
+ /** The caller's owner input, with the organisation a customer owner implies. */
71
+ async #resolveOwner(customerAccountId) {
72
+ if (customerAccountId === null || customerAccountId === undefined)
73
+ return { kind: 'anonymous' };
74
+ return {
75
+ kind: 'customer',
76
+ customerAccountId,
77
+ organizationId: await this.#organizationOf(customerAccountId),
78
+ };
79
+ }
80
+ async register(input) {
81
+ // command-coverage-ignore: push-notification infrastructure — device
82
+ // subscription / message delivery / icon asset, not audited domain state.
83
+ const em = this.emFactory();
84
+ const existing = await em.findOne(PushSubscription, { endpoint: input.endpoint });
85
+ // D-187 — resolved **before** the managed entity is touched, not between
86
+ // the account assignment and the flush. `existing` is managed, so a throw
87
+ // after the assignment would leave an owned, unattributed subscription in
88
+ // the unit of work for whatever flushes this request next.
89
+ const owner = await this.#resolveOwner(input.customerAccountId);
90
+ if (existing) {
91
+ existing.p256dh = input.keys.p256dh;
92
+ existing.auth = input.keys.auth;
93
+ existing.salesChannelId = input.salesChannelId;
94
+ // D-187 — the account and the organisation move together, in **both**
95
+ // directions. Signing in on this device stamps both; re-subscribing it
96
+ // anonymously clears both. The `CHECK` catches only the first of those,
97
+ // by design (it is an implication, so R-6 stays open); the second is
98
+ // held here, by there being no arm of `ownerColumns` that writes one
99
+ // without the other. `em.assign` rather than two statements, for the same
100
+ // reason `ownerColumns` returns both columns as one value: two statements
101
+ // are two things to remember, and the second one is the one that was
102
+ // never written.
103
+ em.assign(existing, ownerColumns(owner));
104
+ existing.status = 'active';
105
+ existing.lastSeenAt = new Date();
106
+ if (input.userAgent !== undefined)
107
+ existing.userAgent = input.userAgent;
108
+ await em.flush();
109
+ return { id: existing.id, status: 'active', created: false };
110
+ }
111
+ const sub = em.create(PushSubscription, {
112
+ salesChannelId: input.salesChannelId,
113
+ // D-187 — the only place a subscription is inserted, and the organisation
114
+ // goes in with the account. `ownerColumns` takes a *resolved* owner, so
115
+ // the customer branch has no shape in which the organisation could be
116
+ // omitted; the anonymous branch has none to carry (FR-011/FR-023).
117
+ ...ownerColumns(owner),
118
+ endpoint: input.endpoint,
119
+ p256dh: input.keys.p256dh,
120
+ auth: input.keys.auth,
121
+ provider: 'web_push',
122
+ status: 'active',
123
+ userAgent: input.userAgent ?? null,
124
+ lastSeenAt: new Date(),
125
+ });
126
+ await em.persistAndFlush(sub);
127
+ return { id: sub.id, status: 'active', created: true };
128
+ }
129
+ /** Revoke by endpoint. Idempotent — returns false if nothing was deleted. */
130
+ async revoke(endpoint) {
131
+ // command-coverage-ignore: push-notification infrastructure — device
132
+ // subscription / message delivery / icon asset, not audited domain state.
133
+ const em = this.emFactory();
134
+ const existing = await em.findOne(PushSubscription, { endpoint });
135
+ if (!existing)
136
+ return false;
137
+ await em.removeAndFlush(existing);
138
+ return true;
139
+ }
140
+ /** Mark a subscription invalid and delete it (provider reported the endpoint gone). */
141
+ async prune(subscriptionId) {
142
+ // command-coverage-ignore: push-notification infrastructure — device
143
+ // subscription / message delivery / icon asset, not audited domain state.
144
+ const em = this.emFactory();
145
+ const existing = await em.findOne(PushSubscription, { id: subscriptionId });
146
+ if (!existing)
147
+ return;
148
+ await em.removeAndFlush(existing);
149
+ }
150
+ /**
151
+ * `null` = the admin asked for a scope this deployment has no channel for, so
152
+ * there is nothing subscribed to count. The column is `NOT NULL`, so the
153
+ * query matches no row and the honest answer is zero — where the `'default'`
154
+ * sentinel used to make PostgreSQL reject the comparison outright.
155
+ */
156
+ async statsForChannel(salesChannelId) {
157
+ const em = this.emFactory();
158
+ const [active, invalid] = await Promise.all([
159
+ em.count(PushSubscription, { salesChannelId, status: 'active' }),
160
+ em.count(PushSubscription, { salesChannelId, status: 'invalid' }),
161
+ ]);
162
+ return { active, invalid };
163
+ }
164
+ }
165
+ //# sourceMappingURL=push-subscription-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-subscription-service.js","sourceRoot":"","sources":["../../../src/backend/services/push-subscription-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import { z } from 'zod';
3
+ import { type PwaIconDescriptor, type PwaPublicConfig } from '@endora-commerce/contracts';
4
+ /**
5
+ * Minimal read port over SettingsService — the resolver only needs `get`.
6
+ * SettingsService already provides read-through caching + write invalidation,
7
+ * so the resolver does not add a second cache (YAGNI; FR-009 propagation is the
8
+ * Settings cache's job).
9
+ */
10
+ export interface SettingsReadPort {
11
+ get<T>(code: string, salesChannelId: string | null, schema: z.ZodType<T>): Promise<T>;
12
+ }
13
+ export declare class PwaConfigResolver {
14
+ private readonly settings;
15
+ private readonly emFactory;
16
+ constructor(settings: SettingsReadPort, emFactory: () => EntityManager);
17
+ private getString;
18
+ private getBool;
19
+ private getDisplayMode;
20
+ /**
21
+ * Build the icon descriptors for a channel from derived renditions. Falls back
22
+ * to the per-channel renditions, then the global (null-channel) renditions, then
23
+ * the bundled placeholders.
24
+ */
25
+ resolveIcons(salesChannelId: string | null): Promise<PwaIconDescriptor[]>;
26
+ /** Public, storefront-facing config for a channel (no private secrets). */
27
+ getPublicConfig(salesChannelId: string | null): Promise<PwaPublicConfig>;
28
+ }
29
+ //# sourceMappingURL=pwa-config-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pwa-config-resolver.d.ts","sourceRoot":"","sources":["../../../src/backend/services/pwa-config-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAIL,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AAGpC;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACvF;AAYD,qBAAa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,QAAQ,EAAE,gBAAgB,EAC1B,SAAS,EAAE,MAAM,aAAa;YAGnC,SAAS;YAYT,OAAO;YAYP,cAAc;IAQ5B;;;;OAIG;IACG,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAkB/E,2EAA2E;IACrE,eAAe,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC;CAoC/E"}
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ import { PWA_SETTING_CODES, PwaDisplayModeSchema, } from '@endora-commerce/contracts';
3
+ import { PwaIconRendition } from '../entities/pwa-icon-rendition.entity.js';
4
+ const StringSchema = z.string();
5
+ const BoolSchema = z.boolean();
6
+ /** Bundled placeholder icons shipped by the storefront when no source is configured. */
7
+ const PLACEHOLDER_ICONS = [
8
+ { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
9
+ { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
10
+ { src: '/icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
11
+ ];
12
+ export class PwaConfigResolver {
13
+ settings;
14
+ emFactory;
15
+ constructor(settings, emFactory) {
16
+ this.settings = settings;
17
+ this.emFactory = emFactory;
18
+ }
19
+ async getString(code, channelId, fallback) {
20
+ try {
21
+ return await this.settings.get(code, channelId, StringSchema);
22
+ }
23
+ catch {
24
+ return fallback;
25
+ }
26
+ }
27
+ async getBool(code, channelId, fallback) {
28
+ try {
29
+ return await this.settings.get(code, channelId, BoolSchema);
30
+ }
31
+ catch {
32
+ return fallback;
33
+ }
34
+ }
35
+ async getDisplayMode(channelId) {
36
+ try {
37
+ return await this.settings.get(PWA_SETTING_CODES.DISPLAY_MODE, channelId, PwaDisplayModeSchema);
38
+ }
39
+ catch {
40
+ return 'standalone';
41
+ }
42
+ }
43
+ /**
44
+ * Build the icon descriptors for a channel from derived renditions. Falls back
45
+ * to the per-channel renditions, then the global (null-channel) renditions, then
46
+ * the bundled placeholders.
47
+ */
48
+ async resolveIcons(salesChannelId) {
49
+ const em = this.emFactory();
50
+ // `null` = no channel to resolve for, so start at the global renditions —
51
+ // the same row set the per-channel lookup falls back to anyway.
52
+ const channelRows = salesChannelId === null ? [] : await em.find(PwaIconRendition, { salesChannelId });
53
+ const rows = channelRows.length > 0
54
+ ? channelRows
55
+ : await em.find(PwaIconRendition, { salesChannelId: null });
56
+ if (rows.length === 0)
57
+ return PLACEHOLDER_ICONS;
58
+ return rows.map((r) => ({
59
+ src: `/api/v1/storefront/pwa/icons/${r.size}-${r.purpose}.png`,
60
+ sizes: `${r.size}x${r.size}`,
61
+ type: 'image/png',
62
+ purpose: r.purpose,
63
+ }));
64
+ }
65
+ /** Public, storefront-facing config for a channel (no private secrets). */
66
+ async getPublicConfig(salesChannelId) {
67
+ const [appName, shortName, themeColor, backgroundColor, displayMode, cachingEnabled, pushEnabled, vapidPublicKey, icons,] = await Promise.all([
68
+ this.getString(PWA_SETTING_CODES.APP_NAME, salesChannelId, 'B2B Platform'),
69
+ this.getString(PWA_SETTING_CODES.SHORT_NAME, salesChannelId, 'B2B'),
70
+ this.getString(PWA_SETTING_CODES.THEME_COLOR, salesChannelId, '#1d4ed8'),
71
+ this.getString(PWA_SETTING_CODES.BACKGROUND_COLOR, salesChannelId, '#fafafa'),
72
+ this.getDisplayMode(salesChannelId),
73
+ this.getBool(PWA_SETTING_CODES.CACHING_ENABLED, salesChannelId, false),
74
+ this.getBool(PWA_SETTING_CODES.PUSH_ENABLED, salesChannelId, false),
75
+ this.getString(PWA_SETTING_CODES.VAPID_PUBLIC_KEY, salesChannelId, ''),
76
+ this.resolveIcons(salesChannelId),
77
+ ]);
78
+ return {
79
+ appName,
80
+ shortName,
81
+ themeColor,
82
+ backgroundColor,
83
+ displayMode,
84
+ icons,
85
+ cachingEnabled,
86
+ // Push is only "on" for the client when both enabled AND a public key exists.
87
+ pushEnabled: pushEnabled && vapidPublicKey.length > 0,
88
+ vapidPublicKey: pushEnabled ? vapidPublicKey : '',
89
+ };
90
+ }
91
+ }
92
+ //# sourceMappingURL=pwa-config-resolver.js.map