@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,207 @@
1
+ import { defineModuleManifest, defineModuleSettingsManifest, PWA_PERMISSIONS, PWA_SETTING_CODES, } from '@endora-commerce/contracts';
2
+ /**
3
+ * PWA module — feature 046.
4
+ *
5
+ * Adds Progressive-Web-App capabilities to the storefront and admin: home-screen
6
+ * installability, a Settings-driven per-channel installable identity (icon, name,
7
+ * theme), opt-in static-asset caching, a controlled service-worker update path,
8
+ * and opt-in push notifications behind a provider-agnostic abstraction (default
9
+ * backend: Web-Push/VAPID; FCM/OneSignal are drop-in providers).
10
+ *
11
+ * Configuration lives in the Settings module (group `pwa`) and resolves per Sales
12
+ * Channel via the standard global + override chain (FR-011). VAPID private key and
13
+ * the optional FCM service-account JSON are `secret` value types (encrypted at rest).
14
+ */
15
+ // Every PWA setting is managed exclusively from the dedicated PWA settings
16
+ // page (/settings/pwa). Marking them `hidden: true` (applied below) keeps them
17
+ // out of the generic Settings screen so each value lives in exactly one place.
18
+ const pwaSettingEntries = [
19
+ {
20
+ code: PWA_SETTING_CODES.APP_NAME,
21
+ name: 'App name',
22
+ description: 'Installed-app name shown on the device home screen (manifest `name`).',
23
+ groupCode: 'pwa',
24
+ valueType: 'string',
25
+ defaultValue: 'B2B Platform',
26
+ },
27
+ {
28
+ code: PWA_SETTING_CODES.SHORT_NAME,
29
+ name: 'Short name',
30
+ description: 'Short installed-app name used where space is constrained (manifest `short_name`).',
31
+ groupCode: 'pwa',
32
+ valueType: 'string',
33
+ defaultValue: 'B2B',
34
+ },
35
+ {
36
+ code: PWA_SETTING_CODES.THEME_COLOR,
37
+ name: 'Theme color',
38
+ description: 'Hex theme color applied to the installed-app UI (manifest `theme_color`).',
39
+ groupCode: 'pwa',
40
+ valueType: 'string',
41
+ defaultValue: '#1d4ed8',
42
+ },
43
+ {
44
+ code: PWA_SETTING_CODES.BACKGROUND_COLOR,
45
+ name: 'Background color',
46
+ description: 'Hex background color shown on the splash screen (manifest `background_color`).',
47
+ groupCode: 'pwa',
48
+ valueType: 'string',
49
+ defaultValue: '#fafafa',
50
+ },
51
+ {
52
+ code: PWA_SETTING_CODES.DISPLAY_MODE,
53
+ name: 'Display mode',
54
+ description: "How the installed app launches: 'standalone', 'fullscreen' or 'minimal-ui'.",
55
+ groupCode: 'pwa',
56
+ valueType: 'string',
57
+ defaultValue: 'standalone',
58
+ },
59
+ {
60
+ code: PWA_SETTING_CODES.ICON_ASSET_ID,
61
+ name: 'Icon source asset id',
62
+ description: 'Assets-library id of the uploaded source icon. Empty falls back to bundled placeholder icons.',
63
+ groupCode: 'pwa',
64
+ valueType: 'string',
65
+ defaultValue: '',
66
+ },
67
+ {
68
+ code: PWA_SETTING_CODES.CACHING_ENABLED,
69
+ name: 'Static-asset caching enabled',
70
+ description: 'When on, the storefront service worker caches static assets for faster repeat visits and an offline shell.',
71
+ groupCode: 'pwa',
72
+ valueType: 'boolean',
73
+ defaultValue: false,
74
+ },
75
+ {
76
+ code: PWA_SETTING_CODES.PUSH_ENABLED,
77
+ name: 'Push notifications enabled',
78
+ description: 'When on, the storefront offers a push opt-in and the platform can deliver notifications to subscribed devices.',
79
+ groupCode: 'pwa',
80
+ valueType: 'boolean',
81
+ defaultValue: false,
82
+ },
83
+ {
84
+ code: PWA_SETTING_CODES.VAPID_PUBLIC_KEY,
85
+ name: 'VAPID public key',
86
+ description: 'Public VAPID key served to the storefront subscribe flow. Generated together with the private key.',
87
+ groupCode: 'pwa',
88
+ valueType: 'string',
89
+ defaultValue: '',
90
+ },
91
+ {
92
+ code: PWA_SETTING_CODES.VAPID_PRIVATE_KEY,
93
+ name: 'VAPID private key',
94
+ description: 'Private VAPID key used to sign Web-Push messages. Write-only: encrypted at rest and never returned after saving.',
95
+ groupCode: 'pwa',
96
+ valueType: 'secret',
97
+ defaultValue: '',
98
+ },
99
+ {
100
+ code: PWA_SETTING_CODES.FCM_SERVICE_ACCOUNT,
101
+ name: 'FCM service account (JSON)',
102
+ description: 'Optional Firebase service-account JSON for the token-based FCM provider. Write-only: encrypted at rest.',
103
+ groupCode: 'pwa',
104
+ valueType: 'secret',
105
+ defaultValue: '',
106
+ },
107
+ ];
108
+ const settings = defineModuleSettingsManifest({
109
+ moduleCode: 'pwa',
110
+ groups: [{ code: 'pwa', name: 'Progressive Web App' }],
111
+ settings: [
112
+ ...pwaSettingEntries.map((entry) => ({ ...entry, hidden: true })),
113
+ {
114
+ // Feature 073 — the operator's activation control. Deliberately NOT
115
+ // hidden: every other setting here is internal plumbing the PWA screen
116
+ // manages, while this one is the operator's own switch and belongs on
117
+ // `/platform/modules`.
118
+ code: 'pwa.enabled',
119
+ name: 'Progressive Web App enabled',
120
+ description: 'Switches the installable-app manifest, the push-subscription registry and push delivery on or off. Nothing is dropped: subscriptions and icons stay in the database, and pushes resume for the same subscribers when you switch it back on.',
121
+ groupCode: 'pwa',
122
+ valueType: 'boolean',
123
+ defaultValue: true,
124
+ },
125
+ ],
126
+ });
127
+ /** Settings-only export consumed by the boot-time ManifestReconciler list. */
128
+ export const pwaSettingsManifest = settings;
129
+ export const manifest = defineModuleManifest({
130
+ id: 'pwa',
131
+ name: 'Progressive Web App',
132
+ description: 'Installability, static-asset caching, controlled service-worker updates, and provider-agnostic push notifications for the storefront and admin.',
133
+ version: '1.0.0',
134
+ /**
135
+ * What this module needs from the environment (`specs/117-instance-bring-up/`
136
+ * FR-002). Only what it **owns**: its reads of platform-owned names are
137
+ * satisfied by `packages/platform/src/env/index.ts`.
138
+ *
139
+ * Why each of these is not a Setting is its entry in
140
+ * `backend/scripts/ledgers/module-environment-inputs/pwa.ts`.
141
+ */
142
+ env: [
143
+ {
144
+ name: 'PWA_VAPID_SUBJECT',
145
+ describes: {
146
+ en: 'The contact address push services are given for this shop, so a provider can reach somebody about its notifications.',
147
+ pl: 'Adres kontaktowy przekazywany usługom push dla tego sklepu, aby dostawca miał się z kim skontaktować w sprawie powiadomień.',
148
+ },
149
+ requirement: {
150
+ kind: 'optional',
151
+ without: {
152
+ en: 'Push services are given the platform’s placeholder contact rather than this shop’s, so a provider with a delivery problem has nobody to write to.',
153
+ pl: 'Usługi push dostają zastępczy kontakt platformy zamiast kontaktu tego sklepu, więc dostawca mający problem z doręczeniem nie ma do kogo napisać.',
154
+ },
155
+ },
156
+ secret: false,
157
+ generable: false,
158
+ owner: { kind: 'module', moduleId: 'pwa' },
159
+ consumers: ['backend'],
160
+ addressOf: null,
161
+ },
162
+ ],
163
+ // `auth` owns the `requireAdmin` port the admin routes are gated by; feature
164
+ // 072 made it a container resolution rather than a constructor argument.
165
+ //
166
+ // `customer_accounts` and `organizations` arrived with feature 075 Phase C:
167
+ // the push Rule Builder's three target pickers and the rule-audience
168
+ // expansion read rows those modules own, and they used to read them off the
169
+ // tables directly. Both are **binding**: a picker that silently lists nothing
170
+ // and an audience that silently resolves to nobody both read as legitimate
171
+ // answers, so the seam fails closed. `price_lists` was the third, for
172
+ // `customerGroupReadPort`; feature 076 (D-79) moved that port to
173
+ // `customer_accounts`, which this module already declares, and the port check
174
+ // confirms nothing else here resolves out of `price_lists`.
175
+ //
176
+ // `orders` arrived with `specs/110-instance-repository/` T118c: the FR-024
177
+ // order-status auto-trigger reads the order it is announcing over
178
+ // `orderReadPort`, which a composition root used to read on this module's
179
+ // behalf inside `pwaBridge`. It is **binding**, and it costs no operator a
180
+ // control, derived rather than waived: `orders` is `nonDeactivatable`.
181
+ // `assets_library` and `sales_channels` are `nonDeactivatable` as well, and
182
+ // both were already declared, so the two ports that drained beside it owe no
183
+ // `refuses-without` sentence either. The kernel's
184
+ // `salesChannelResolutionPort` is a platform name and puts nothing here at
185
+ // all.
186
+ dependencies: [
187
+ '_i18n',
188
+ '_lifecycle',
189
+ 'assets_library',
190
+ 'orders',
191
+ 'sales_channels',
192
+ 'settings',
193
+ 'auth',
194
+ 'customer_accounts',
195
+ 'organizations',
196
+ ],
197
+ settings,
198
+ i18n: { bundlesDir: 'i18n' },
199
+ docs: { dir: 'docs' },
200
+ permissions: [
201
+ { code: PWA_PERMISSIONS.READ, label: 'View PWA settings' },
202
+ { code: PWA_PERMISSIONS.WRITE, label: 'Configure PWA' },
203
+ { code: PWA_PERMISSIONS.SEND_PUSH, label: 'Send push notifications' },
204
+ ],
205
+ activation: { settingCode: 'pwa.enabled', default: true },
206
+ });
207
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * PWA module init (feature 046).
4
+ *
5
+ * Four tables owned by the `pwa` module:
6
+ * - push_subscriptions — one subscribed device (Web-Push), unique endpoint
7
+ * - push_messages — a notification (admin- or event-triggered)
8
+ * - push_message_deliveries — (message × subscription) delivery, idempotency unit
9
+ * - pwa_icon_renditions — derived icon sizes per channel (global = null channel)
10
+ *
11
+ * PWA identity/toggles are NOT a table — they live in the Settings module
12
+ * (group `pwa`, seeded by the manifest reconciler).
13
+ */
14
+ export declare class Migration20260625T144228PwaInit extends Migration {
15
+ up(): Promise<void>;
16
+ down(): Promise<void>;
17
+ }
18
+ //# sourceMappingURL=20260625T144228_pwa_init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260625T144228_pwa_init.d.ts","sourceRoot":"","sources":["../../src/migrations/20260625T144228_pwa_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;GAWG;AACH,qBAAa,+BAAgC,SAAQ,SAAS;IAC7C,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IA6GnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAMrC"}
@@ -0,0 +1,105 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * PWA module init (feature 046).
4
+ *
5
+ * Four tables owned by the `pwa` module:
6
+ * - push_subscriptions — one subscribed device (Web-Push), unique endpoint
7
+ * - push_messages — a notification (admin- or event-triggered)
8
+ * - push_message_deliveries — (message × subscription) delivery, idempotency unit
9
+ * - pwa_icon_renditions — derived icon sizes per channel (global = null channel)
10
+ *
11
+ * PWA identity/toggles are NOT a table — they live in the Settings module
12
+ * (group `pwa`, seeded by the manifest reconciler).
13
+ */
14
+ export class Migration20260625T144228PwaInit extends Migration {
15
+ async up() {
16
+ this.addSql(`
17
+ create table "push_subscriptions" (
18
+ "id" uuid not null,
19
+ "sales_channel_id" uuid not null,
20
+ "customer_account_id" uuid null,
21
+ "endpoint" text not null,
22
+ "p256dh" text not null,
23
+ "auth" text not null,
24
+ "provider" varchar(32) not null default 'web_push',
25
+ "status" varchar(16) not null default 'active',
26
+ "user_agent" text null,
27
+ "created_at" timestamptz not null,
28
+ "last_seen_at" timestamptz not null,
29
+ constraint "push_subscriptions_pkey" primary key ("id"),
30
+ constraint "push_subscriptions_status_check" check ("status" in ('active', 'invalid'))
31
+ );
32
+ `);
33
+ this.addSql(`alter table "push_subscriptions" add constraint "push_subscriptions_endpoint_unique" unique ("endpoint");`);
34
+ this.addSql(`create index "push_subscriptions_channel_status_idx" on "push_subscriptions" ("sales_channel_id", "status");`);
35
+ this.addSql(`create index "push_subscriptions_customer_idx" on "push_subscriptions" ("customer_account_id") where "customer_account_id" is not null;`);
36
+ this.addSql(`
37
+ create table "push_messages" (
38
+ "id" uuid not null,
39
+ "sales_channel_id" uuid not null,
40
+ "title" varchar(120) not null,
41
+ "body" varchar(500) not null,
42
+ "icon_url" text null,
43
+ "url" text null,
44
+ "audience" jsonb not null,
45
+ "trigger" varchar(32) not null,
46
+ "source_event_id" varchar(128) null,
47
+ "status" varchar(16) not null default 'queued',
48
+ "sent_count" int not null default 0,
49
+ "failed_count" int not null default 0,
50
+ "created_by_admin_user_id" uuid null,
51
+ "created_at" timestamptz not null,
52
+ "sent_at" timestamptz null,
53
+ constraint "push_messages_pkey" primary key ("id"),
54
+ constraint "push_messages_trigger_check" check ("trigger" in ('admin', 'order_status', 'quote_request')),
55
+ constraint "push_messages_status_check" check ("status" in ('queued', 'sending', 'sent', 'failed'))
56
+ );
57
+ `);
58
+ this.addSql(`create index "push_messages_channel_created_idx" on "push_messages" ("sales_channel_id", "created_at" desc);`);
59
+ this.addSql(`create unique index "push_messages_trigger_event_uniq" on "push_messages" ("trigger", "source_event_id") where "source_event_id" is not null;`);
60
+ this.addSql(`
61
+ create table "push_message_deliveries" (
62
+ "id" uuid not null,
63
+ "message_id" uuid not null,
64
+ "subscription_id" uuid not null,
65
+ "status" varchar(16) not null default 'pending',
66
+ "attempts" int not null default 0,
67
+ "last_error" text null,
68
+ "attempted_at" timestamptz null,
69
+ "created_at" timestamptz not null,
70
+ constraint "push_message_deliveries_pkey" primary key ("id"),
71
+ constraint "push_message_deliveries_status_check" check ("status" in ('pending', 'sent', 'failed', 'pruned'))
72
+ );
73
+ `);
74
+ this.addSql(`alter table "push_message_deliveries" add constraint "push_message_deliveries_message_id_foreign" foreign key ("message_id") references "push_messages" ("id") on update cascade on delete cascade;`);
75
+ this.addSql(`alter table "push_message_deliveries" add constraint "push_message_deliveries_subscription_id_foreign" foreign key ("subscription_id") references "push_subscriptions" ("id") on update cascade on delete cascade;`);
76
+ this.addSql(`alter table "push_message_deliveries" add constraint "push_message_deliveries_msg_sub_unique" unique ("message_id", "subscription_id");`);
77
+ this.addSql(`create index "push_message_deliveries_status_idx" on "push_message_deliveries" ("status");`);
78
+ this.addSql(`
79
+ create table "pwa_icon_renditions" (
80
+ "id" uuid not null,
81
+ "sales_channel_id" uuid null,
82
+ "source_asset_id" uuid not null,
83
+ "size" int not null,
84
+ "purpose" varchar(16) not null,
85
+ "asset_id" uuid not null,
86
+ "content_hash" varchar(64) not null,
87
+ "created_at" timestamptz not null,
88
+ constraint "pwa_icon_renditions_pkey" primary key ("id"),
89
+ constraint "pwa_icon_renditions_purpose_check" check ("purpose" in ('any', 'maskable'))
90
+ );
91
+ `);
92
+ // One rendition per (size, purpose) per scope. Two partial unique indexes so
93
+ // the rule holds for both per-channel rows and the single global (null-channel)
94
+ // row without relying on PG15-only NULLS NOT DISTINCT.
95
+ this.addSql(`create unique index "pwa_icon_renditions_channel_size_purpose_uniq" on "pwa_icon_renditions" ("sales_channel_id", "size", "purpose") where "sales_channel_id" is not null;`);
96
+ this.addSql(`create unique index "pwa_icon_renditions_global_size_purpose_uniq" on "pwa_icon_renditions" ("size", "purpose") where "sales_channel_id" is null;`);
97
+ }
98
+ async down() {
99
+ this.addSql(`drop table if exists "pwa_icon_renditions" cascade;`);
100
+ this.addSql(`drop table if exists "push_message_deliveries" cascade;`);
101
+ this.addSql(`drop table if exists "push_messages" cascade;`);
102
+ this.addSql(`drop table if exists "push_subscriptions" cascade;`);
103
+ }
104
+ }
105
+ //# sourceMappingURL=20260625T144228_pwa_init.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260625T144228_pwa_init.js","sourceRoot":"","sources":["../../src/migrations/20260625T144228_pwa_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,+BAAgC,SAAQ,SAAS;IACnD,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;KAgBX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,2GAA2G,CAC5G,CAAC;QACF,IAAI,CAAC,MAAM,CACT,8GAA8G,CAC/G,CAAC;QACF,IAAI,CAAC,MAAM,CACT,yIAAyI,CAC1I,CAAC;QAEF,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;KAqBX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,8GAA8G,CAC/G,CAAC;QACF,IAAI,CAAC,MAAM,CACT,+IAA+I,CAChJ,CAAC;QAEF,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;KAaX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,qMAAqM,CACtM,CAAC;QACF,IAAI,CAAC,MAAM,CACT,oNAAoN,CACrN,CAAC;QACF,IAAI,CAAC,MAAM,CACT,yIAAyI,CAC1I,CAAC;QACF,IAAI,CAAC,MAAM,CACT,4FAA4F,CAC7F,CAAC;QAEF,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;KAaX,CAAC,CAAC;QACH,6EAA6E;QAC7E,gFAAgF;QAChF,uDAAuD;QACvD,IAAI,CAAC,MAAM,CACT,4KAA4K,CAC7K,CAAC;QACF,IAAI,CAAC,MAAM,CACT,mJAAmJ,CACpJ,CAAC;IACJ,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,qDAAqD,CAAC,CAAC;QACnE,IAAI,CAAC,MAAM,CAAC,yDAAyD,CAAC,CAAC;QACvE,IAAI,CAAC,MAAM,CAAC,+CAA+C,CAAC,CAAC;QAC7D,IAAI,CAAC,MAAM,CAAC,oDAAoD,CAAC,CAAC;IACpE,CAAC;CACF"}
@@ -0,0 +1,102 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * D-187 — `push_subscriptions` gains its organisation, and a row that names a
4
+ * customer account carries one.
5
+ *
6
+ * Feature 087 Group B, class 2 of 4 (`specs/087-tenant-scope-enforcement/`).
7
+ * `PushSubscription` is `@CustomerScoped`, and until this migration the
8
+ * `allowed-set` arm of `customerFilterCond` had no column to grant on: a sales
9
+ * representative assigned to the buyer's own organisation was shown **none** of
10
+ * their subscribed devices and told so, through the
11
+ * `ORGANIZATION_ATTRIBUTION_PENDING` notice the refusing arm records. The
12
+ * column is what turns that into an answer.
13
+ *
14
+ * ## The column and the read arrive together, on purpose
15
+ *
16
+ * `customerOrganizationColumn` asks the ORM's own metadata whether the filtered
17
+ * entity carries `organizationId`, **per query**, so the entity property in
18
+ * this same merge request is what switches the grant on — there is no third
19
+ * artefact, no flag and no staging. At the same instant the notice retires
20
+ * itself: it is keyed on the column's *absence*.
21
+ *
22
+ * That pairing is why this migration is not "column and backfill". From the
23
+ * moment the grant is live, a row inserted without an organisation is invisible
24
+ * to the representative who serves that organisation, on a screen that has just
25
+ * stopped explaining itself — and MikroORM applies no filter to `INSERT`
26
+ * (`r1-spike.md` §4, measured), so the filter cannot refuse it. The `CHECK`
27
+ * below is the only refusal an `INSERT` has.
28
+ *
29
+ * ## An implication, not an equivalence
30
+ *
31
+ * `customer_account_id is null or organization_id is not null` is FR-010 and
32
+ * FR-011 together and nothing wider: an **owned** row has an organisation, an
33
+ * **ownerless** row need not. This table is ownerless by construction for every
34
+ * device that subscribes before anybody signs in (FR-023 — `customer_account_id`
35
+ * has been nullable since `20260625T144228_pwa_init.ts`), and who such a device
36
+ * belongs to is R-6's open question. The equivalence would answer it in the
37
+ * schema, which D-187 declines to do.
38
+ *
39
+ * **What that deliberately leaves open, and where it is held instead.** The
40
+ * equivalence would also close the opposite shape — an **ownerless** row still
41
+ * carrying an organisation — and this table is the one in Group B that can
42
+ * produce it. Its write is an upsert on `endpoint`, so re-subscribing a
43
+ * previously owned device anonymously clears the account, and an implication
44
+ * says nothing about the organisation left behind. That row would be visible to
45
+ * a representative of an organisation it no longer belongs to. It is closed at
46
+ * the write instead of in the schema: `PushSubscriptionService` writes both
47
+ * columns from one resolved owner value, so there is no branch in which one
48
+ * moves without the other, and `admin-and-storefront.contract.test.ts` asserts
49
+ * the de-association end to end. A constraint here would have bought the same
50
+ * guarantee at the price of deciding R-6.
51
+ *
52
+ * ## No foreign key
53
+ *
54
+ * Matching `Cart` and `Comparison`, neither of which carries one. D-187
55
+ * withdraws `r1-spike.md` §8's `on delete set null` recommendation as
56
+ * contradicting FR-011: it produces a row that names an account and no
57
+ * organisation, which is precisely the state this migration makes unreachable,
58
+ * and under the constraint below it would abort an unrelated organisation
59
+ * delete with a message about a table the operator was not touching. If one is
60
+ * ever taken here it must be `restrict`, and it is not this migration's
61
+ * decision.
62
+ *
63
+ * ## Derive, then count, then refuse — never delete
64
+ *
65
+ * The derivation is the owning account's own organisation. **On this table the
66
+ * refusal is a real branch, not a formality**, and that is why it is written
67
+ * with as much care as the derivation: unlike `comparisons`, `push_subscriptions`
68
+ * has **no foreign key** on `customer_account_id` (the init migration declares
69
+ * the column `uuid null` and constrains nothing), so a row naming an account
70
+ * that is gone is representable here.
71
+ *
72
+ * It is nevertheless empty today, and the reason is worth knowing rather than
73
+ * hoping: a customer account is never hard-deleted in this tree.
74
+ * `customer-account-lifecycle-ports.ts` soft-deletes, restores and
75
+ * **anonymises** — the last one rewrites the e-mail and the name and keeps the
76
+ * row — and every one of those still satisfies `organization_id NOT NULL`
77
+ * (D-178), so it still derives. Change any of that and the count below is the
78
+ * only thing that would notice.
79
+ *
80
+ * It raises with the count and up to twenty ids and deletes nothing (D-184), in
81
+ * the shape `20260825T141659_customer_accounts_organization_required.ts`
82
+ * established and `20260830T112911_carts_organization_attribution_check.ts`
83
+ * repeated.
84
+ *
85
+ * ## Nothing to declare in the manifest
86
+ *
87
+ * `pwa` already declares both `customer_accounts` and `organizations` in its
88
+ * manifest `dependencies`, so the table this reads is created before this runs.
89
+ * No foreign key is added, so `fk-dependency-drift` has nothing to say either,
90
+ * and a migration naming another module's table is outside
91
+ * `check:module-boundary`'s population by that check's own rule.
92
+ *
93
+ * The standing guard is
94
+ * `backend/test/integration/tenancy/customer-scoped-organization-completeness.test.ts`,
95
+ * which derives its population from the ORM's metadata rather than from a list
96
+ * — so this class is covered by gaining the column, with no edit to that file.
97
+ */
98
+ export declare class Migration20260830T172022PwaOrganizationAttribution extends Migration {
99
+ up(): Promise<void>;
100
+ down(): Promise<void>;
101
+ }
102
+ //# sourceMappingURL=20260830T172022_pwa_organization_attribution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260830T172022_pwa_organization_attribution.d.ts","sourceRoot":"","sources":["../../src/migrations/20260830T172022_pwa_organization_attribution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+FG;AACH,qBAAa,kDAAmD,SAAQ,SAAS;IAChE,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAiEnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAWrC"}
@@ -0,0 +1,169 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * D-187 — `push_subscriptions` gains its organisation, and a row that names a
4
+ * customer account carries one.
5
+ *
6
+ * Feature 087 Group B, class 2 of 4 (`specs/087-tenant-scope-enforcement/`).
7
+ * `PushSubscription` is `@CustomerScoped`, and until this migration the
8
+ * `allowed-set` arm of `customerFilterCond` had no column to grant on: a sales
9
+ * representative assigned to the buyer's own organisation was shown **none** of
10
+ * their subscribed devices and told so, through the
11
+ * `ORGANIZATION_ATTRIBUTION_PENDING` notice the refusing arm records. The
12
+ * column is what turns that into an answer.
13
+ *
14
+ * ## The column and the read arrive together, on purpose
15
+ *
16
+ * `customerOrganizationColumn` asks the ORM's own metadata whether the filtered
17
+ * entity carries `organizationId`, **per query**, so the entity property in
18
+ * this same merge request is what switches the grant on — there is no third
19
+ * artefact, no flag and no staging. At the same instant the notice retires
20
+ * itself: it is keyed on the column's *absence*.
21
+ *
22
+ * That pairing is why this migration is not "column and backfill". From the
23
+ * moment the grant is live, a row inserted without an organisation is invisible
24
+ * to the representative who serves that organisation, on a screen that has just
25
+ * stopped explaining itself — and MikroORM applies no filter to `INSERT`
26
+ * (`r1-spike.md` §4, measured), so the filter cannot refuse it. The `CHECK`
27
+ * below is the only refusal an `INSERT` has.
28
+ *
29
+ * ## An implication, not an equivalence
30
+ *
31
+ * `customer_account_id is null or organization_id is not null` is FR-010 and
32
+ * FR-011 together and nothing wider: an **owned** row has an organisation, an
33
+ * **ownerless** row need not. This table is ownerless by construction for every
34
+ * device that subscribes before anybody signs in (FR-023 — `customer_account_id`
35
+ * has been nullable since `20260625T144228_pwa_init.ts`), and who such a device
36
+ * belongs to is R-6's open question. The equivalence would answer it in the
37
+ * schema, which D-187 declines to do.
38
+ *
39
+ * **What that deliberately leaves open, and where it is held instead.** The
40
+ * equivalence would also close the opposite shape — an **ownerless** row still
41
+ * carrying an organisation — and this table is the one in Group B that can
42
+ * produce it. Its write is an upsert on `endpoint`, so re-subscribing a
43
+ * previously owned device anonymously clears the account, and an implication
44
+ * says nothing about the organisation left behind. That row would be visible to
45
+ * a representative of an organisation it no longer belongs to. It is closed at
46
+ * the write instead of in the schema: `PushSubscriptionService` writes both
47
+ * columns from one resolved owner value, so there is no branch in which one
48
+ * moves without the other, and `admin-and-storefront.contract.test.ts` asserts
49
+ * the de-association end to end. A constraint here would have bought the same
50
+ * guarantee at the price of deciding R-6.
51
+ *
52
+ * ## No foreign key
53
+ *
54
+ * Matching `Cart` and `Comparison`, neither of which carries one. D-187
55
+ * withdraws `r1-spike.md` §8's `on delete set null` recommendation as
56
+ * contradicting FR-011: it produces a row that names an account and no
57
+ * organisation, which is precisely the state this migration makes unreachable,
58
+ * and under the constraint below it would abort an unrelated organisation
59
+ * delete with a message about a table the operator was not touching. If one is
60
+ * ever taken here it must be `restrict`, and it is not this migration's
61
+ * decision.
62
+ *
63
+ * ## Derive, then count, then refuse — never delete
64
+ *
65
+ * The derivation is the owning account's own organisation. **On this table the
66
+ * refusal is a real branch, not a formality**, and that is why it is written
67
+ * with as much care as the derivation: unlike `comparisons`, `push_subscriptions`
68
+ * has **no foreign key** on `customer_account_id` (the init migration declares
69
+ * the column `uuid null` and constrains nothing), so a row naming an account
70
+ * that is gone is representable here.
71
+ *
72
+ * It is nevertheless empty today, and the reason is worth knowing rather than
73
+ * hoping: a customer account is never hard-deleted in this tree.
74
+ * `customer-account-lifecycle-ports.ts` soft-deletes, restores and
75
+ * **anonymises** — the last one rewrites the e-mail and the name and keeps the
76
+ * row — and every one of those still satisfies `organization_id NOT NULL`
77
+ * (D-178), so it still derives. Change any of that and the count below is the
78
+ * only thing that would notice.
79
+ *
80
+ * It raises with the count and up to twenty ids and deletes nothing (D-184), in
81
+ * the shape `20260825T141659_customer_accounts_organization_required.ts`
82
+ * established and `20260830T112911_carts_organization_attribution_check.ts`
83
+ * repeated.
84
+ *
85
+ * ## Nothing to declare in the manifest
86
+ *
87
+ * `pwa` already declares both `customer_accounts` and `organizations` in its
88
+ * manifest `dependencies`, so the table this reads is created before this runs.
89
+ * No foreign key is added, so `fk-dependency-drift` has nothing to say either,
90
+ * and a migration naming another module's table is outside
91
+ * `check:module-boundary`'s population by that check's own rule.
92
+ *
93
+ * The standing guard is
94
+ * `backend/test/integration/tenancy/customer-scoped-organization-completeness.test.ts`,
95
+ * which derives its population from the ORM's metadata rather than from a list
96
+ * — so this class is covered by gaining the column, with no edit to that file.
97
+ */
98
+ export class Migration20260830T172022PwaOrganizationAttribution extends Migration {
99
+ async up() {
100
+ // 1. The column. Nullable, because an anonymous device legitimately has
101
+ // none (FR-011/FR-023) and this table is mostly anonymous in practice.
102
+ this.addSql(`alter table "push_subscriptions" add column "organization_id" uuid null;`);
103
+ // 2. Derive the organisation from the account that owns the subscription.
104
+ // Every account carries one since D-178, including a soft-deleted or
105
+ // anonymised one, so this is total for every row whose account is still
106
+ // there. Step 3 is about the rows for which it is not.
107
+ this.addSql(`
108
+ update "push_subscriptions" ps
109
+ set "organization_id" = ca."organization_id"
110
+ from "customer_accounts" ca
111
+ where ca."id" = ps."customer_account_id"
112
+ and ps."organization_id" is null;
113
+ `);
114
+ // 3. Count what is left and refuse. This table has no foreign key on
115
+ // `customer_account_id`, so a dangling account id is representable and
116
+ // this branch is real. It deletes nothing (D-184): a subscribed device
117
+ // is a customer's, and a migration is not the place to decide that one
118
+ // of them should stop receiving notifications.
119
+ this.addSql(`
120
+ do $$
121
+ declare
122
+ remaining bigint;
123
+ sample text;
124
+ begin
125
+ select count(*) into remaining
126
+ from "push_subscriptions"
127
+ where "customer_account_id" is not null
128
+ and "organization_id" is null;
129
+ if remaining > 0 then
130
+ select string_agg(id::text, ', ') into sample from (
131
+ select "id" from "push_subscriptions"
132
+ where "customer_account_id" is not null
133
+ and "organization_id" is null
134
+ order by "id" limit 20
135
+ ) s;
136
+ raise exception
137
+ 'D-187: % push_subscriptions row(s) still name a customer account with organization_id IS NULL after derivation. First ids: %. Every customer account has an organization (D-178) and none is ever hard-deleted, so these rows name an account that is gone. Do not delete them - find out how they got here.',
138
+ remaining, sample;
139
+ end if;
140
+ end $$;
141
+ `);
142
+ // 4. The constraint.
143
+ this.addSql(`
144
+ alter table "push_subscriptions"
145
+ add constraint "push_subscriptions_organization_attribution_chk"
146
+ check ("customer_account_id" is null or "organization_id" is not null);
147
+ `);
148
+ // 5. The index the entity property declares. Partial, matching this table's
149
+ // own owner index — `push_subscriptions_customer_idx` is
150
+ // `("customer_account_id") where "customer_account_id" is not null` —
151
+ // for the same reason: the column is null on every anonymous device, and
152
+ // the reader is an organisation-scoped list.
153
+ this.addSql(`
154
+ create index "push_subscriptions_organization_idx"
155
+ on "push_subscriptions" ("organization_id")
156
+ where "organization_id" is not null;
157
+ `);
158
+ }
159
+ async down() {
160
+ // The constraint, the index and the column. The organisations step 2
161
+ // derived go with the column they were written into — there is nothing to
162
+ // preserve, because each of them was already implied by the account its row
163
+ // names.
164
+ this.addSql(`alter table "push_subscriptions" drop constraint if exists "push_subscriptions_organization_attribution_chk";`);
165
+ this.addSql(`drop index if exists "push_subscriptions_organization_idx";`);
166
+ this.addSql(`alter table "push_subscriptions" drop column if exists "organization_id";`);
167
+ }
168
+ }
169
+ //# sourceMappingURL=20260830T172022_pwa_organization_attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260830T172022_pwa_organization_attribution.js","sourceRoot":"","sources":["../../src/migrations/20260830T172022_pwa_organization_attribution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+FG;AACH,MAAM,OAAO,kDAAmD,SAAQ,SAAS;IACtE,KAAK,CAAC,EAAE;QACf,wEAAwE;QACxE,0EAA0E;QAC1E,IAAI,CAAC,MAAM,CAAC,0EAA0E,CAAC,CAAC;QAExF,0EAA0E;QAC1E,wEAAwE;QACxE,2EAA2E;QAC3E,0DAA0D;QAC1D,IAAI,CAAC,MAAM,CAAC;;;;;;KAMX,CAAC,CAAC;QAEH,qEAAqE;QACrE,0EAA0E;QAC1E,0EAA0E;QAC1E,0EAA0E;QAC1E,kDAAkD;QAClD,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;KAsBX,CAAC,CAAC;QAEH,qBAAqB;QACrB,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;QAEH,4EAA4E;QAC5E,4DAA4D;QAC5D,yEAAyE;QACzE,4EAA4E;QAC5E,gDAAgD;QAChD,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;IACL,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,qEAAqE;QACrE,0EAA0E;QAC1E,4EAA4E;QAC5E,SAAS;QACT,IAAI,CAAC,MAAM,CACT,+GAA+G,CAChH,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,6DAA6D,CAAC,CAAC;QAC3E,IAAI,CAAC,MAAM,CAAC,2EAA2E,CAAC,CAAC;IAC3F,CAAC;CACF"}