@open-mercato/core 0.6.8-develop.6983.1.e4f794099e → 0.6.8-develop.6985.1.fb93574faa
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/notification/index.js +6 -0
- package/dist/generated/entities/notification/index.js.map +2 -2
- package/dist/generated/entities/notification_preference/index.js +19 -0
- package/dist/generated/entities/notification_preference/index.js.map +7 -0
- package/dist/generated/entities/notification_type/index.js +21 -0
- package/dist/generated/entities/notification_type/index.js.map +7 -0
- package/dist/generated/entities/notification_type_override/index.js +17 -0
- package/dist/generated/entities/notification_type_override/index.js.map +7 -0
- package/dist/generated/entities/push_notification_delivery/index.js +41 -0
- package/dist/generated/entities/push_notification_delivery/index.js.map +7 -0
- package/dist/generated/entities/user_device/index.js +35 -0
- package/dist/generated/entities/user_device/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +12 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +72 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/api.js +2 -1
- package/dist/helpers/integration/api.js.map +2 -2
- package/dist/helpers/integration/appRoot.js +9 -0
- package/dist/helpers/integration/appRoot.js.map +7 -0
- package/dist/helpers/integration/dbFixtures.js +1 -4
- package/dist/helpers/integration/dbFixtures.js.map +2 -2
- package/dist/helpers/integration/pushFake.js +126 -0
- package/dist/helpers/integration/pushFake.js.map +7 -0
- package/dist/modules/auth/notifications.js +16 -0
- package/dist/modules/auth/notifications.js.map +2 -2
- package/dist/modules/business_rules/notifications.js +2 -0
- package/dist/modules/business_rules/notifications.js.map +2 -2
- package/dist/modules/catalog/notifications.js +2 -0
- package/dist/modules/catalog/notifications.js.map +2 -2
- package/dist/modules/communication_channels/acl.js +9 -0
- package/dist/modules/communication_channels/acl.js.map +2 -2
- package/dist/modules/communication_channels/api/delete/admin/channels/[id]/route.js +133 -0
- package/dist/modules/communication_channels/api/delete/admin/channels/[id]/route.js.map +7 -0
- package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js +6 -2
- package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js.map +2 -2
- package/dist/modules/communication_channels/api/get/channels/[id]/route.js +6 -2
- package/dist/modules/communication_channels/api/get/channels/[id]/route.js.map +2 -2
- package/dist/modules/communication_channels/api/get/channels/route.js +2 -1
- package/dist/modules/communication_channels/api/get/channels/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +10 -5
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +29 -1
- package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +157 -0
- package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +7 -0
- package/dist/modules/communication_channels/backend/communication_channels/channels/page.js +8 -1
- package/dist/modules/communication_channels/backend/communication_channels/channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/admin-delete-channel.js +141 -0
- package/dist/modules/communication_channels/commands/admin-delete-channel.js.map +7 -0
- package/dist/modules/communication_channels/commands/connect-credential-channel.js +25 -8
- package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js +2 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
- package/dist/modules/communication_channels/commands/push-register.js +1 -1
- package/dist/modules/communication_channels/commands/push-register.js.map +2 -2
- package/dist/modules/communication_channels/commands/push-unregister.js +4 -3
- package/dist/modules/communication_channels/commands/push-unregister.js.map +2 -2
- package/dist/modules/communication_channels/data/entities.js +4 -0
- package/dist/modules/communication_channels/data/entities.js.map +2 -2
- package/dist/modules/communication_channels/data/validators.js +2 -1
- package/dist/modules/communication_channels/data/validators.js.map +2 -2
- package/dist/modules/communication_channels/lib/access-control.js +11 -1
- package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
- package/dist/modules/communication_channels/lib/connect-channel.js +20 -12
- package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
- package/dist/modules/communication_channels/lib/push-adapter.js +68 -0
- package/dist/modules/communication_channels/lib/push-adapter.js.map +7 -0
- package/dist/modules/communication_channels/lib/push-capabilities.js +31 -0
- package/dist/modules/communication_channels/lib/push-capabilities.js.map +7 -0
- package/dist/modules/communication_channels/lib/push-connect-error.js +20 -0
- package/dist/modules/communication_channels/lib/push-connect-error.js.map +7 -0
- package/dist/modules/communication_channels/lib/push-credential-errors.js +41 -0
- package/dist/modules/communication_channels/lib/push-credential-errors.js.map +7 -0
- package/dist/modules/communication_channels/lib/push-envelope.js +46 -0
- package/dist/modules/communication_channels/lib/push-envelope.js.map +7 -0
- package/dist/modules/communication_channels/lib/refcounted-client-cache.js +64 -0
- package/dist/modules/communication_channels/lib/refcounted-client-cache.js.map +7 -0
- package/dist/modules/communication_channels/migrations/Migration20260703123630_communication_channels.js +13 -0
- package/dist/modules/communication_channels/migrations/Migration20260703123630_communication_channels.js.map +7 -0
- package/dist/modules/communication_channels/notifications.js +4 -0
- package/dist/modules/communication_channels/notifications.js.map +2 -2
- package/dist/modules/communication_channels/setup.js +2 -0
- package/dist/modules/communication_channels/setup.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +21 -0
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/customer_accounts/notifications.js +12 -0
- package/dist/modules/customer_accounts/notifications.js.map +2 -2
- package/dist/modules/customers/notifications.js +4 -0
- package/dist/modules/customers/notifications.js.map +2 -2
- package/dist/modules/devices/acl.js +11 -0
- package/dist/modules/devices/acl.js.map +7 -0
- package/dist/modules/devices/api/[id]/route.js +167 -0
- package/dist/modules/devices/api/[id]/route.js.map +7 -0
- package/dist/modules/devices/api/admin/devices/[id]/route.js +219 -0
- package/dist/modules/devices/api/admin/devices/[id]/route.js.map +7 -0
- package/dist/modules/devices/api/admin/devices/route.js +140 -0
- package/dist/modules/devices/api/admin/devices/route.js.map +7 -0
- package/dist/modules/devices/api/auth.js +9 -0
- package/dist/modules/devices/api/auth.js.map +7 -0
- package/dist/modules/devices/api/deviceList.js +53 -0
- package/dist/modules/devices/api/deviceList.js.map +7 -0
- package/dist/modules/devices/api/deviceOps.js +118 -0
- package/dist/modules/devices/api/deviceOps.js.map +7 -0
- package/dist/modules/devices/api/openapi.js +19 -0
- package/dist/modules/devices/api/openapi.js.map +7 -0
- package/dist/modules/devices/api/route.js +143 -0
- package/dist/modules/devices/api/route.js.map +7 -0
- package/dist/modules/devices/backend/devices/[id]/page.js +135 -0
- package/dist/modules/devices/backend/devices/[id]/page.js.map +7 -0
- package/dist/modules/devices/backend/devices/[id]/page.meta.js +16 -0
- package/dist/modules/devices/backend/devices/[id]/page.meta.js.map +7 -0
- package/dist/modules/devices/backend/devices/create/page.js +68 -0
- package/dist/modules/devices/backend/devices/create/page.js.map +7 -0
- package/dist/modules/devices/backend/devices/create/page.meta.js +16 -0
- package/dist/modules/devices/backend/devices/create/page.meta.js.map +7 -0
- package/dist/modules/devices/backend/devices/page.js +235 -0
- package/dist/modules/devices/backend/devices/page.js.map +7 -0
- package/dist/modules/devices/backend/devices/page.meta.js +23 -0
- package/dist/modules/devices/backend/devices/page.meta.js.map +7 -0
- package/dist/modules/devices/commands/deactivate.js +107 -0
- package/dist/modules/devices/commands/deactivate.js.map +7 -0
- package/dist/modules/devices/commands/index.js +4 -0
- package/dist/modules/devices/commands/index.js.map +7 -0
- package/dist/modules/devices/commands/register.js +183 -0
- package/dist/modules/devices/commands/register.js.map +7 -0
- package/dist/modules/devices/commands/shared.js +74 -0
- package/dist/modules/devices/commands/shared.js.map +7 -0
- package/dist/modules/devices/commands/update.js +126 -0
- package/dist/modules/devices/commands/update.js.map +7 -0
- package/dist/modules/devices/data/entities.js +83 -0
- package/dist/modules/devices/data/entities.js.map +7 -0
- package/dist/modules/devices/data/validators.js +49 -0
- package/dist/modules/devices/data/validators.js.map +7 -0
- package/dist/modules/devices/di.js +11 -0
- package/dist/modules/devices/di.js.map +7 -0
- package/dist/modules/devices/encryption.js +12 -0
- package/dist/modules/devices/encryption.js.map +7 -0
- package/dist/modules/devices/events.js +19 -0
- package/dist/modules/devices/events.js.map +7 -0
- package/dist/modules/devices/index.js +17 -0
- package/dist/modules/devices/index.js.map +7 -0
- package/dist/modules/devices/lib/operationMetadata.js +20 -0
- package/dist/modules/devices/lib/operationMetadata.js.map +7 -0
- package/dist/modules/devices/migrations/Migration20260602120000.js +15 -0
- package/dist/modules/devices/migrations/Migration20260602120000.js.map +7 -0
- package/dist/modules/devices/migrations/Migration20260630120000.js +15 -0
- package/dist/modules/devices/migrations/Migration20260630120000.js.map +7 -0
- package/dist/modules/devices/migrations/Migration20260701120000.js +13 -0
- package/dist/modules/devices/migrations/Migration20260701120000.js.map +7 -0
- package/dist/modules/devices/migrations/Migration20260721120000.js +15 -0
- package/dist/modules/devices/migrations/Migration20260721120000.js.map +7 -0
- package/dist/modules/devices/setup.js +13 -0
- package/dist/modules/devices/setup.js.map +7 -0
- package/dist/modules/entities/cli.js +8 -3
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/migrations/Migration20260722120000.js +28 -0
- package/dist/modules/entities/migrations/Migration20260722120000.js.map +7 -0
- package/dist/modules/inbox_ops/notifications.js +2 -0
- package/dist/modules/inbox_ops/notifications.js.map +2 -2
- package/dist/modules/messages/notifications.js +2 -0
- package/dist/modules/messages/notifications.js.map +2 -2
- package/dist/modules/notifications/acl.js +3 -1
- package/dist/modules/notifications/acl.js.map +2 -2
- package/dist/modules/notifications/api/admin/preferences/route.js +167 -0
- package/dist/modules/notifications/api/admin/preferences/route.js.map +7 -0
- package/dist/modules/notifications/api/channels/route.js +54 -0
- package/dist/modules/notifications/api/channels/route.js.map +7 -0
- package/dist/modules/notifications/api/preferences/route.js +126 -0
- package/dist/modules/notifications/api/preferences/route.js.map +7 -0
- package/dist/modules/notifications/api/route.js +8 -1
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/api/types/[id]/channels/[channel]/route.js +187 -0
- package/dist/modules/notifications/api/types/[id]/channels/[channel]/route.js.map +7 -0
- package/dist/modules/notifications/api/types/route.js +225 -0
- package/dist/modules/notifications/api/types/route.js.map +7 -0
- package/dist/modules/notifications/api/unread-count/route.js +5 -1
- package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
- package/dist/modules/notifications/backend/notifications/user-preferences/page.js +10 -0
- package/dist/modules/notifications/backend/notifications/user-preferences/page.js.map +7 -0
- package/dist/modules/notifications/backend/notifications/user-preferences/page.meta.js +28 -0
- package/dist/modules/notifications/backend/notifications/user-preferences/page.meta.js.map +7 -0
- package/dist/modules/notifications/backend/profile/notification-preferences/page.js +10 -0
- package/dist/modules/notifications/backend/profile/notification-preferences/page.js.map +7 -0
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +33 -0
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +7 -0
- package/dist/modules/notifications/data/entities.js +128 -1
- package/dist/modules/notifications/data/entities.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +95 -2
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/di.js +7 -0
- package/dist/modules/notifications/di.js.map +2 -2
- package/dist/modules/notifications/events.js +17 -0
- package/dist/modules/notifications/events.js.map +2 -2
- package/dist/modules/notifications/frontend/NotificationPreferenceMatrix.js +190 -0
- package/dist/modules/notifications/frontend/NotificationPreferenceMatrix.js.map +7 -0
- package/dist/modules/notifications/frontend/NotificationPreferencesPageClient.js +124 -0
- package/dist/modules/notifications/frontend/NotificationPreferencesPageClient.js.map +7 -0
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +166 -1
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
- package/dist/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.js +172 -0
- package/dist/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.js.map +7 -0
- package/dist/modules/notifications/generators.js +63 -0
- package/dist/modules/notifications/generators.js.map +7 -0
- package/dist/modules/notifications/lib/delivery-strategies-registry.js +19 -0
- package/dist/modules/notifications/lib/delivery-strategies-registry.js.map +7 -0
- package/dist/modules/notifications/lib/deliveryStrategies.js.map +2 -2
- package/dist/modules/notifications/lib/derive-category.js +8 -0
- package/dist/modules/notifications/lib/derive-category.js.map +7 -0
- package/dist/modules/notifications/lib/notification-channel-registry.js +35 -0
- package/dist/modules/notifications/lib/notification-channel-registry.js.map +7 -0
- package/dist/modules/notifications/lib/notification-type-registry.js +87 -0
- package/dist/modules/notifications/lib/notification-type-registry.js.map +7 -0
- package/dist/modules/notifications/lib/notificationCopy.js +14 -0
- package/dist/modules/notifications/lib/notificationCopy.js.map +7 -0
- package/dist/modules/notifications/lib/notificationFactory.js +4 -1
- package/dist/modules/notifications/lib/notificationFactory.js.map +2 -2
- package/dist/modules/notifications/lib/notificationMapper.js +2 -0
- package/dist/modules/notifications/lib/notificationMapper.js.map +2 -2
- package/dist/modules/notifications/lib/notificationPreferenceService.js +83 -0
- package/dist/modules/notifications/lib/notificationPreferenceService.js.map +7 -0
- package/dist/modules/notifications/lib/notificationService.js +83 -28
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/notificationVisibility.js +24 -0
- package/dist/modules/notifications/lib/notificationVisibility.js.map +7 -0
- package/dist/modules/notifications/lib/routeHelpers.js +11 -1
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/notifications/lib/shouldDeliver.js +46 -0
- package/dist/modules/notifications/lib/shouldDeliver.js.map +7 -0
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +54 -0
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +7 -0
- package/dist/modules/notifications/lib/strategies/in-app-delivery-strategy.js +12 -0
- package/dist/modules/notifications/lib/strategies/in-app-delivery-strategy.js.map +7 -0
- package/dist/modules/notifications/lib/typeCatalogue.js +71 -0
- package/dist/modules/notifications/lib/typeCatalogue.js.map +7 -0
- package/dist/modules/notifications/lib/typeChannelSettings.js +12 -0
- package/dist/modules/notifications/lib/typeChannelSettings.js.map +7 -0
- package/dist/modules/notifications/lib/typeOverrides.js +19 -0
- package/dist/modules/notifications/lib/typeOverrides.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260625122947_notifications.js +14 -0
- package/dist/modules/notifications/migrations/Migration20260625122947_notifications.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.js +15 -0
- package/dist/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.js +13 -0
- package/dist/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.js +15 -0
- package/dist/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260706120000_notifications_channels.js +13 -0
- package/dist/modules/notifications/migrations/Migration20260706120000_notifications_channels.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260716123121_notifications.js +14 -0
- package/dist/modules/notifications/migrations/Migration20260716123121_notifications.js.map +7 -0
- package/dist/modules/notifications/notification-channels.js +26 -0
- package/dist/modules/notifications/notification-channels.js.map +7 -0
- package/dist/modules/notifications/notifications.delivery-strategies.js +12 -0
- package/dist/modules/notifications/notifications.delivery-strategies.js.map +7 -0
- package/dist/modules/notifications/setup.js +16 -1
- package/dist/modules/notifications/setup.js.map +2 -2
- package/dist/modules/notifications/subscribers/deliver-notification.js +66 -57
- package/dist/modules/notifications/subscribers/deliver-notification.js.map +2 -2
- package/dist/modules/notifications/subscribers/sync-notification-types.js +16 -0
- package/dist/modules/notifications/subscribers/sync-notification-types.js.map +7 -0
- package/dist/modules/notifications/widgets/injection/profile-preferences-menu/widget.js +23 -0
- package/dist/modules/notifications/widgets/injection/profile-preferences-menu/widget.js.map +7 -0
- package/dist/modules/notifications/widgets/injection-table.js +14 -0
- package/dist/modules/notifications/widgets/injection-table.js.map +7 -0
- package/dist/modules/push_notifications/acl.js +13 -0
- package/dist/modules/push_notifications/acl.js.map +7 -0
- package/dist/modules/push_notifications/api/custom-send/route.js +141 -0
- package/dist/modules/push_notifications/api/custom-send/route.js.map +7 -0
- package/dist/modules/push_notifications/api/deliveries/[id]/route.js +110 -0
- package/dist/modules/push_notifications/api/deliveries/[id]/route.js.map +7 -0
- package/dist/modules/push_notifications/api/deliveries/route.js +56 -0
- package/dist/modules/push_notifications/api/deliveries/route.js.map +7 -0
- package/dist/modules/push_notifications/api/openapi.js +19 -0
- package/dist/modules/push_notifications/api/openapi.js.map +7 -0
- package/dist/modules/push_notifications/backend/push_notifications/[id]/page.js +103 -0
- package/dist/modules/push_notifications/backend/push_notifications/[id]/page.js.map +7 -0
- package/dist/modules/push_notifications/backend/push_notifications/[id]/page.meta.js +13 -0
- package/dist/modules/push_notifications/backend/push_notifications/[id]/page.meta.js.map +7 -0
- package/dist/modules/push_notifications/backend/push_notifications/page.js +213 -0
- package/dist/modules/push_notifications/backend/push_notifications/page.js.map +7 -0
- package/dist/modules/push_notifications/backend/push_notifications/page.meta.js +23 -0
- package/dist/modules/push_notifications/backend/push_notifications/page.meta.js.map +7 -0
- package/dist/modules/push_notifications/backend/push_notifications/send/page.js +245 -0
- package/dist/modules/push_notifications/backend/push_notifications/send/page.js.map +7 -0
- package/dist/modules/push_notifications/backend/push_notifications/send/page.meta.js +26 -0
- package/dist/modules/push_notifications/backend/push_notifications/send/page.meta.js.map +7 -0
- package/dist/modules/push_notifications/data/entities.js +92 -0
- package/dist/modules/push_notifications/data/entities.js.map +7 -0
- package/dist/modules/push_notifications/data/extensions.js +23 -0
- package/dist/modules/push_notifications/data/extensions.js.map +7 -0
- package/dist/modules/push_notifications/data/validators.js +89 -0
- package/dist/modules/push_notifications/data/validators.js.map +7 -0
- package/dist/modules/push_notifications/di.js +17 -0
- package/dist/modules/push_notifications/di.js.map +7 -0
- package/dist/modules/push_notifications/events.js +24 -0
- package/dist/modules/push_notifications/events.js.map +7 -0
- package/dist/modules/push_notifications/index.js +16 -0
- package/dist/modules/push_notifications/index.js.map +7 -0
- package/dist/modules/push_notifications/lib/fake-provider-recorder.js +80 -0
- package/dist/modules/push_notifications/lib/fake-provider-recorder.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-delivery-strategy.js +54 -0
- package/dist/modules/push_notifications/lib/push-delivery-strategy.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-delivery.js +297 -0
- package/dist/modules/push_notifications/lib/push-delivery.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-fanout.js +138 -0
- package/dist/modules/push_notifications/lib/push-fanout.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-health.js +32 -0
- package/dist/modules/push_notifications/lib/push-health.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-reaper.js +87 -0
- package/dist/modules/push_notifications/lib/push-reaper.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-receipt-reaper.js +136 -0
- package/dist/modules/push_notifications/lib/push-receipt-reaper.js.map +7 -0
- package/dist/modules/push_notifications/lib/push-stub-adapter.js +63 -0
- package/dist/modules/push_notifications/lib/push-stub-adapter.js.map +7 -0
- package/dist/modules/push_notifications/lib/queue.js +53 -0
- package/dist/modules/push_notifications/lib/queue.js.map +7 -0
- package/dist/modules/push_notifications/lib/reclaim-window.js +13 -0
- package/dist/modules/push_notifications/lib/reclaim-window.js.map +7 -0
- package/dist/modules/push_notifications/lib/send-custom-push.js +48 -0
- package/dist/modules/push_notifications/lib/send-custom-push.js.map +7 -0
- package/dist/modules/push_notifications/migrations/Migration20260625150049_push_notifications.js +16 -0
- package/dist/modules/push_notifications/migrations/Migration20260625150049_push_notifications.js.map +7 -0
- package/dist/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.js +13 -0
- package/dist/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.js.map +7 -0
- package/dist/modules/push_notifications/notifications.delivery-strategies.js +8 -0
- package/dist/modules/push_notifications/notifications.delivery-strategies.js.map +7 -0
- package/dist/modules/push_notifications/notifications.js +40 -0
- package/dist/modules/push_notifications/notifications.js.map +7 -0
- package/dist/modules/push_notifications/setup.js +52 -0
- package/dist/modules/push_notifications/setup.js.map +7 -0
- package/dist/modules/push_notifications/workers/reclaim-stuck.worker.js +57 -0
- package/dist/modules/push_notifications/workers/reclaim-stuck.worker.js.map +7 -0
- package/dist/modules/push_notifications/workers/send-push.worker.js +27 -0
- package/dist/modules/push_notifications/workers/send-push.worker.js.map +7 -0
- package/dist/modules/sales/notifications.js +8 -0
- package/dist/modules/sales/notifications.js.map +2 -2
- package/dist/modules/staff/notifications.js +6 -0
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/notifications.js +2 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/notification/index.ts +3 -0
- package/generated/entities/notification_preference/index.ts +8 -0
- package/generated/entities/notification_type/index.ts +9 -0
- package/generated/entities/notification_type_override/index.ts +7 -0
- package/generated/entities/push_notification_delivery/index.ts +19 -0
- package/generated/entities/user_device/index.ts +16 -0
- package/generated/entities.ids.generated.ts +12 -1
- package/generated/entity-fields-registry.ts +72 -0
- package/package.json +7 -7
- package/src/helpers/integration/api.ts +2 -1
- package/src/helpers/integration/appRoot.ts +13 -0
- package/src/helpers/integration/dbFixtures.ts +1 -5
- package/src/helpers/integration/pushFake.ts +219 -0
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/ko.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/auth/notifications.ts +16 -0
- package/src/modules/business_rules/i18n/de.json +2 -1
- package/src/modules/business_rules/i18n/en.json +2 -1
- package/src/modules/business_rules/i18n/es.json +2 -1
- package/src/modules/business_rules/i18n/ko.json +2 -1
- package/src/modules/business_rules/i18n/pl.json +2 -1
- package/src/modules/business_rules/notifications.ts +2 -0
- package/src/modules/catalog/i18n/de.json +2 -1
- package/src/modules/catalog/i18n/en.json +2 -1
- package/src/modules/catalog/i18n/es.json +2 -1
- package/src/modules/catalog/i18n/ko.json +2 -1
- package/src/modules/catalog/i18n/pl.json +2 -1
- package/src/modules/catalog/notifications.ts +2 -0
- package/src/modules/communication_channels/acl.ts +9 -0
- package/src/modules/communication_channels/api/delete/admin/channels/[id]/route.ts +168 -0
- package/src/modules/communication_channels/api/get/channels/[id]/health/route.ts +6 -2
- package/src/modules/communication_channels/api/get/channels/[id]/route.ts +6 -2
- package/src/modules/communication_channels/api/get/channels/route.ts +2 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +16 -5
- package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +37 -1
- package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +175 -0
- package/src/modules/communication_channels/backend/communication_channels/channels/page.tsx +11 -1
- package/src/modules/communication_channels/commands/admin-delete-channel.ts +203 -0
- package/src/modules/communication_channels/commands/connect-credential-channel.ts +63 -15
- package/src/modules/communication_channels/commands/deliver-outbound-message.ts +10 -2
- package/src/modules/communication_channels/commands/push-register.ts +7 -1
- package/src/modules/communication_channels/commands/push-unregister.ts +14 -3
- package/src/modules/communication_channels/data/entities.ts +10 -0
- package/src/modules/communication_channels/data/validators.ts +1 -0
- package/src/modules/communication_channels/i18n/de.json +35 -1
- package/src/modules/communication_channels/i18n/en.json +35 -1
- package/src/modules/communication_channels/i18n/es.json +35 -1
- package/src/modules/communication_channels/i18n/ko.json +35 -1
- package/src/modules/communication_channels/i18n/pl.json +35 -1
- package/src/modules/communication_channels/lib/access-control.ts +42 -0
- package/src/modules/communication_channels/lib/adapter.ts +25 -0
- package/src/modules/communication_channels/lib/connect-channel.ts +61 -26
- package/src/modules/communication_channels/lib/push-adapter.ts +139 -0
- package/src/modules/communication_channels/lib/push-capabilities.ts +42 -0
- package/src/modules/communication_channels/lib/push-connect-error.ts +52 -0
- package/src/modules/communication_channels/lib/push-credential-errors.ts +58 -0
- package/src/modules/communication_channels/lib/push-envelope.ts +104 -0
- package/src/modules/communication_channels/lib/refcounted-client-cache.ts +123 -0
- package/src/modules/communication_channels/migrations/.snapshot-open-mercato.json +9 -0
- package/src/modules/communication_channels/migrations/Migration20260703123630_communication_channels.ts +20 -0
- package/src/modules/communication_channels/notifications.ts +4 -0
- package/src/modules/communication_channels/setup.ts +2 -0
- package/src/modules/configs/i18n/de.json +5 -1
- package/src/modules/configs/i18n/en.json +5 -1
- package/src/modules/configs/i18n/es.json +5 -1
- package/src/modules/configs/i18n/ko.json +5 -1
- package/src/modules/configs/i18n/pl.json +5 -1
- package/src/modules/configs/lib/upgrade-actions.ts +21 -0
- package/src/modules/customer_accounts/i18n/de.json +2 -1
- package/src/modules/customer_accounts/i18n/en.json +2 -1
- package/src/modules/customer_accounts/i18n/es.json +2 -1
- package/src/modules/customer_accounts/i18n/ko.json +2 -1
- package/src/modules/customer_accounts/i18n/pl.json +2 -1
- package/src/modules/customer_accounts/notifications.ts +12 -0
- package/src/modules/customers/i18n/de.json +2 -1
- package/src/modules/customers/i18n/en.json +2 -1
- package/src/modules/customers/i18n/es.json +2 -1
- package/src/modules/customers/i18n/ko.json +2 -1
- package/src/modules/customers/i18n/pl.json +2 -1
- package/src/modules/customers/notifications.ts +4 -0
- package/src/modules/devices/AGENTS.md +101 -0
- package/src/modules/devices/acl.ts +7 -0
- package/src/modules/devices/api/[id]/route.ts +180 -0
- package/src/modules/devices/api/admin/devices/[id]/route.ts +235 -0
- package/src/modules/devices/api/admin/devices/route.ts +160 -0
- package/src/modules/devices/api/auth.ts +13 -0
- package/src/modules/devices/api/deviceList.ts +54 -0
- package/src/modules/devices/api/deviceOps.ts +186 -0
- package/src/modules/devices/api/openapi.ts +23 -0
- package/src/modules/devices/api/route.ts +165 -0
- package/src/modules/devices/backend/devices/[id]/page.meta.ts +12 -0
- package/src/modules/devices/backend/devices/[id]/page.tsx +164 -0
- package/src/modules/devices/backend/devices/create/page.meta.ts +12 -0
- package/src/modules/devices/backend/devices/create/page.tsx +81 -0
- package/src/modules/devices/backend/devices/page.meta.ts +21 -0
- package/src/modules/devices/backend/devices/page.tsx +259 -0
- package/src/modules/devices/commands/deactivate.ts +126 -0
- package/src/modules/devices/commands/index.ts +3 -0
- package/src/modules/devices/commands/register.ts +199 -0
- package/src/modules/devices/commands/shared.ts +119 -0
- package/src/modules/devices/commands/update.ts +142 -0
- package/src/modules/devices/data/entities.ts +79 -0
- package/src/modules/devices/data/validators.ts +65 -0
- package/src/modules/devices/di.ts +9 -0
- package/src/modules/devices/encryption.ts +15 -0
- package/src/modules/devices/events.ts +16 -0
- package/src/modules/devices/i18n/de.json +45 -0
- package/src/modules/devices/i18n/en.json +45 -0
- package/src/modules/devices/i18n/es.json +45 -0
- package/src/modules/devices/i18n/ko.json +45 -0
- package/src/modules/devices/i18n/pl.json +45 -0
- package/src/modules/devices/index.ts +15 -0
- package/src/modules/devices/lib/operationMetadata.ts +35 -0
- package/src/modules/devices/migrations/.snapshot-open-mercato.json +200 -0
- package/src/modules/devices/migrations/Migration20260602120000.ts +15 -0
- package/src/modules/devices/migrations/Migration20260630120000.ts +20 -0
- package/src/modules/devices/migrations/Migration20260701120000.ts +13 -0
- package/src/modules/devices/migrations/Migration20260721120000.ts +18 -0
- package/src/modules/devices/setup.ts +11 -0
- package/src/modules/entities/cli.ts +16 -2
- package/src/modules/entities/migrations/Migration20260722120000.ts +38 -0
- package/src/modules/inbox_ops/i18n/de.json +2 -1
- package/src/modules/inbox_ops/i18n/en.json +2 -1
- package/src/modules/inbox_ops/i18n/es.json +2 -1
- package/src/modules/inbox_ops/i18n/ko.json +2 -1
- package/src/modules/inbox_ops/i18n/pl.json +2 -1
- package/src/modules/inbox_ops/notifications.ts +2 -0
- package/src/modules/messages/i18n/de.json +2 -1
- package/src/modules/messages/i18n/en.json +2 -1
- package/src/modules/messages/i18n/es.json +2 -1
- package/src/modules/messages/i18n/ko.json +2 -1
- package/src/modules/messages/i18n/pl.json +2 -1
- package/src/modules/messages/notifications.ts +2 -0
- package/src/modules/notifications/AGENTS.md +54 -0
- package/src/modules/notifications/acl.ts +2 -0
- package/src/modules/notifications/api/admin/preferences/route.ts +193 -0
- package/src/modules/notifications/api/channels/route.ts +54 -0
- package/src/modules/notifications/api/preferences/route.ts +131 -0
- package/src/modules/notifications/api/route.ts +8 -1
- package/src/modules/notifications/api/types/[id]/channels/[channel]/route.ts +226 -0
- package/src/modules/notifications/api/types/route.ts +239 -0
- package/src/modules/notifications/api/unread-count/route.ts +5 -1
- package/src/modules/notifications/backend/notifications/user-preferences/page.meta.ts +26 -0
- package/src/modules/notifications/backend/notifications/user-preferences/page.tsx +12 -0
- package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +31 -0
- package/src/modules/notifications/backend/profile/notification-preferences/page.tsx +12 -0
- package/src/modules/notifications/data/entities.ts +144 -0
- package/src/modules/notifications/data/validators.ts +116 -0
- package/src/modules/notifications/di.ts +7 -0
- package/src/modules/notifications/events.ts +23 -0
- package/src/modules/notifications/frontend/NotificationPreferenceMatrix.tsx +287 -0
- package/src/modules/notifications/frontend/NotificationPreferencesPageClient.tsx +155 -0
- package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +273 -2
- package/src/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.tsx +214 -0
- package/src/modules/notifications/generators.ts +82 -0
- package/src/modules/notifications/i18n/de.json +43 -0
- package/src/modules/notifications/i18n/en.json +43 -0
- package/src/modules/notifications/i18n/es.json +43 -0
- package/src/modules/notifications/i18n/ko.json +43 -0
- package/src/modules/notifications/i18n/pl.json +44 -1
- package/src/modules/notifications/lib/delivery-strategies-registry.ts +30 -0
- package/src/modules/notifications/lib/deliveryStrategies.ts +40 -4
- package/src/modules/notifications/lib/derive-category.ts +15 -0
- package/src/modules/notifications/lib/notification-channel-registry.ts +59 -0
- package/src/modules/notifications/lib/notification-type-registry.ts +182 -0
- package/src/modules/notifications/lib/notificationCopy.ts +34 -0
- package/src/modules/notifications/lib/notificationFactory.ts +5 -1
- package/src/modules/notifications/lib/notificationMapper.ts +2 -0
- package/src/modules/notifications/lib/notificationPreferenceService.ts +138 -0
- package/src/modules/notifications/lib/notificationService.ts +129 -25
- package/src/modules/notifications/lib/notificationVisibility.ts +48 -0
- package/src/modules/notifications/lib/routeHelpers.ts +14 -1
- package/src/modules/notifications/lib/shouldDeliver.ts +112 -0
- package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +63 -0
- package/src/modules/notifications/lib/strategies/in-app-delivery-strategy.ts +21 -0
- package/src/modules/notifications/lib/typeCatalogue.ts +141 -0
- package/src/modules/notifications/lib/typeChannelSettings.ts +29 -0
- package/src/modules/notifications/lib/typeOverrides.ts +46 -0
- package/src/modules/notifications/migrations/.snapshot-open-mercato.json +874 -100
- package/src/modules/notifications/migrations/Migration20260625122947_notifications.ts +14 -0
- package/src/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.ts +15 -0
- package/src/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.ts +13 -0
- package/src/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.ts +15 -0
- package/src/modules/notifications/migrations/Migration20260706120000_notifications_channels.ts +13 -0
- package/src/modules/notifications/migrations/Migration20260716123121_notifications.ts +14 -0
- package/src/modules/notifications/notification-channels.ts +30 -0
- package/src/modules/notifications/notifications.delivery-strategies.ts +15 -0
- package/src/modules/notifications/setup.ts +23 -1
- package/src/modules/notifications/subscribers/deliver-notification.ts +105 -71
- package/src/modules/notifications/subscribers/sync-notification-types.ts +18 -0
- package/src/modules/notifications/widgets/injection/profile-preferences-menu/widget.ts +35 -0
- package/src/modules/notifications/widgets/injection-table.ts +18 -0
- package/src/modules/push_notifications/AGENTS.md +92 -0
- package/src/modules/push_notifications/acl.ts +9 -0
- package/src/modules/push_notifications/api/custom-send/route.ts +175 -0
- package/src/modules/push_notifications/api/deliveries/[id]/route.ts +122 -0
- package/src/modules/push_notifications/api/deliveries/route.ts +58 -0
- package/src/modules/push_notifications/api/openapi.ts +22 -0
- package/src/modules/push_notifications/backend/push_notifications/[id]/page.meta.ts +9 -0
- package/src/modules/push_notifications/backend/push_notifications/[id]/page.tsx +149 -0
- package/src/modules/push_notifications/backend/push_notifications/page.meta.ts +21 -0
- package/src/modules/push_notifications/backend/push_notifications/page.tsx +239 -0
- package/src/modules/push_notifications/backend/push_notifications/send/page.meta.ts +24 -0
- package/src/modules/push_notifications/backend/push_notifications/send/page.tsx +327 -0
- package/src/modules/push_notifications/data/entities.ts +111 -0
- package/src/modules/push_notifications/data/extensions.ts +29 -0
- package/src/modules/push_notifications/data/validators.ts +102 -0
- package/src/modules/push_notifications/di.ts +20 -0
- package/src/modules/push_notifications/events.ts +21 -0
- package/src/modules/push_notifications/i18n/de.json +79 -0
- package/src/modules/push_notifications/i18n/en.json +79 -0
- package/src/modules/push_notifications/i18n/es.json +79 -0
- package/src/modules/push_notifications/i18n/ko.json +79 -0
- package/src/modules/push_notifications/i18n/pl.json +79 -0
- package/src/modules/push_notifications/index.ts +15 -0
- package/src/modules/push_notifications/lib/fake-provider-recorder.ts +154 -0
- package/src/modules/push_notifications/lib/push-delivery-strategy.ts +90 -0
- package/src/modules/push_notifications/lib/push-delivery.ts +455 -0
- package/src/modules/push_notifications/lib/push-fanout.ts +237 -0
- package/src/modules/push_notifications/lib/push-health.ts +47 -0
- package/src/modules/push_notifications/lib/push-reaper.ts +140 -0
- package/src/modules/push_notifications/lib/push-receipt-reaper.ts +210 -0
- package/src/modules/push_notifications/lib/push-stub-adapter.ts +80 -0
- package/src/modules/push_notifications/lib/queue.ts +74 -0
- package/src/modules/push_notifications/lib/reclaim-window.ts +16 -0
- package/src/modules/push_notifications/lib/send-custom-push.ts +100 -0
- package/src/modules/push_notifications/migrations/.snapshot-open-mercato.json +386 -0
- package/src/modules/push_notifications/migrations/Migration20260625150049_push_notifications.ts +16 -0
- package/src/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.ts +13 -0
- package/src/modules/push_notifications/notifications.delivery-strategies.ts +8 -0
- package/src/modules/push_notifications/notifications.ts +50 -0
- package/src/modules/push_notifications/setup.ts +92 -0
- package/src/modules/push_notifications/workers/reclaim-stuck.worker.ts +78 -0
- package/src/modules/push_notifications/workers/send-push.worker.ts +34 -0
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/ko.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
- package/src/modules/sales/notifications.ts +8 -0
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/ko.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/notifications.ts +6 -0
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/ko.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/notifications.ts +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { EntityName } from '@mikro-orm/core'
|
|
3
|
+
import { sql } from 'kysely'
|
|
4
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import type { PushOptions } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
|
|
6
|
+
import type { UserDevice } from '@open-mercato/core/modules/devices/data/entities'
|
|
7
|
+
import type { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
8
|
+
import { defaultLocale } from '@open-mercato/shared/lib/i18n/config'
|
|
9
|
+
import { resolveSupportedLocale } from '@open-mercato/shared/lib/i18n/locale'
|
|
10
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
11
|
+
import {
|
|
12
|
+
resolveNotificationCopy,
|
|
13
|
+
type NotificationCopySource,
|
|
14
|
+
} from '@open-mercato/core/modules/notifications/lib/notificationCopy'
|
|
15
|
+
import { enqueuePushDelivery } from './queue'
|
|
16
|
+
|
|
17
|
+
const logger = createLogger('push_notifications')
|
|
18
|
+
|
|
19
|
+
export const PUSH_CHANNEL = 'push'
|
|
20
|
+
|
|
21
|
+
type Resolve = <T = unknown>(name: string) => T
|
|
22
|
+
|
|
23
|
+
/** The push payload persisted on each delivery row and unpacked by the worker into the send envelope. */
|
|
24
|
+
export interface PushFanoutPayload {
|
|
25
|
+
title?: string
|
|
26
|
+
body?: string | null
|
|
27
|
+
data: Record<string, string>
|
|
28
|
+
options?: PushOptions
|
|
29
|
+
silent?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FanOutPushDeliveriesArgs {
|
|
33
|
+
em: EntityManager
|
|
34
|
+
resolve: Resolve
|
|
35
|
+
scope: { tenantId: string; organizationId: string | null }
|
|
36
|
+
userId: string
|
|
37
|
+
/**
|
|
38
|
+
* Restrict the fan-out to a single one of the user's devices (`UserDevice.id`). Still scoped to the
|
|
39
|
+
* same (tenant, org, user), so an id that isn't the user's own matches nothing. Omit to fan out to
|
|
40
|
+
* all of the user's push-capable devices (the default).
|
|
41
|
+
*/
|
|
42
|
+
userDeviceId?: string
|
|
43
|
+
/** Source in-app notification id, or `null` for a silent push (no Notification row). */
|
|
44
|
+
notificationId: string | null
|
|
45
|
+
notificationTypeId: string
|
|
46
|
+
payload: PushFanoutPayload
|
|
47
|
+
/**
|
|
48
|
+
* Per-device localizable copy for visible notifications. When present, `title`/`body` are
|
|
49
|
+
* resolved per device using its `locale`. Omit for silent pushes (no user-facing copy).
|
|
50
|
+
*/
|
|
51
|
+
copy?: NotificationCopySource
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tokenSnapshot(token: string): string {
|
|
55
|
+
// Persist at most the last 8 chars of the (long) provider token — never the full secret.
|
|
56
|
+
return token.slice(-8)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a recipient's push-capable devices, route each to its provider's tenant push
|
|
61
|
+
* `CommunicationChannel`, persist one `pending` push delivery row per device, and enqueue a send
|
|
62
|
+
* job per row. Shared by the `push` delivery strategy (visible + silent notifications) and
|
|
63
|
+
* `sendCustomPush` (admin one-off pushes); it is preference-agnostic — the caller decides whether
|
|
64
|
+
* to consult per-channel preferences before fanning out.
|
|
65
|
+
*
|
|
66
|
+
* Cross-module entities are resolved via DI tokens (registered `asValue` by their owning modules)
|
|
67
|
+
* so this stays decoupled from those modules' internals. Returns the number of jobs enqueued.
|
|
68
|
+
*/
|
|
69
|
+
export async function fanOutPushDeliveries(args: FanOutPushDeliveriesArgs): Promise<{ enqueued: number }> {
|
|
70
|
+
const { em, resolve, scope, userId, userDeviceId, notificationId, notificationTypeId, payload, copy } = args
|
|
71
|
+
const { tenantId, organizationId } = scope
|
|
72
|
+
const silent = payload.silent === true
|
|
73
|
+
|
|
74
|
+
// Require at least one active push CommunicationChannel for the tenant (push not configured ⇒ skip).
|
|
75
|
+
// This is the cheapest, most selective short-circuit (most tenants have no push channel), so it runs
|
|
76
|
+
// before the per-recipient device lookup. Channels are indexed by providerKey so each device can be
|
|
77
|
+
// routed to its matching provider below (ios→apns, android→fcm, expo→expo). Oldest-first so that when
|
|
78
|
+
// a tenant has more than one active channel for the same provider, the pick is deterministic.
|
|
79
|
+
const ChannelRef = resolve('CommunicationChannel') as EntityName<CommunicationChannel>
|
|
80
|
+
const channels = await em.find(
|
|
81
|
+
ChannelRef,
|
|
82
|
+
{
|
|
83
|
+
tenantId,
|
|
84
|
+
channelType: PUSH_CHANNEL,
|
|
85
|
+
isActive: true,
|
|
86
|
+
deletedAt: null,
|
|
87
|
+
},
|
|
88
|
+
{ orderBy: { createdAt: 'asc' } },
|
|
89
|
+
)
|
|
90
|
+
if (channels.length === 0) return { enqueued: 0 }
|
|
91
|
+
const channelsByProvider = new Map<string, CommunicationChannel>()
|
|
92
|
+
for (const channel of channels) {
|
|
93
|
+
if (!channelsByProvider.has(channel.providerKey)) channelsByProvider.set(channel.providerKey, channel)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Load the recipient's devices that can receive push (active + has a token). Scoped to the
|
|
97
|
+
// notification's organization so an org-scoped notification never fans out to a device the user
|
|
98
|
+
// registered under a different org — device identity is per (tenant, org, user, device) (see devices
|
|
99
|
+
// module). A tenant-level (null-org) notification targets the user's tenant-level (null-org) devices.
|
|
100
|
+
// `push_token` is encrypted at rest; decrypt on read (no-op when encryption is disabled).
|
|
101
|
+
const DeviceRef = resolve('UserDevice') as EntityName<UserDevice>
|
|
102
|
+
const devices = await findWithDecryption(
|
|
103
|
+
em,
|
|
104
|
+
DeviceRef,
|
|
105
|
+
{
|
|
106
|
+
tenantId,
|
|
107
|
+
organizationId,
|
|
108
|
+
userId,
|
|
109
|
+
// Optionally restrict to a single device; still scoped to this user so a foreign id matches none.
|
|
110
|
+
...(userDeviceId ? { id: userDeviceId } : {}),
|
|
111
|
+
deletedAt: null,
|
|
112
|
+
pushToken: { $ne: null },
|
|
113
|
+
},
|
|
114
|
+
undefined,
|
|
115
|
+
{ tenantId, organizationId },
|
|
116
|
+
)
|
|
117
|
+
if (devices.length === 0) return { enqueued: 0 }
|
|
118
|
+
|
|
119
|
+
// Resolve the per-device localized copy. The default-locale case (the common one: device has no
|
|
120
|
+
// locale, an unsupported one, or already speaks the default) reuses the copy already resolved
|
|
121
|
+
// upstream — no dictionary load. Other locales translate via the shared notification-copy helper
|
|
122
|
+
// (dictionaries are memoized by loadDictionary). Silent pushes carry no copy and skip this.
|
|
123
|
+
// Memoized by resolved locale so a user with many same-locale devices derives copy once
|
|
124
|
+
// (O(distinct locales) instead of O(devices)).
|
|
125
|
+
const localizedByLocale = new Map<string, Promise<PushFanoutPayload>>()
|
|
126
|
+
function resolveLocalizedPayload(deviceLocale?: string | null): Promise<PushFanoutPayload> {
|
|
127
|
+
if (!copy) return Promise.resolve(payload)
|
|
128
|
+
const locale = resolveSupportedLocale(deviceLocale) ?? defaultLocale
|
|
129
|
+
let cached = localizedByLocale.get(locale)
|
|
130
|
+
if (!cached) {
|
|
131
|
+
cached = (async () => {
|
|
132
|
+
if (locale === defaultLocale) {
|
|
133
|
+
return { ...payload, title: copy.title, body: copy.body ?? null }
|
|
134
|
+
}
|
|
135
|
+
const { title, body } = await resolveNotificationCopy(copy, locale)
|
|
136
|
+
return { ...payload, title, body }
|
|
137
|
+
})()
|
|
138
|
+
localizedByLocale.set(locale, cached)
|
|
139
|
+
}
|
|
140
|
+
return cached
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Insert one pending delivery row per device, routing each device to the push channel whose
|
|
144
|
+
// providerKey matches the device's pushProvider. Devices with no provider, or no matching configured
|
|
145
|
+
// channel, are skipped. Insert via INSERT ... ON CONFLICT DO NOTHING on the (notification_id,
|
|
146
|
+
// user_device_id) partial unique index: the `push` strategy runs inside the at-least-once persistent
|
|
147
|
+
// `notifications:deliver` subscriber, so a redelivered event re-runs it — the conflict clause makes
|
|
148
|
+
// the re-fan-out a no-op instead of inserting a duplicate set of rows (and duplicate pushes). Silent
|
|
149
|
+
// pushes carry a null notification_id and are excluded from the partial index (they are triggered
|
|
150
|
+
// explicitly, not via at-least-once redelivery). Only the rows actually inserted are enqueued.
|
|
151
|
+
const builtRows = await Promise.all(
|
|
152
|
+
devices.map(async (device) => {
|
|
153
|
+
const providerKey = device.pushProvider
|
|
154
|
+
if (!providerKey) return null
|
|
155
|
+
const channel = channelsByProvider.get(providerKey)
|
|
156
|
+
if (!channel) return null
|
|
157
|
+
const rowPayload = await resolveLocalizedPayload(device.locale)
|
|
158
|
+
return {
|
|
159
|
+
tenant_id: tenantId,
|
|
160
|
+
organization_id: organizationId,
|
|
161
|
+
notification_id: notificationId,
|
|
162
|
+
notification_type_id: notificationTypeId,
|
|
163
|
+
user_device_id: device.id,
|
|
164
|
+
user_id: userId,
|
|
165
|
+
provider: channel.providerKey,
|
|
166
|
+
token_snapshot: tokenSnapshot(device.pushToken as string),
|
|
167
|
+
silent,
|
|
168
|
+
status: 'pending',
|
|
169
|
+
attempts: 0,
|
|
170
|
+
payload: sql`${JSON.stringify(rowPayload)}::jsonb`,
|
|
171
|
+
created_at: sql`now()`,
|
|
172
|
+
updated_at: sql`now()`,
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
)
|
|
176
|
+
const rows = builtRows.filter((row): row is NonNullable<typeof row> => row !== null)
|
|
177
|
+
// A registered device whose pushProvider has no matching active channel (e.g. an Expo device on an
|
|
178
|
+
// FCM-only tenant) is correctly skipped above — but it produces no delivery row and would otherwise
|
|
179
|
+
// be an invisible no-op. Surface a count so a provider-config gap is diagnosable.
|
|
180
|
+
const skippedNoChannel = devices.length - rows.length
|
|
181
|
+
if (skippedNoChannel > 0) {
|
|
182
|
+
logger.warn('Skipped devices with no matching push channel', {
|
|
183
|
+
tenantId,
|
|
184
|
+
userId,
|
|
185
|
+
notificationId,
|
|
186
|
+
skipped: skippedNoChannel,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
if (rows.length === 0) return { enqueued: 0 }
|
|
190
|
+
|
|
191
|
+
const db = em.getKysely<any>()
|
|
192
|
+
const inserted = (await db
|
|
193
|
+
.insertInto('push_notification_deliveries')
|
|
194
|
+
.values(rows)
|
|
195
|
+
.onConflict((oc: any) =>
|
|
196
|
+
oc.columns(['notification_id', 'user_device_id']).where('notification_id', 'is not', null).doNothing(),
|
|
197
|
+
)
|
|
198
|
+
.returning(['id'])
|
|
199
|
+
.execute()) as Array<{ id: string }>
|
|
200
|
+
|
|
201
|
+
// Enqueue one send job per inserted row (per-device retry isolation, idempotent on delivery id).
|
|
202
|
+
// Enqueue all rows concurrently — the jobs are independent, so there is no reason to serialize the
|
|
203
|
+
// queue round-trips. If an enqueue fails, mark that row failed instead of leaving it orphaned in
|
|
204
|
+
// `pending` forever (the worker only ever processes rows it receives a job for). Failed rows are
|
|
205
|
+
// grouped by reason and marked in one UPDATE per reason to keep DB round-trips minimal.
|
|
206
|
+
const outcomes = await Promise.allSettled(
|
|
207
|
+
inserted.map((row) => enqueuePushDelivery({ deliveryId: row.id, tenantId, organizationId })),
|
|
208
|
+
)
|
|
209
|
+
const failedIdsByReason = new Map<string, string[]>()
|
|
210
|
+
let enqueued = 0
|
|
211
|
+
outcomes.forEach((outcome, index) => {
|
|
212
|
+
if (outcome.status === 'fulfilled') {
|
|
213
|
+
enqueued += 1
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const error = outcome.reason
|
|
217
|
+
const reason = error instanceof Error ? `enqueue_failed: ${error.message}` : 'enqueue_failed'
|
|
218
|
+
const ids = failedIdsByReason.get(reason) ?? []
|
|
219
|
+
ids.push(inserted[index].id)
|
|
220
|
+
failedIdsByReason.set(reason, ids)
|
|
221
|
+
})
|
|
222
|
+
if (failedIdsByReason.size > 0) {
|
|
223
|
+
await Promise.allSettled(
|
|
224
|
+
Array.from(failedIdsByReason.entries()).map(([reason, ids]) =>
|
|
225
|
+
db
|
|
226
|
+
.updateTable('push_notification_deliveries')
|
|
227
|
+
.set({ status: 'failed', last_error: reason, updated_at: sql`now()` })
|
|
228
|
+
.where('id', 'in', ids)
|
|
229
|
+
// Guard on `pending`: if the enqueue reported failure yet the job actually landed and a
|
|
230
|
+
// worker already claimed/finished the row, don't clobber its later status back to `failed`.
|
|
231
|
+
.where('status', '=', 'pending')
|
|
232
|
+
.execute(),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
return { enqueued }
|
|
237
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ZodType } from 'zod'
|
|
2
|
+
import type { EmailHealthCheck } from '@open-mercato/core/modules/communication_channels/lib/provider-health'
|
|
3
|
+
import { isPushFakeProvidersEnabled, PUSH_FAKE_PROVIDERS_ENV } from './fake-provider-recorder'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Liveness probe for a push provider (FCM/APNs/Expo).
|
|
7
|
+
*
|
|
8
|
+
* A push provider has NO OAuth client — its credentials are a service-account JSON, a `.p8` signing
|
|
9
|
+
* key, or an Expo access token. So this probe validates the tenant-scoped credentials against the
|
|
10
|
+
* provider schema and reports push-appropriate copy, rather than the OAuth-client wording the shared
|
|
11
|
+
* `makeClientConfigHealthCheck` emits (which is correct for the Gmail-style channels but misleading
|
|
12
|
+
* here). It additionally surfaces fake-provider mode: when `OM_PUSH_FAKE_PROVIDERS` is set every send is
|
|
13
|
+
* short-circuited to a recorded no-op, so a plain "credentials valid → healthy" probe would report green
|
|
14
|
+
* while nothing is delivered — this reports `degraded` instead. Pairs with `warnPushFakeProvidersActive`
|
|
15
|
+
* (a one-time log at registration).
|
|
16
|
+
*/
|
|
17
|
+
export function makePushClientConfigHealthCheck<T>(options: {
|
|
18
|
+
schema: ZodType<T>
|
|
19
|
+
providerLabel: string
|
|
20
|
+
healthyDetails?: (parsed: T) => Record<string, unknown>
|
|
21
|
+
}): EmailHealthCheck {
|
|
22
|
+
return {
|
|
23
|
+
async check(credentials) {
|
|
24
|
+
if (isPushFakeProvidersEnabled()) {
|
|
25
|
+
return {
|
|
26
|
+
status: 'degraded',
|
|
27
|
+
message: `${options.providerLabel} is running in FAKE mode (${PUSH_FAKE_PROVIDERS_ENV} is set): pushes are recorded, not delivered. Unset this flag in production.`,
|
|
28
|
+
details: { fakeProviders: true, env: PUSH_FAKE_PROVIDERS_ENV },
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const parsed = options.schema.safeParse(credentials ?? {})
|
|
32
|
+
if (!parsed.success) {
|
|
33
|
+
const first = parsed.error.issues[0]
|
|
34
|
+
return {
|
|
35
|
+
status: 'unhealthy',
|
|
36
|
+
message: `${options.providerLabel} credentials invalid: ${first?.message ?? 'unknown validation error'}`,
|
|
37
|
+
details: { reason: 'invalid_credentials' },
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
status: 'healthy',
|
|
42
|
+
message: `${options.providerLabel} credentials configured`,
|
|
43
|
+
details: { credentialsConfigured: true, ...(options.healthyDetails?.(parsed.data) ?? {}) },
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { PushNotificationDelivery } from '../data/entities'
|
|
3
|
+
import { emitPushNotificationsEvent } from '../events'
|
|
4
|
+
import { MAX_ATTEMPTS } from './push-delivery'
|
|
5
|
+
import { resolveStuckThresholdMs } from './reclaim-window'
|
|
6
|
+
import { enqueuePushDelivery } from './queue'
|
|
7
|
+
|
|
8
|
+
// The stuck-reclaim window (minutes a row may sit in `sending`/`pending` before a dead-worker reclaim)
|
|
9
|
+
// lives in ./reclaim-window so the send-path timeout shares the same source of truth.
|
|
10
|
+
//
|
|
11
|
+
// INVARIANT: this window MUST exceed the worst-case single provider send time. `updated_at` is stamped
|
|
12
|
+
// once when the row is claimed (`pending` → `sending`) and is NOT refreshed mid-send (no heartbeat), so
|
|
13
|
+
// a legitimate send that runs longer than the window would be reclaimed and re-enqueued → a duplicate
|
|
14
|
+
// push. The send path caps each provider send below this window (see push-delivery.ts
|
|
15
|
+
// `resolvePushSendTimeoutMs`), and duplicates are otherwise bounded by MAX_ATTEMPTS (incremented at
|
|
16
|
+
// claim time) and inherent to at-least-once delivery.
|
|
17
|
+
|
|
18
|
+
// Bound the per-tick scan so a stranded backlog (provider/queue outage) cannot load an unbounded row
|
|
19
|
+
// set into memory and re-enqueue it serially in one tick. Oldest-stuck rows are drained first; the
|
|
20
|
+
// remainder is picked up by subsequent ticks. Tunable via OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT (values
|
|
21
|
+
// below MIN_RECLAIM_BATCH_LIMIT, negative, or non-numeric → default). Mirrors the receipt reaper's
|
|
22
|
+
// OM_PUSH_RECEIPT_BATCH_LIMIT.
|
|
23
|
+
const DEFAULT_RECLAIM_BATCH_LIMIT = 500
|
|
24
|
+
const MIN_RECLAIM_BATCH_LIMIT = 1
|
|
25
|
+
|
|
26
|
+
export type ReclaimStuckResult = { reEnqueued: number; expired: number }
|
|
27
|
+
|
|
28
|
+
function resolveReclaimBatchLimit(): number {
|
|
29
|
+
const raw = Number.parseInt(process.env.OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT ?? '', 10)
|
|
30
|
+
return Number.isFinite(raw) && raw >= MIN_RECLAIM_BATCH_LIMIT ? raw : DEFAULT_RECLAIM_BATCH_LIMIT
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function emitFailed(delivery: PushNotificationDelivery, willRetry: boolean): Promise<void> {
|
|
34
|
+
await emitPushNotificationsEvent(
|
|
35
|
+
'push_notifications.delivery.failed',
|
|
36
|
+
{
|
|
37
|
+
deliveryId: delivery.id,
|
|
38
|
+
tenantId: delivery.tenantId,
|
|
39
|
+
organizationId: delivery.organizationId ?? null,
|
|
40
|
+
userId: delivery.userId,
|
|
41
|
+
provider: delivery.provider,
|
|
42
|
+
status: delivery.status,
|
|
43
|
+
...(willRetry ? { willRetry: true } : {}),
|
|
44
|
+
},
|
|
45
|
+
{ persistent: true },
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recover push delivery rows stranded by a worker (or an enqueue) that never finished:
|
|
51
|
+
*
|
|
52
|
+
* - `sending` rows — a worker crashed between claiming the row (`pending` → `sending`) and finalizing
|
|
53
|
+
* it. Such a row has no outstanding job the claim can re-match.
|
|
54
|
+
* - `pending` rows — the fan-out committed the delivery row (a plain INSERT, auto-committed) but the
|
|
55
|
+
* subsequent enqueue never landed (process died, or the queue dropped the job). The row then sits
|
|
56
|
+
* `pending` forever: the send-path claim only runs when a job fires, so no job ⇒ no claim ⇒ the row
|
|
57
|
+
* is never retried, expired, nor surfaced as failed. Only a *synchronous* enqueue throw is handled
|
|
58
|
+
* inline by the fan-out; a lost job after the INSERT committed is invisible without this sweep.
|
|
59
|
+
*
|
|
60
|
+
* Both are recovered identically: re-enqueue if retry budget remains, else finalize `expired`. Driven
|
|
61
|
+
* by the `push_notifications:reclaim-stuck` scheduler tick (one per tenant), so the query is scoped to
|
|
62
|
+
* the tenant only (covers org-bound and tenant-level rows alike); there is no cross-tenant read.
|
|
63
|
+
*
|
|
64
|
+
* The stale-`updated_at < cutoff` guard is what makes sweeping `pending` safe: a freshly fan-outed row
|
|
65
|
+
* (with a live job in flight) is younger than the cutoff and is left alone; only a row that has sat
|
|
66
|
+
* past the reclaim window — long after any real job would have claimed it — is re-enqueued. A rare
|
|
67
|
+
* duplicate job is a no-op because the send-path claim is atomic (`pending` → `sending`, exactly one
|
|
68
|
+
* winner), and provider-send duplicates are bounded by MAX_ATTEMPTS.
|
|
69
|
+
*
|
|
70
|
+
* Each transition is an atomic `nativeUpdate` guarded on the row's observed `status` AND still-stale
|
|
71
|
+
* `updated_at < cutoff`, so overlapping ticks (or a worker that re-claimed the row in the meantime)
|
|
72
|
+
* can never re-open an actively-processing delivery — exactly one actor wins each row.
|
|
73
|
+
*/
|
|
74
|
+
export async function reclaimStuckPushDeliveries(
|
|
75
|
+
em: EntityManager,
|
|
76
|
+
scope: { tenantId: string },
|
|
77
|
+
now: Date = new Date(),
|
|
78
|
+
): Promise<ReclaimStuckResult> {
|
|
79
|
+
const cutoff = new Date(now.getTime() - resolveStuckThresholdMs())
|
|
80
|
+
const stuck = await em.find(
|
|
81
|
+
PushNotificationDelivery,
|
|
82
|
+
{
|
|
83
|
+
tenantId: scope.tenantId,
|
|
84
|
+
status: { $in: ['sending', 'pending'] },
|
|
85
|
+
updatedAt: { $lt: cutoff },
|
|
86
|
+
},
|
|
87
|
+
{ limit: resolveReclaimBatchLimit(), orderBy: { updatedAt: 'asc' } },
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
let reEnqueued = 0
|
|
91
|
+
let expired = 0
|
|
92
|
+
for (const delivery of stuck) {
|
|
93
|
+
const claimGuard = { id: delivery.id, tenantId: scope.tenantId, status: delivery.status, updatedAt: { $lt: cutoff } }
|
|
94
|
+
|
|
95
|
+
if (delivery.attempts >= MAX_ATTEMPTS) {
|
|
96
|
+
const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {
|
|
97
|
+
status: 'expired',
|
|
98
|
+
lastError: 'stuck_reclaimed',
|
|
99
|
+
nextRetryAt: null,
|
|
100
|
+
updatedAt: new Date(),
|
|
101
|
+
})
|
|
102
|
+
if (claimed === 0) continue
|
|
103
|
+
delivery.status = 'expired'
|
|
104
|
+
delivery.lastError = 'stuck_reclaimed'
|
|
105
|
+
await emitFailed(delivery, false)
|
|
106
|
+
expired += 1
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {
|
|
111
|
+
status: 'pending',
|
|
112
|
+
nextRetryAt: null,
|
|
113
|
+
updatedAt: new Date(),
|
|
114
|
+
})
|
|
115
|
+
if (claimed === 0) continue
|
|
116
|
+
delivery.status = 'pending'
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await enqueuePushDelivery({
|
|
120
|
+
deliveryId: delivery.id,
|
|
121
|
+
tenantId: scope.tenantId,
|
|
122
|
+
organizationId: delivery.organizationId ?? null,
|
|
123
|
+
})
|
|
124
|
+
reEnqueued += 1
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// Re-enqueue failed: don't leave the row pending with no job. Fail it terminally instead.
|
|
127
|
+
const reason = error instanceof Error ? `reclaim_enqueue_failed: ${error.message}` : 'reclaim_enqueue_failed'
|
|
128
|
+
await em.nativeUpdate(
|
|
129
|
+
PushNotificationDelivery,
|
|
130
|
+
{ id: delivery.id, tenantId: scope.tenantId, status: 'pending' },
|
|
131
|
+
{ status: 'failed', lastError: reason, nextRetryAt: null, updatedAt: new Date() },
|
|
132
|
+
)
|
|
133
|
+
delivery.status = 'failed'
|
|
134
|
+
delivery.lastError = reason
|
|
135
|
+
await emitFailed(delivery, false)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { reEnqueued, expired }
|
|
140
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { raw, type EntityName } from '@mikro-orm/core'
|
|
3
|
+
import type { ChannelAdapter } from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
4
|
+
import {
|
|
5
|
+
DEVICE_UNREGISTERED,
|
|
6
|
+
supportsReceiptChecking,
|
|
7
|
+
} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'
|
|
8
|
+
import type { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
|
|
9
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
10
|
+
import { PushNotificationDelivery } from '../data/entities'
|
|
11
|
+
import { softDeleteUnregisteredDevice } from './push-delivery'
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('push_notifications')
|
|
14
|
+
|
|
15
|
+
type Resolve = <T = unknown>(name: string) => T
|
|
16
|
+
|
|
17
|
+
interface ChannelAdapterRegistryLike {
|
|
18
|
+
get(providerKey: string): ChannelAdapter | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CredentialsServiceLike {
|
|
22
|
+
resolve(
|
|
23
|
+
integrationId: string,
|
|
24
|
+
scope: { tenantId: string; organizationId: string; userId?: string | null },
|
|
25
|
+
): Promise<Record<string, unknown> | null>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ReceiptReaperResult = { checked: number; unregistered: number }
|
|
29
|
+
|
|
30
|
+
// A receipt only exists after the provider has attempted handoff to FCM/APNs, so a just-`sent` row has
|
|
31
|
+
// nothing to poll yet. Wait MIN_AGE before the first check (Expo recommends ~15m) and stop looking after
|
|
32
|
+
// MAX_AGE — a row not resolved within the window is left as `sent` (best-effort hygiene, not a
|
|
33
|
+
// correctness guarantee). Both tunable; MAX is clamped above MIN so the window is always non-empty.
|
|
34
|
+
// Parse an int env var, falling back to `fallback` only when it is unset/non-numeric — a legitimately
|
|
35
|
+
// configured `0` is preserved (`|| fallback` would coerce it, since `0` is falsy).
|
|
36
|
+
function envInt(name: string, fallback: number): number {
|
|
37
|
+
const parsed = Number.parseInt(process.env[name] ?? '', 10)
|
|
38
|
+
return Number.isFinite(parsed) ? parsed : fallback
|
|
39
|
+
}
|
|
40
|
+
// A negative value must NOT collapse to 0 (that would poll before any receipt exists and mark the
|
|
41
|
+
// delivery `receiptChecked` forever). Preserve a legitimately-configured 0 but floor a negative back to
|
|
42
|
+
// the default — mirroring the reaper's `raw >= MIN ? raw : DEFAULT` guard (resolveReclaimBatchLimit).
|
|
43
|
+
const RECEIPT_MIN_AGE_MINUTES_RAW = envInt('OM_PUSH_RECEIPT_MIN_AGE_MINUTES', 15)
|
|
44
|
+
const RECEIPT_MIN_AGE_MS = (RECEIPT_MIN_AGE_MINUTES_RAW >= 0 ? RECEIPT_MIN_AGE_MINUTES_RAW : 15) * 60 * 1000
|
|
45
|
+
const RECEIPT_MAX_AGE_MS = Math.max(
|
|
46
|
+
RECEIPT_MIN_AGE_MS + 60 * 1000,
|
|
47
|
+
envInt('OM_PUSH_RECEIPT_MAX_AGE_MINUTES', 60) * 60 * 1000,
|
|
48
|
+
)
|
|
49
|
+
// Bound the per-tick scan so a busy tenant's `sent` backlog cannot fetch an unbounded row set.
|
|
50
|
+
const RECEIPT_BATCH_LIMIT = Math.max(1, envInt('OM_PUSH_RECEIPT_BATCH_LIMIT', 500))
|
|
51
|
+
|
|
52
|
+
/** The provider ticket id persisted by the send path, or null when there is nothing to poll. */
|
|
53
|
+
function ticketIdOf(delivery: PushNotificationDelivery): string | null {
|
|
54
|
+
const id = (delivery.providerResponse as { externalMessageId?: unknown } | null | undefined)?.externalMessageId
|
|
55
|
+
return typeof id === 'string' && id.length > 0 ? id : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A row whose receipt was already resolved on a prior sweep — skip it (marker is JSON, no migration). */
|
|
59
|
+
function isReceiptChecked(delivery: PushNotificationDelivery): boolean {
|
|
60
|
+
return (delivery.providerResponse as { receiptChecked?: unknown } | null | undefined)?.receiptChecked === true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function markReceiptChecked(delivery: PushNotificationDelivery, unregistered: boolean): void {
|
|
64
|
+
delivery.providerResponse = {
|
|
65
|
+
...(delivery.providerResponse ?? {}),
|
|
66
|
+
receiptChecked: true,
|
|
67
|
+
...(unregistered ? { unregistered: true } : {}),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Second-pass hygiene for push providers whose "device unregistered" signal arrives in an ASYNCHRONOUS
|
|
73
|
+
* receipt phase rather than the synchronous send ticket (Expo). Runs on the per-tenant reclaim tick.
|
|
74
|
+
*
|
|
75
|
+
* For each `sent` delivery old enough to have a receipt, group ticket ids by provider, and for every
|
|
76
|
+
* provider whose adapter implements the optional `PushReceiptChecker` capability, poll receipts using
|
|
77
|
+
* the same channel credentials as the send path. A `DeviceNotRegistered` receipt is routed through the
|
|
78
|
+
* SAME `softDeleteUnregisteredDevice` contract the synchronous path uses — no separate deletion path.
|
|
79
|
+
* `MessageRateExceeded` and other transient receipt errors resolve the receipt but never kill the token.
|
|
80
|
+
*
|
|
81
|
+
* Idempotent: each resolved row is marked (`providerResponse.receiptChecked`) so it is never re-polled,
|
|
82
|
+
* and `devices.deactivate` is itself idempotent, so an overlapping tick cannot double-delete.
|
|
83
|
+
*/
|
|
84
|
+
export async function checkPushReceipts(
|
|
85
|
+
em: EntityManager,
|
|
86
|
+
scope: { tenantId: string },
|
|
87
|
+
resolve: Resolve,
|
|
88
|
+
now: Date = new Date(),
|
|
89
|
+
): Promise<ReceiptReaperResult> {
|
|
90
|
+
const registry = resolve('channelAdapterRegistry') as ChannelAdapterRegistryLike | undefined
|
|
91
|
+
if (!registry) return { checked: 0, unregistered: 0 }
|
|
92
|
+
|
|
93
|
+
// Exclude non-workable rows in SQL, not in memory. A resolved row keeps `status: 'sent'` (the
|
|
94
|
+
// `receiptChecked` marker lives in the `provider_response` JSON, not the status column), and rows
|
|
95
|
+
// with no pollable ticket id (fcm/apns report unregistered synchronously and persist an empty
|
|
96
|
+
// `externalMessageId`) can never be receipt-checked. Filtering those in memory after a
|
|
97
|
+
// `LIMIT … ORDER BY sent_at ASC` fetch lets an oldest-first backlog of them fill the bounded batch
|
|
98
|
+
// and starve every still-unchecked Expo row in the tenant — the batch does zero work forever until
|
|
99
|
+
// the backlog ages past MAX_AGE. Push both predicates into the query so the budget only ever loads
|
|
100
|
+
// rows that can actually make progress. (`provider_response` is `json`; `->>` reads it as text.)
|
|
101
|
+
const workableRow = raw(
|
|
102
|
+
`(nullif(provider_response->>'externalMessageId', '') is not null and coalesce((provider_response->>'receiptChecked')::boolean, false) = false)`,
|
|
103
|
+
)
|
|
104
|
+
const candidates = await em.find(
|
|
105
|
+
PushNotificationDelivery,
|
|
106
|
+
{
|
|
107
|
+
tenantId: scope.tenantId,
|
|
108
|
+
status: 'sent',
|
|
109
|
+
sentAt: { $gte: new Date(now.getTime() - RECEIPT_MAX_AGE_MS), $lte: new Date(now.getTime() - RECEIPT_MIN_AGE_MS) },
|
|
110
|
+
[workableRow as unknown as string]: true,
|
|
111
|
+
},
|
|
112
|
+
{ limit: RECEIPT_BATCH_LIMIT, orderBy: { sentAt: 'asc' } },
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Group unchecked rows that have a real ticket id by provider. Providers whose adapter doesn't
|
|
116
|
+
// implement receipt checking (fcm/apns) never issue a network call — they are dropped below.
|
|
117
|
+
const byProvider = new Map<string, { ticketId: string; delivery: PushNotificationDelivery }[]>()
|
|
118
|
+
for (const delivery of candidates) {
|
|
119
|
+
if (isReceiptChecked(delivery)) continue
|
|
120
|
+
const ticketId = ticketIdOf(delivery)
|
|
121
|
+
if (!ticketId) continue
|
|
122
|
+
const list = byProvider.get(delivery.provider) ?? []
|
|
123
|
+
list.push({ ticketId, delivery })
|
|
124
|
+
byProvider.set(delivery.provider, list)
|
|
125
|
+
}
|
|
126
|
+
if (byProvider.size === 0) return { checked: 0, unregistered: 0 }
|
|
127
|
+
|
|
128
|
+
let credentialsService: CredentialsServiceLike | undefined
|
|
129
|
+
try {
|
|
130
|
+
credentialsService = resolve('integrationCredentialsService') as CredentialsServiceLike | undefined
|
|
131
|
+
} catch {
|
|
132
|
+
credentialsService = undefined
|
|
133
|
+
}
|
|
134
|
+
const ChannelRef = resolve('CommunicationChannel') as EntityName<CommunicationChannel>
|
|
135
|
+
|
|
136
|
+
let checked = 0
|
|
137
|
+
let unregistered = 0
|
|
138
|
+
|
|
139
|
+
for (const [provider, entries] of byProvider) {
|
|
140
|
+
const adapter = registry.get(provider)
|
|
141
|
+
if (!adapter || !supportsReceiptChecking(adapter)) continue
|
|
142
|
+
|
|
143
|
+
const channel = await em.findOne(ChannelRef, {
|
|
144
|
+
tenantId: scope.tenantId,
|
|
145
|
+
providerKey: provider,
|
|
146
|
+
channelType: 'push',
|
|
147
|
+
isActive: true,
|
|
148
|
+
deletedAt: null,
|
|
149
|
+
})
|
|
150
|
+
if (!channel) continue
|
|
151
|
+
|
|
152
|
+
// Credentials are keyed by the CHANNEL's org context, mirroring the send path (see push-delivery).
|
|
153
|
+
const credentialScope = {
|
|
154
|
+
tenantId: scope.tenantId,
|
|
155
|
+
organizationId: channel.organizationId ?? scope.tenantId,
|
|
156
|
+
userId: channel.userId ?? null,
|
|
157
|
+
}
|
|
158
|
+
let credentials: Record<string, unknown> = {}
|
|
159
|
+
if (channel.credentialsRef && credentialsService) {
|
|
160
|
+
credentials = (await credentialsService.resolve(`channel_${provider}`, credentialScope).catch(() => null)) ?? {}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const byTicket = new Map<string, PushNotificationDelivery[]>()
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
const list = byTicket.get(entry.ticketId) ?? []
|
|
166
|
+
list.push(entry.delivery)
|
|
167
|
+
byTicket.set(entry.ticketId, list)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let outcomes
|
|
171
|
+
try {
|
|
172
|
+
outcomes = await adapter.checkReceipts([...byTicket.keys()], credentials)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.error('Push receipt check failed', {
|
|
175
|
+
tenantId: scope.tenantId,
|
|
176
|
+
provider,
|
|
177
|
+
error: error instanceof Error ? error.message : String(error),
|
|
178
|
+
})
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Persist the receipt-checked marks first, then run the device deactivations AFTER the flush so a
|
|
183
|
+
// command that reads on the same EntityManager can never discard the pending scalar marks.
|
|
184
|
+
const toDeactivate: Array<{ id: string; tenantId: string; userId: string; organizationId: string | null }> = []
|
|
185
|
+
for (const outcome of outcomes) {
|
|
186
|
+
const deliveries = byTicket.get(outcome.ticketId)
|
|
187
|
+
if (!deliveries) continue
|
|
188
|
+
for (const delivery of deliveries) {
|
|
189
|
+
markReceiptChecked(delivery, outcome.unregistered)
|
|
190
|
+
checked += 1
|
|
191
|
+
if (outcome.unregistered) {
|
|
192
|
+
delivery.lastError = DEVICE_UNREGISTERED
|
|
193
|
+
toDeactivate.push({
|
|
194
|
+
id: delivery.userDeviceId,
|
|
195
|
+
tenantId: delivery.tenantId,
|
|
196
|
+
userId: delivery.userId,
|
|
197
|
+
organizationId: delivery.organizationId ?? null,
|
|
198
|
+
})
|
|
199
|
+
unregistered += 1
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await em.flush()
|
|
204
|
+
for (const input of toDeactivate) {
|
|
205
|
+
await softDeleteUnregisteredDevice(resolve, input)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { checked, unregistered }
|
|
210
|
+
}
|