@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,138 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
+
import { defaultLocale } from "@open-mercato/shared/lib/i18n/config";
|
|
4
|
+
import { resolveSupportedLocale } from "@open-mercato/shared/lib/i18n/locale";
|
|
5
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
6
|
+
import {
|
|
7
|
+
resolveNotificationCopy
|
|
8
|
+
} from "@open-mercato/core/modules/notifications/lib/notificationCopy";
|
|
9
|
+
import { enqueuePushDelivery } from "./queue.js";
|
|
10
|
+
const logger = createLogger("push_notifications");
|
|
11
|
+
const PUSH_CHANNEL = "push";
|
|
12
|
+
function tokenSnapshot(token) {
|
|
13
|
+
return token.slice(-8);
|
|
14
|
+
}
|
|
15
|
+
async function fanOutPushDeliveries(args) {
|
|
16
|
+
const { em, resolve, scope, userId, userDeviceId, notificationId, notificationTypeId, payload, copy } = args;
|
|
17
|
+
const { tenantId, organizationId } = scope;
|
|
18
|
+
const silent = payload.silent === true;
|
|
19
|
+
const ChannelRef = resolve("CommunicationChannel");
|
|
20
|
+
const channels = await em.find(
|
|
21
|
+
ChannelRef,
|
|
22
|
+
{
|
|
23
|
+
tenantId,
|
|
24
|
+
channelType: PUSH_CHANNEL,
|
|
25
|
+
isActive: true,
|
|
26
|
+
deletedAt: null
|
|
27
|
+
},
|
|
28
|
+
{ orderBy: { createdAt: "asc" } }
|
|
29
|
+
);
|
|
30
|
+
if (channels.length === 0) return { enqueued: 0 };
|
|
31
|
+
const channelsByProvider = /* @__PURE__ */ new Map();
|
|
32
|
+
for (const channel of channels) {
|
|
33
|
+
if (!channelsByProvider.has(channel.providerKey)) channelsByProvider.set(channel.providerKey, channel);
|
|
34
|
+
}
|
|
35
|
+
const DeviceRef = resolve("UserDevice");
|
|
36
|
+
const devices = await findWithDecryption(
|
|
37
|
+
em,
|
|
38
|
+
DeviceRef,
|
|
39
|
+
{
|
|
40
|
+
tenantId,
|
|
41
|
+
organizationId,
|
|
42
|
+
userId,
|
|
43
|
+
// Optionally restrict to a single device; still scoped to this user so a foreign id matches none.
|
|
44
|
+
...userDeviceId ? { id: userDeviceId } : {},
|
|
45
|
+
deletedAt: null,
|
|
46
|
+
pushToken: { $ne: null }
|
|
47
|
+
},
|
|
48
|
+
void 0,
|
|
49
|
+
{ tenantId, organizationId }
|
|
50
|
+
);
|
|
51
|
+
if (devices.length === 0) return { enqueued: 0 };
|
|
52
|
+
const localizedByLocale = /* @__PURE__ */ new Map();
|
|
53
|
+
function resolveLocalizedPayload(deviceLocale) {
|
|
54
|
+
if (!copy) return Promise.resolve(payload);
|
|
55
|
+
const locale = resolveSupportedLocale(deviceLocale) ?? defaultLocale;
|
|
56
|
+
let cached = localizedByLocale.get(locale);
|
|
57
|
+
if (!cached) {
|
|
58
|
+
cached = (async () => {
|
|
59
|
+
if (locale === defaultLocale) {
|
|
60
|
+
return { ...payload, title: copy.title, body: copy.body ?? null };
|
|
61
|
+
}
|
|
62
|
+
const { title, body } = await resolveNotificationCopy(copy, locale);
|
|
63
|
+
return { ...payload, title, body };
|
|
64
|
+
})();
|
|
65
|
+
localizedByLocale.set(locale, cached);
|
|
66
|
+
}
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
69
|
+
const builtRows = await Promise.all(
|
|
70
|
+
devices.map(async (device) => {
|
|
71
|
+
const providerKey = device.pushProvider;
|
|
72
|
+
if (!providerKey) return null;
|
|
73
|
+
const channel = channelsByProvider.get(providerKey);
|
|
74
|
+
if (!channel) return null;
|
|
75
|
+
const rowPayload = await resolveLocalizedPayload(device.locale);
|
|
76
|
+
return {
|
|
77
|
+
tenant_id: tenantId,
|
|
78
|
+
organization_id: organizationId,
|
|
79
|
+
notification_id: notificationId,
|
|
80
|
+
notification_type_id: notificationTypeId,
|
|
81
|
+
user_device_id: device.id,
|
|
82
|
+
user_id: userId,
|
|
83
|
+
provider: channel.providerKey,
|
|
84
|
+
token_snapshot: tokenSnapshot(device.pushToken),
|
|
85
|
+
silent,
|
|
86
|
+
status: "pending",
|
|
87
|
+
attempts: 0,
|
|
88
|
+
payload: sql`${JSON.stringify(rowPayload)}::jsonb`,
|
|
89
|
+
created_at: sql`now()`,
|
|
90
|
+
updated_at: sql`now()`
|
|
91
|
+
};
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
const rows = builtRows.filter((row) => row !== null);
|
|
95
|
+
const skippedNoChannel = devices.length - rows.length;
|
|
96
|
+
if (skippedNoChannel > 0) {
|
|
97
|
+
logger.warn("Skipped devices with no matching push channel", {
|
|
98
|
+
tenantId,
|
|
99
|
+
userId,
|
|
100
|
+
notificationId,
|
|
101
|
+
skipped: skippedNoChannel
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (rows.length === 0) return { enqueued: 0 };
|
|
105
|
+
const db = em.getKysely();
|
|
106
|
+
const inserted = await db.insertInto("push_notification_deliveries").values(rows).onConflict(
|
|
107
|
+
(oc) => oc.columns(["notification_id", "user_device_id"]).where("notification_id", "is not", null).doNothing()
|
|
108
|
+
).returning(["id"]).execute();
|
|
109
|
+
const outcomes = await Promise.allSettled(
|
|
110
|
+
inserted.map((row) => enqueuePushDelivery({ deliveryId: row.id, tenantId, organizationId }))
|
|
111
|
+
);
|
|
112
|
+
const failedIdsByReason = /* @__PURE__ */ new Map();
|
|
113
|
+
let enqueued = 0;
|
|
114
|
+
outcomes.forEach((outcome, index) => {
|
|
115
|
+
if (outcome.status === "fulfilled") {
|
|
116
|
+
enqueued += 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const error = outcome.reason;
|
|
120
|
+
const reason = error instanceof Error ? `enqueue_failed: ${error.message}` : "enqueue_failed";
|
|
121
|
+
const ids = failedIdsByReason.get(reason) ?? [];
|
|
122
|
+
ids.push(inserted[index].id);
|
|
123
|
+
failedIdsByReason.set(reason, ids);
|
|
124
|
+
});
|
|
125
|
+
if (failedIdsByReason.size > 0) {
|
|
126
|
+
await Promise.allSettled(
|
|
127
|
+
Array.from(failedIdsByReason.entries()).map(
|
|
128
|
+
([reason, ids]) => db.updateTable("push_notification_deliveries").set({ status: "failed", last_error: reason, updated_at: sql`now()` }).where("id", "in", ids).where("status", "=", "pending").execute()
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return { enqueued };
|
|
133
|
+
}
|
|
134
|
+
export {
|
|
135
|
+
PUSH_CHANNEL,
|
|
136
|
+
fanOutPushDeliveries
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=push-fanout.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/push_notifications/lib/push-fanout.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { EntityName } from '@mikro-orm/core'\nimport { sql } from 'kysely'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { PushOptions } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'\nimport type { UserDevice } from '@open-mercato/core/modules/devices/data/entities'\nimport type { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { defaultLocale } from '@open-mercato/shared/lib/i18n/config'\nimport { resolveSupportedLocale } from '@open-mercato/shared/lib/i18n/locale'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n resolveNotificationCopy,\n type NotificationCopySource,\n} from '@open-mercato/core/modules/notifications/lib/notificationCopy'\nimport { enqueuePushDelivery } from './queue'\n\nconst logger = createLogger('push_notifications')\n\nexport const PUSH_CHANNEL = 'push'\n\ntype Resolve = <T = unknown>(name: string) => T\n\n/** The push payload persisted on each delivery row and unpacked by the worker into the send envelope. */\nexport interface PushFanoutPayload {\n title?: string\n body?: string | null\n data: Record<string, string>\n options?: PushOptions\n silent?: boolean\n}\n\nexport interface FanOutPushDeliveriesArgs {\n em: EntityManager\n resolve: Resolve\n scope: { tenantId: string; organizationId: string | null }\n userId: string\n /**\n * Restrict the fan-out to a single one of the user's devices (`UserDevice.id`). Still scoped to the\n * same (tenant, org, user), so an id that isn't the user's own matches nothing. Omit to fan out to\n * all of the user's push-capable devices (the default).\n */\n userDeviceId?: string\n /** Source in-app notification id, or `null` for a silent push (no Notification row). */\n notificationId: string | null\n notificationTypeId: string\n payload: PushFanoutPayload\n /**\n * Per-device localizable copy for visible notifications. When present, `title`/`body` are\n * resolved per device using its `locale`. Omit for silent pushes (no user-facing copy).\n */\n copy?: NotificationCopySource\n}\n\nfunction tokenSnapshot(token: string): string {\n // Persist at most the last 8 chars of the (long) provider token \u2014 never the full secret.\n return token.slice(-8)\n}\n\n/**\n * Resolve a recipient's push-capable devices, route each to its provider's tenant push\n * `CommunicationChannel`, persist one `pending` push delivery row per device, and enqueue a send\n * job per row. Shared by the `push` delivery strategy (visible + silent notifications) and\n * `sendCustomPush` (admin one-off pushes); it is preference-agnostic \u2014 the caller decides whether\n * to consult per-channel preferences before fanning out.\n *\n * Cross-module entities are resolved via DI tokens (registered `asValue` by their owning modules)\n * so this stays decoupled from those modules' internals. Returns the number of jobs enqueued.\n */\nexport async function fanOutPushDeliveries(args: FanOutPushDeliveriesArgs): Promise<{ enqueued: number }> {\n const { em, resolve, scope, userId, userDeviceId, notificationId, notificationTypeId, payload, copy } = args\n const { tenantId, organizationId } = scope\n const silent = payload.silent === true\n\n // Require at least one active push CommunicationChannel for the tenant (push not configured \u21D2 skip).\n // This is the cheapest, most selective short-circuit (most tenants have no push channel), so it runs\n // before the per-recipient device lookup. Channels are indexed by providerKey so each device can be\n // routed to its matching provider below (ios\u2192apns, android\u2192fcm, expo\u2192expo). Oldest-first so that when\n // a tenant has more than one active channel for the same provider, the pick is deterministic.\n const ChannelRef = resolve('CommunicationChannel') as EntityName<CommunicationChannel>\n const channels = await em.find(\n ChannelRef,\n {\n tenantId,\n channelType: PUSH_CHANNEL,\n isActive: true,\n deletedAt: null,\n },\n { orderBy: { createdAt: 'asc' } },\n )\n if (channels.length === 0) return { enqueued: 0 }\n const channelsByProvider = new Map<string, CommunicationChannel>()\n for (const channel of channels) {\n if (!channelsByProvider.has(channel.providerKey)) channelsByProvider.set(channel.providerKey, channel)\n }\n\n // Load the recipient's devices that can receive push (active + has a token). Scoped to the\n // notification's organization so an org-scoped notification never fans out to a device the user\n // registered under a different org \u2014 device identity is per (tenant, org, user, device) (see devices\n // module). A tenant-level (null-org) notification targets the user's tenant-level (null-org) devices.\n // `push_token` is encrypted at rest; decrypt on read (no-op when encryption is disabled).\n const DeviceRef = resolve('UserDevice') as EntityName<UserDevice>\n const devices = await findWithDecryption(\n em,\n DeviceRef,\n {\n tenantId,\n organizationId,\n userId,\n // Optionally restrict to a single device; still scoped to this user so a foreign id matches none.\n ...(userDeviceId ? { id: userDeviceId } : {}),\n deletedAt: null,\n pushToken: { $ne: null },\n },\n undefined,\n { tenantId, organizationId },\n )\n if (devices.length === 0) return { enqueued: 0 }\n\n // Resolve the per-device localized copy. The default-locale case (the common one: device has no\n // locale, an unsupported one, or already speaks the default) reuses the copy already resolved\n // upstream \u2014 no dictionary load. Other locales translate via the shared notification-copy helper\n // (dictionaries are memoized by loadDictionary). Silent pushes carry no copy and skip this.\n // Memoized by resolved locale so a user with many same-locale devices derives copy once\n // (O(distinct locales) instead of O(devices)).\n const localizedByLocale = new Map<string, Promise<PushFanoutPayload>>()\n function resolveLocalizedPayload(deviceLocale?: string | null): Promise<PushFanoutPayload> {\n if (!copy) return Promise.resolve(payload)\n const locale = resolveSupportedLocale(deviceLocale) ?? defaultLocale\n let cached = localizedByLocale.get(locale)\n if (!cached) {\n cached = (async () => {\n if (locale === defaultLocale) {\n return { ...payload, title: copy.title, body: copy.body ?? null }\n }\n const { title, body } = await resolveNotificationCopy(copy, locale)\n return { ...payload, title, body }\n })()\n localizedByLocale.set(locale, cached)\n }\n return cached\n }\n\n // Insert one pending delivery row per device, routing each device to the push channel whose\n // providerKey matches the device's pushProvider. Devices with no provider, or no matching configured\n // channel, are skipped. Insert via INSERT ... ON CONFLICT DO NOTHING on the (notification_id,\n // user_device_id) partial unique index: the `push` strategy runs inside the at-least-once persistent\n // `notifications:deliver` subscriber, so a redelivered event re-runs it \u2014 the conflict clause makes\n // the re-fan-out a no-op instead of inserting a duplicate set of rows (and duplicate pushes). Silent\n // pushes carry a null notification_id and are excluded from the partial index (they are triggered\n // explicitly, not via at-least-once redelivery). Only the rows actually inserted are enqueued.\n const builtRows = await Promise.all(\n devices.map(async (device) => {\n const providerKey = device.pushProvider\n if (!providerKey) return null\n const channel = channelsByProvider.get(providerKey)\n if (!channel) return null\n const rowPayload = await resolveLocalizedPayload(device.locale)\n return {\n tenant_id: tenantId,\n organization_id: organizationId,\n notification_id: notificationId,\n notification_type_id: notificationTypeId,\n user_device_id: device.id,\n user_id: userId,\n provider: channel.providerKey,\n token_snapshot: tokenSnapshot(device.pushToken as string),\n silent,\n status: 'pending',\n attempts: 0,\n payload: sql`${JSON.stringify(rowPayload)}::jsonb`,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n }\n }),\n )\n const rows = builtRows.filter((row): row is NonNullable<typeof row> => row !== null)\n // A registered device whose pushProvider has no matching active channel (e.g. an Expo device on an\n // FCM-only tenant) is correctly skipped above \u2014 but it produces no delivery row and would otherwise\n // be an invisible no-op. Surface a count so a provider-config gap is diagnosable.\n const skippedNoChannel = devices.length - rows.length\n if (skippedNoChannel > 0) {\n logger.warn('Skipped devices with no matching push channel', {\n tenantId,\n userId,\n notificationId,\n skipped: skippedNoChannel,\n })\n }\n if (rows.length === 0) return { enqueued: 0 }\n\n const db = em.getKysely<any>()\n const inserted = (await db\n .insertInto('push_notification_deliveries')\n .values(rows)\n .onConflict((oc: any) =>\n oc.columns(['notification_id', 'user_device_id']).where('notification_id', 'is not', null).doNothing(),\n )\n .returning(['id'])\n .execute()) as Array<{ id: string }>\n\n // Enqueue one send job per inserted row (per-device retry isolation, idempotent on delivery id).\n // Enqueue all rows concurrently \u2014 the jobs are independent, so there is no reason to serialize the\n // queue round-trips. If an enqueue fails, mark that row failed instead of leaving it orphaned in\n // `pending` forever (the worker only ever processes rows it receives a job for). Failed rows are\n // grouped by reason and marked in one UPDATE per reason to keep DB round-trips minimal.\n const outcomes = await Promise.allSettled(\n inserted.map((row) => enqueuePushDelivery({ deliveryId: row.id, tenantId, organizationId })),\n )\n const failedIdsByReason = new Map<string, string[]>()\n let enqueued = 0\n outcomes.forEach((outcome, index) => {\n if (outcome.status === 'fulfilled') {\n enqueued += 1\n return\n }\n const error = outcome.reason\n const reason = error instanceof Error ? `enqueue_failed: ${error.message}` : 'enqueue_failed'\n const ids = failedIdsByReason.get(reason) ?? []\n ids.push(inserted[index].id)\n failedIdsByReason.set(reason, ids)\n })\n if (failedIdsByReason.size > 0) {\n await Promise.allSettled(\n Array.from(failedIdsByReason.entries()).map(([reason, ids]) =>\n db\n .updateTable('push_notification_deliveries')\n .set({ status: 'failed', last_error: reason, updated_at: sql`now()` })\n .where('id', 'in', ids)\n // Guard on `pending`: if the enqueue reported failure yet the job actually landed and a\n // worker already claimed/finished the row, don't clobber its later status back to `failed`.\n .where('status', '=', 'pending')\n .execute(),\n ),\n )\n }\n return { enqueued }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isPushFakeProvidersEnabled, PUSH_FAKE_PROVIDERS_ENV } from "./fake-provider-recorder.js";
|
|
2
|
+
function makePushClientConfigHealthCheck(options) {
|
|
3
|
+
return {
|
|
4
|
+
async check(credentials) {
|
|
5
|
+
if (isPushFakeProvidersEnabled()) {
|
|
6
|
+
return {
|
|
7
|
+
status: "degraded",
|
|
8
|
+
message: `${options.providerLabel} is running in FAKE mode (${PUSH_FAKE_PROVIDERS_ENV} is set): pushes are recorded, not delivered. Unset this flag in production.`,
|
|
9
|
+
details: { fakeProviders: true, env: PUSH_FAKE_PROVIDERS_ENV }
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const parsed = options.schema.safeParse(credentials ?? {});
|
|
13
|
+
if (!parsed.success) {
|
|
14
|
+
const first = parsed.error.issues[0];
|
|
15
|
+
return {
|
|
16
|
+
status: "unhealthy",
|
|
17
|
+
message: `${options.providerLabel} credentials invalid: ${first?.message ?? "unknown validation error"}`,
|
|
18
|
+
details: { reason: "invalid_credentials" }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
status: "healthy",
|
|
23
|
+
message: `${options.providerLabel} credentials configured`,
|
|
24
|
+
details: { credentialsConfigured: true, ...options.healthyDetails?.(parsed.data) ?? {} }
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
makePushClientConfigHealthCheck
|
|
31
|
+
};
|
|
32
|
+
//# sourceMappingURL=push-health.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/push_notifications/lib/push-health.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ZodType } from 'zod'\nimport type { EmailHealthCheck } from '@open-mercato/core/modules/communication_channels/lib/provider-health'\nimport { isPushFakeProvidersEnabled, PUSH_FAKE_PROVIDERS_ENV } from './fake-provider-recorder'\n\n/**\n * Liveness probe for a push provider (FCM/APNs/Expo).\n *\n * A push provider has NO OAuth client \u2014 its credentials are a service-account JSON, a `.p8` signing\n * key, or an Expo access token. So this probe validates the tenant-scoped credentials against the\n * provider schema and reports push-appropriate copy, rather than the OAuth-client wording the shared\n * `makeClientConfigHealthCheck` emits (which is correct for the Gmail-style channels but misleading\n * here). It additionally surfaces fake-provider mode: when `OM_PUSH_FAKE_PROVIDERS` is set every send is\n * short-circuited to a recorded no-op, so a plain \"credentials valid \u2192 healthy\" probe would report green\n * while nothing is delivered \u2014 this reports `degraded` instead. Pairs with `warnPushFakeProvidersActive`\n * (a one-time log at registration).\n */\nexport function makePushClientConfigHealthCheck<T>(options: {\n schema: ZodType<T>\n providerLabel: string\n healthyDetails?: (parsed: T) => Record<string, unknown>\n}): EmailHealthCheck {\n return {\n async check(credentials) {\n if (isPushFakeProvidersEnabled()) {\n return {\n status: 'degraded',\n message: `${options.providerLabel} is running in FAKE mode (${PUSH_FAKE_PROVIDERS_ENV} is set): pushes are recorded, not delivered. Unset this flag in production.`,\n details: { fakeProviders: true, env: PUSH_FAKE_PROVIDERS_ENV },\n }\n }\n const parsed = options.schema.safeParse(credentials ?? {})\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n return {\n status: 'unhealthy',\n message: `${options.providerLabel} credentials invalid: ${first?.message ?? 'unknown validation error'}`,\n details: { reason: 'invalid_credentials' },\n }\n }\n return {\n status: 'healthy',\n message: `${options.providerLabel} credentials configured`,\n details: { credentialsConfigured: true, ...(options.healthyDetails?.(parsed.data) ?? {}) },\n }\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,4BAA4B,+BAA+B;AAc7D,SAAS,gCAAmC,SAI9B;AACnB,SAAO;AAAA,IACL,MAAM,MAAM,aAAa;AACvB,UAAI,2BAA2B,GAAG;AAChC,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,SAAS,GAAG,QAAQ,aAAa,6BAA6B,uBAAuB;AAAA,UACrF,SAAS,EAAE,eAAe,MAAM,KAAK,wBAAwB;AAAA,QAC/D;AAAA,MACF;AACA,YAAM,SAAS,QAAQ,OAAO,UAAU,eAAe,CAAC,CAAC;AACzD,UAAI,CAAC,OAAO,SAAS;AACnB,cAAM,QAAQ,OAAO,MAAM,OAAO,CAAC;AACnC,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,SAAS,GAAG,QAAQ,aAAa,yBAAyB,OAAO,WAAW,0BAA0B;AAAA,UACtG,SAAS,EAAE,QAAQ,sBAAsB;AAAA,QAC3C;AAAA,MACF;AACA,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS,GAAG,QAAQ,aAAa;AAAA,QACjC,SAAS,EAAE,uBAAuB,MAAM,GAAI,QAAQ,iBAAiB,OAAO,IAAI,KAAK,CAAC,EAAG;AAAA,MAC3F;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { PushNotificationDelivery } from "../data/entities.js";
|
|
2
|
+
import { emitPushNotificationsEvent } from "../events.js";
|
|
3
|
+
import { MAX_ATTEMPTS } from "./push-delivery.js";
|
|
4
|
+
import { resolveStuckThresholdMs } from "./reclaim-window.js";
|
|
5
|
+
import { enqueuePushDelivery } from "./queue.js";
|
|
6
|
+
const DEFAULT_RECLAIM_BATCH_LIMIT = 500;
|
|
7
|
+
const MIN_RECLAIM_BATCH_LIMIT = 1;
|
|
8
|
+
function resolveReclaimBatchLimit() {
|
|
9
|
+
const raw = Number.parseInt(process.env.OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT ?? "", 10);
|
|
10
|
+
return Number.isFinite(raw) && raw >= MIN_RECLAIM_BATCH_LIMIT ? raw : DEFAULT_RECLAIM_BATCH_LIMIT;
|
|
11
|
+
}
|
|
12
|
+
async function emitFailed(delivery, willRetry) {
|
|
13
|
+
await emitPushNotificationsEvent(
|
|
14
|
+
"push_notifications.delivery.failed",
|
|
15
|
+
{
|
|
16
|
+
deliveryId: delivery.id,
|
|
17
|
+
tenantId: delivery.tenantId,
|
|
18
|
+
organizationId: delivery.organizationId ?? null,
|
|
19
|
+
userId: delivery.userId,
|
|
20
|
+
provider: delivery.provider,
|
|
21
|
+
status: delivery.status,
|
|
22
|
+
...willRetry ? { willRetry: true } : {}
|
|
23
|
+
},
|
|
24
|
+
{ persistent: true }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
async function reclaimStuckPushDeliveries(em, scope, now = /* @__PURE__ */ new Date()) {
|
|
28
|
+
const cutoff = new Date(now.getTime() - resolveStuckThresholdMs());
|
|
29
|
+
const stuck = await em.find(
|
|
30
|
+
PushNotificationDelivery,
|
|
31
|
+
{
|
|
32
|
+
tenantId: scope.tenantId,
|
|
33
|
+
status: { $in: ["sending", "pending"] },
|
|
34
|
+
updatedAt: { $lt: cutoff }
|
|
35
|
+
},
|
|
36
|
+
{ limit: resolveReclaimBatchLimit(), orderBy: { updatedAt: "asc" } }
|
|
37
|
+
);
|
|
38
|
+
let reEnqueued = 0;
|
|
39
|
+
let expired = 0;
|
|
40
|
+
for (const delivery of stuck) {
|
|
41
|
+
const claimGuard = { id: delivery.id, tenantId: scope.tenantId, status: delivery.status, updatedAt: { $lt: cutoff } };
|
|
42
|
+
if (delivery.attempts >= MAX_ATTEMPTS) {
|
|
43
|
+
const claimed2 = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {
|
|
44
|
+
status: "expired",
|
|
45
|
+
lastError: "stuck_reclaimed",
|
|
46
|
+
nextRetryAt: null,
|
|
47
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
48
|
+
});
|
|
49
|
+
if (claimed2 === 0) continue;
|
|
50
|
+
delivery.status = "expired";
|
|
51
|
+
delivery.lastError = "stuck_reclaimed";
|
|
52
|
+
await emitFailed(delivery, false);
|
|
53
|
+
expired += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {
|
|
57
|
+
status: "pending",
|
|
58
|
+
nextRetryAt: null,
|
|
59
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
60
|
+
});
|
|
61
|
+
if (claimed === 0) continue;
|
|
62
|
+
delivery.status = "pending";
|
|
63
|
+
try {
|
|
64
|
+
await enqueuePushDelivery({
|
|
65
|
+
deliveryId: delivery.id,
|
|
66
|
+
tenantId: scope.tenantId,
|
|
67
|
+
organizationId: delivery.organizationId ?? null
|
|
68
|
+
});
|
|
69
|
+
reEnqueued += 1;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const reason = error instanceof Error ? `reclaim_enqueue_failed: ${error.message}` : "reclaim_enqueue_failed";
|
|
72
|
+
await em.nativeUpdate(
|
|
73
|
+
PushNotificationDelivery,
|
|
74
|
+
{ id: delivery.id, tenantId: scope.tenantId, status: "pending" },
|
|
75
|
+
{ status: "failed", lastError: reason, nextRetryAt: null, updatedAt: /* @__PURE__ */ new Date() }
|
|
76
|
+
);
|
|
77
|
+
delivery.status = "failed";
|
|
78
|
+
delivery.lastError = reason;
|
|
79
|
+
await emitFailed(delivery, false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { reEnqueued, expired };
|
|
83
|
+
}
|
|
84
|
+
export {
|
|
85
|
+
reclaimStuckPushDeliveries
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=push-reaper.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/push_notifications/lib/push-reaper.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { PushNotificationDelivery } from '../data/entities'\nimport { emitPushNotificationsEvent } from '../events'\nimport { MAX_ATTEMPTS } from './push-delivery'\nimport { resolveStuckThresholdMs } from './reclaim-window'\nimport { enqueuePushDelivery } from './queue'\n\n// The stuck-reclaim window (minutes a row may sit in `sending`/`pending` before a dead-worker reclaim)\n// lives in ./reclaim-window so the send-path timeout shares the same source of truth.\n//\n// INVARIANT: this window MUST exceed the worst-case single provider send time. `updated_at` is stamped\n// once when the row is claimed (`pending` \u2192 `sending`) and is NOT refreshed mid-send (no heartbeat), so\n// a legitimate send that runs longer than the window would be reclaimed and re-enqueued \u2192 a duplicate\n// push. The send path caps each provider send below this window (see push-delivery.ts\n// `resolvePushSendTimeoutMs`), and duplicates are otherwise bounded by MAX_ATTEMPTS (incremented at\n// claim time) and inherent to at-least-once delivery.\n\n// Bound the per-tick scan so a stranded backlog (provider/queue outage) cannot load an unbounded row\n// set into memory and re-enqueue it serially in one tick. Oldest-stuck rows are drained first; the\n// remainder is picked up by subsequent ticks. Tunable via OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT (values\n// below MIN_RECLAIM_BATCH_LIMIT, negative, or non-numeric \u2192 default). Mirrors the receipt reaper's\n// OM_PUSH_RECEIPT_BATCH_LIMIT.\nconst DEFAULT_RECLAIM_BATCH_LIMIT = 500\nconst MIN_RECLAIM_BATCH_LIMIT = 1\n\nexport type ReclaimStuckResult = { reEnqueued: number; expired: number }\n\nfunction resolveReclaimBatchLimit(): number {\n const raw = Number.parseInt(process.env.OM_PUSH_STUCK_RECLAIM_BATCH_LIMIT ?? '', 10)\n return Number.isFinite(raw) && raw >= MIN_RECLAIM_BATCH_LIMIT ? raw : DEFAULT_RECLAIM_BATCH_LIMIT\n}\n\nasync function emitFailed(delivery: PushNotificationDelivery, willRetry: boolean): Promise<void> {\n await emitPushNotificationsEvent(\n 'push_notifications.delivery.failed',\n {\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId ?? null,\n userId: delivery.userId,\n provider: delivery.provider,\n status: delivery.status,\n ...(willRetry ? { willRetry: true } : {}),\n },\n { persistent: true },\n )\n}\n\n/**\n * Recover push delivery rows stranded by a worker (or an enqueue) that never finished:\n *\n * - `sending` rows \u2014 a worker crashed between claiming the row (`pending` \u2192 `sending`) and finalizing\n * it. Such a row has no outstanding job the claim can re-match.\n * - `pending` rows \u2014 the fan-out committed the delivery row (a plain INSERT, auto-committed) but the\n * subsequent enqueue never landed (process died, or the queue dropped the job). The row then sits\n * `pending` forever: the send-path claim only runs when a job fires, so no job \u21D2 no claim \u21D2 the row\n * is never retried, expired, nor surfaced as failed. Only a *synchronous* enqueue throw is handled\n * inline by the fan-out; a lost job after the INSERT committed is invisible without this sweep.\n *\n * Both are recovered identically: re-enqueue if retry budget remains, else finalize `expired`. Driven\n * by the `push_notifications:reclaim-stuck` scheduler tick (one per tenant), so the query is scoped to\n * the tenant only (covers org-bound and tenant-level rows alike); there is no cross-tenant read.\n *\n * The stale-`updated_at < cutoff` guard is what makes sweeping `pending` safe: a freshly fan-outed row\n * (with a live job in flight) is younger than the cutoff and is left alone; only a row that has sat\n * past the reclaim window \u2014 long after any real job would have claimed it \u2014 is re-enqueued. A rare\n * duplicate job is a no-op because the send-path claim is atomic (`pending` \u2192 `sending`, exactly one\n * winner), and provider-send duplicates are bounded by MAX_ATTEMPTS.\n *\n * Each transition is an atomic `nativeUpdate` guarded on the row's observed `status` AND still-stale\n * `updated_at < cutoff`, so overlapping ticks (or a worker that re-claimed the row in the meantime)\n * can never re-open an actively-processing delivery \u2014 exactly one actor wins each row.\n */\nexport async function reclaimStuckPushDeliveries(\n em: EntityManager,\n scope: { tenantId: string },\n now: Date = new Date(),\n): Promise<ReclaimStuckResult> {\n const cutoff = new Date(now.getTime() - resolveStuckThresholdMs())\n const stuck = await em.find(\n PushNotificationDelivery,\n {\n tenantId: scope.tenantId,\n status: { $in: ['sending', 'pending'] },\n updatedAt: { $lt: cutoff },\n },\n { limit: resolveReclaimBatchLimit(), orderBy: { updatedAt: 'asc' } },\n )\n\n let reEnqueued = 0\n let expired = 0\n for (const delivery of stuck) {\n const claimGuard = { id: delivery.id, tenantId: scope.tenantId, status: delivery.status, updatedAt: { $lt: cutoff } }\n\n if (delivery.attempts >= MAX_ATTEMPTS) {\n const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {\n status: 'expired',\n lastError: 'stuck_reclaimed',\n nextRetryAt: null,\n updatedAt: new Date(),\n })\n if (claimed === 0) continue\n delivery.status = 'expired'\n delivery.lastError = 'stuck_reclaimed'\n await emitFailed(delivery, false)\n expired += 1\n continue\n }\n\n const claimed = await em.nativeUpdate(PushNotificationDelivery, claimGuard, {\n status: 'pending',\n nextRetryAt: null,\n updatedAt: new Date(),\n })\n if (claimed === 0) continue\n delivery.status = 'pending'\n\n try {\n await enqueuePushDelivery({\n deliveryId: delivery.id,\n tenantId: scope.tenantId,\n organizationId: delivery.organizationId ?? null,\n })\n reEnqueued += 1\n } catch (error) {\n // Re-enqueue failed: don't leave the row pending with no job. Fail it terminally instead.\n const reason = error instanceof Error ? `reclaim_enqueue_failed: ${error.message}` : 'reclaim_enqueue_failed'\n await em.nativeUpdate(\n PushNotificationDelivery,\n { id: delivery.id, tenantId: scope.tenantId, status: 'pending' },\n { status: 'failed', lastError: reason, nextRetryAt: null, updatedAt: new Date() },\n )\n delivery.status = 'failed'\n delivery.lastError = reason\n await emitFailed(delivery, false)\n }\n }\n\n return { reEnqueued, expired }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,gCAAgC;AACzC,SAAS,kCAAkC;AAC3C,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AACxC,SAAS,2BAA2B;AAiBpC,MAAM,8BAA8B;AACpC,MAAM,0BAA0B;AAIhC,SAAS,2BAAmC;AAC1C,QAAM,MAAM,OAAO,SAAS,QAAQ,IAAI,qCAAqC,IAAI,EAAE;AACnF,SAAO,OAAO,SAAS,GAAG,KAAK,OAAO,0BAA0B,MAAM;AACxE;AAEA,eAAe,WAAW,UAAoC,WAAmC;AAC/F,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,MACE,YAAY,SAAS;AAAA,MACrB,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS,kBAAkB;AAAA,MAC3C,QAAQ,SAAS;AAAA,MACjB,UAAU,SAAS;AAAA,MACnB,QAAQ,SAAS;AAAA,MACjB,GAAI,YAAY,EAAE,WAAW,KAAK,IAAI,CAAC;AAAA,IACzC;AAAA,IACA,EAAE,YAAY,KAAK;AAAA,EACrB;AACF;AA2BA,eAAsB,2BACpB,IACA,OACA,MAAY,oBAAI,KAAK,GACQ;AAC7B,QAAM,SAAS,IAAI,KAAK,IAAI,QAAQ,IAAI,wBAAwB,CAAC;AACjE,QAAM,QAAQ,MAAM,GAAG;AAAA,IACrB;AAAA,IACA;AAAA,MACE,UAAU,MAAM;AAAA,MAChB,QAAQ,EAAE,KAAK,CAAC,WAAW,SAAS,EAAE;AAAA,MACtC,WAAW,EAAE,KAAK,OAAO;AAAA,IAC3B;AAAA,IACA,EAAE,OAAO,yBAAyB,GAAG,SAAS,EAAE,WAAW,MAAM,EAAE;AAAA,EACrE;AAEA,MAAI,aAAa;AACjB,MAAI,UAAU;AACd,aAAW,YAAY,OAAO;AAC5B,UAAM,aAAa,EAAE,IAAI,SAAS,IAAI,UAAU,MAAM,UAAU,QAAQ,SAAS,QAAQ,WAAW,EAAE,KAAK,OAAO,EAAE;AAEpH,QAAI,SAAS,YAAY,cAAc;AACrC,YAAMA,WAAU,MAAM,GAAG,aAAa,0BAA0B,YAAY;AAAA,QAC1E,QAAQ;AAAA,QACR,WAAW;AAAA,QACX,aAAa;AAAA,QACb,WAAW,oBAAI,KAAK;AAAA,MACtB,CAAC;AACD,UAAIA,aAAY,EAAG;AACnB,eAAS,SAAS;AAClB,eAAS,YAAY;AACrB,YAAM,WAAW,UAAU,KAAK;AAChC,iBAAW;AACX;AAAA,IACF;AAEA,UAAM,UAAU,MAAM,GAAG,aAAa,0BAA0B,YAAY;AAAA,MAC1E,QAAQ;AAAA,MACR,aAAa;AAAA,MACb,WAAW,oBAAI,KAAK;AAAA,IACtB,CAAC;AACD,QAAI,YAAY,EAAG;AACnB,aAAS,SAAS;AAElB,QAAI;AACF,YAAM,oBAAoB;AAAA,QACxB,YAAY,SAAS;AAAA,QACrB,UAAU,MAAM;AAAA,QAChB,gBAAgB,SAAS,kBAAkB;AAAA,MAC7C,CAAC;AACD,oBAAc;AAAA,IAChB,SAAS,OAAO;AAEd,YAAM,SAAS,iBAAiB,QAAQ,2BAA2B,MAAM,OAAO,KAAK;AACrF,YAAM,GAAG;AAAA,QACP;AAAA,QACA,EAAE,IAAI,SAAS,IAAI,UAAU,MAAM,UAAU,QAAQ,UAAU;AAAA,QAC/D,EAAE,QAAQ,UAAU,WAAW,QAAQ,aAAa,MAAM,WAAW,oBAAI,KAAK,EAAE;AAAA,MAClF;AACA,eAAS,SAAS;AAClB,eAAS,YAAY;AACrB,YAAM,WAAW,UAAU,KAAK;AAAA,IAClC;AAAA,EACF;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;",
|
|
6
|
+
"names": ["claimed"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { raw } from "@mikro-orm/core";
|
|
2
|
+
import {
|
|
3
|
+
DEVICE_UNREGISTERED,
|
|
4
|
+
supportsReceiptChecking
|
|
5
|
+
} from "@open-mercato/core/modules/communication_channels/lib/push-adapter";
|
|
6
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
7
|
+
import { PushNotificationDelivery } from "../data/entities.js";
|
|
8
|
+
import { softDeleteUnregisteredDevice } from "./push-delivery.js";
|
|
9
|
+
const logger = createLogger("push_notifications");
|
|
10
|
+
function envInt(name, fallback) {
|
|
11
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
12
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
13
|
+
}
|
|
14
|
+
const RECEIPT_MIN_AGE_MINUTES_RAW = envInt("OM_PUSH_RECEIPT_MIN_AGE_MINUTES", 15);
|
|
15
|
+
const RECEIPT_MIN_AGE_MS = (RECEIPT_MIN_AGE_MINUTES_RAW >= 0 ? RECEIPT_MIN_AGE_MINUTES_RAW : 15) * 60 * 1e3;
|
|
16
|
+
const RECEIPT_MAX_AGE_MS = Math.max(
|
|
17
|
+
RECEIPT_MIN_AGE_MS + 60 * 1e3,
|
|
18
|
+
envInt("OM_PUSH_RECEIPT_MAX_AGE_MINUTES", 60) * 60 * 1e3
|
|
19
|
+
);
|
|
20
|
+
const RECEIPT_BATCH_LIMIT = Math.max(1, envInt("OM_PUSH_RECEIPT_BATCH_LIMIT", 500));
|
|
21
|
+
function ticketIdOf(delivery) {
|
|
22
|
+
const id = delivery.providerResponse?.externalMessageId;
|
|
23
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
24
|
+
}
|
|
25
|
+
function isReceiptChecked(delivery) {
|
|
26
|
+
return delivery.providerResponse?.receiptChecked === true;
|
|
27
|
+
}
|
|
28
|
+
function markReceiptChecked(delivery, unregistered) {
|
|
29
|
+
delivery.providerResponse = {
|
|
30
|
+
...delivery.providerResponse ?? {},
|
|
31
|
+
receiptChecked: true,
|
|
32
|
+
...unregistered ? { unregistered: true } : {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async function checkPushReceipts(em, scope, resolve, now = /* @__PURE__ */ new Date()) {
|
|
36
|
+
const registry = resolve("channelAdapterRegistry");
|
|
37
|
+
if (!registry) return { checked: 0, unregistered: 0 };
|
|
38
|
+
const workableRow = raw(
|
|
39
|
+
`(nullif(provider_response->>'externalMessageId', '') is not null and coalesce((provider_response->>'receiptChecked')::boolean, false) = false)`
|
|
40
|
+
);
|
|
41
|
+
const candidates = await em.find(
|
|
42
|
+
PushNotificationDelivery,
|
|
43
|
+
{
|
|
44
|
+
tenantId: scope.tenantId,
|
|
45
|
+
status: "sent",
|
|
46
|
+
sentAt: { $gte: new Date(now.getTime() - RECEIPT_MAX_AGE_MS), $lte: new Date(now.getTime() - RECEIPT_MIN_AGE_MS) },
|
|
47
|
+
[workableRow]: true
|
|
48
|
+
},
|
|
49
|
+
{ limit: RECEIPT_BATCH_LIMIT, orderBy: { sentAt: "asc" } }
|
|
50
|
+
);
|
|
51
|
+
const byProvider = /* @__PURE__ */ new Map();
|
|
52
|
+
for (const delivery of candidates) {
|
|
53
|
+
if (isReceiptChecked(delivery)) continue;
|
|
54
|
+
const ticketId = ticketIdOf(delivery);
|
|
55
|
+
if (!ticketId) continue;
|
|
56
|
+
const list = byProvider.get(delivery.provider) ?? [];
|
|
57
|
+
list.push({ ticketId, delivery });
|
|
58
|
+
byProvider.set(delivery.provider, list);
|
|
59
|
+
}
|
|
60
|
+
if (byProvider.size === 0) return { checked: 0, unregistered: 0 };
|
|
61
|
+
let credentialsService;
|
|
62
|
+
try {
|
|
63
|
+
credentialsService = resolve("integrationCredentialsService");
|
|
64
|
+
} catch {
|
|
65
|
+
credentialsService = void 0;
|
|
66
|
+
}
|
|
67
|
+
const ChannelRef = resolve("CommunicationChannel");
|
|
68
|
+
let checked = 0;
|
|
69
|
+
let unregistered = 0;
|
|
70
|
+
for (const [provider, entries] of byProvider) {
|
|
71
|
+
const adapter = registry.get(provider);
|
|
72
|
+
if (!adapter || !supportsReceiptChecking(adapter)) continue;
|
|
73
|
+
const channel = await em.findOne(ChannelRef, {
|
|
74
|
+
tenantId: scope.tenantId,
|
|
75
|
+
providerKey: provider,
|
|
76
|
+
channelType: "push",
|
|
77
|
+
isActive: true,
|
|
78
|
+
deletedAt: null
|
|
79
|
+
});
|
|
80
|
+
if (!channel) continue;
|
|
81
|
+
const credentialScope = {
|
|
82
|
+
tenantId: scope.tenantId,
|
|
83
|
+
organizationId: channel.organizationId ?? scope.tenantId,
|
|
84
|
+
userId: channel.userId ?? null
|
|
85
|
+
};
|
|
86
|
+
let credentials = {};
|
|
87
|
+
if (channel.credentialsRef && credentialsService) {
|
|
88
|
+
credentials = await credentialsService.resolve(`channel_${provider}`, credentialScope).catch(() => null) ?? {};
|
|
89
|
+
}
|
|
90
|
+
const byTicket = /* @__PURE__ */ new Map();
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const list = byTicket.get(entry.ticketId) ?? [];
|
|
93
|
+
list.push(entry.delivery);
|
|
94
|
+
byTicket.set(entry.ticketId, list);
|
|
95
|
+
}
|
|
96
|
+
let outcomes;
|
|
97
|
+
try {
|
|
98
|
+
outcomes = await adapter.checkReceipts([...byTicket.keys()], credentials);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logger.error("Push receipt check failed", {
|
|
101
|
+
tenantId: scope.tenantId,
|
|
102
|
+
provider,
|
|
103
|
+
error: error instanceof Error ? error.message : String(error)
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const toDeactivate = [];
|
|
108
|
+
for (const outcome of outcomes) {
|
|
109
|
+
const deliveries = byTicket.get(outcome.ticketId);
|
|
110
|
+
if (!deliveries) continue;
|
|
111
|
+
for (const delivery of deliveries) {
|
|
112
|
+
markReceiptChecked(delivery, outcome.unregistered);
|
|
113
|
+
checked += 1;
|
|
114
|
+
if (outcome.unregistered) {
|
|
115
|
+
delivery.lastError = DEVICE_UNREGISTERED;
|
|
116
|
+
toDeactivate.push({
|
|
117
|
+
id: delivery.userDeviceId,
|
|
118
|
+
tenantId: delivery.tenantId,
|
|
119
|
+
userId: delivery.userId,
|
|
120
|
+
organizationId: delivery.organizationId ?? null
|
|
121
|
+
});
|
|
122
|
+
unregistered += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await em.flush();
|
|
127
|
+
for (const input of toDeactivate) {
|
|
128
|
+
await softDeleteUnregisteredDevice(resolve, input);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { checked, unregistered };
|
|
132
|
+
}
|
|
133
|
+
export {
|
|
134
|
+
checkPushReceipts
|
|
135
|
+
};
|
|
136
|
+
//# sourceMappingURL=push-receipt-reaper.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/push_notifications/lib/push-receipt-reaper.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { raw, type EntityName } from '@mikro-orm/core'\nimport type { ChannelAdapter } from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n DEVICE_UNREGISTERED,\n supportsReceiptChecking,\n} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'\nimport type { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { PushNotificationDelivery } from '../data/entities'\nimport { softDeleteUnregisteredDevice } from './push-delivery'\n\nconst logger = createLogger('push_notifications')\n\ntype Resolve = <T = unknown>(name: string) => T\n\ninterface ChannelAdapterRegistryLike {\n get(providerKey: string): ChannelAdapter | undefined\n}\n\ninterface CredentialsServiceLike {\n resolve(\n integrationId: string,\n scope: { tenantId: string; organizationId: string; userId?: string | null },\n ): Promise<Record<string, unknown> | null>\n}\n\nexport type ReceiptReaperResult = { checked: number; unregistered: number }\n\n// A receipt only exists after the provider has attempted handoff to FCM/APNs, so a just-`sent` row has\n// nothing to poll yet. Wait MIN_AGE before the first check (Expo recommends ~15m) and stop looking after\n// MAX_AGE \u2014 a row not resolved within the window is left as `sent` (best-effort hygiene, not a\n// correctness guarantee). Both tunable; MAX is clamped above MIN so the window is always non-empty.\n// Parse an int env var, falling back to `fallback` only when it is unset/non-numeric \u2014 a legitimately\n// configured `0` is preserved (`|| fallback` would coerce it, since `0` is falsy).\nfunction envInt(name: string, fallback: number): number {\n const parsed = Number.parseInt(process.env[name] ?? '', 10)\n return Number.isFinite(parsed) ? parsed : fallback\n}\n// A negative value must NOT collapse to 0 (that would poll before any receipt exists and mark the\n// delivery `receiptChecked` forever). Preserve a legitimately-configured 0 but floor a negative back to\n// the default \u2014 mirroring the reaper's `raw >= MIN ? raw : DEFAULT` guard (resolveReclaimBatchLimit).\nconst RECEIPT_MIN_AGE_MINUTES_RAW = envInt('OM_PUSH_RECEIPT_MIN_AGE_MINUTES', 15)\nconst RECEIPT_MIN_AGE_MS = (RECEIPT_MIN_AGE_MINUTES_RAW >= 0 ? RECEIPT_MIN_AGE_MINUTES_RAW : 15) * 60 * 1000\nconst RECEIPT_MAX_AGE_MS = Math.max(\n RECEIPT_MIN_AGE_MS + 60 * 1000,\n envInt('OM_PUSH_RECEIPT_MAX_AGE_MINUTES', 60) * 60 * 1000,\n)\n// Bound the per-tick scan so a busy tenant's `sent` backlog cannot fetch an unbounded row set.\nconst RECEIPT_BATCH_LIMIT = Math.max(1, envInt('OM_PUSH_RECEIPT_BATCH_LIMIT', 500))\n\n/** The provider ticket id persisted by the send path, or null when there is nothing to poll. */\nfunction ticketIdOf(delivery: PushNotificationDelivery): string | null {\n const id = (delivery.providerResponse as { externalMessageId?: unknown } | null | undefined)?.externalMessageId\n return typeof id === 'string' && id.length > 0 ? id : null\n}\n\n/** A row whose receipt was already resolved on a prior sweep \u2014 skip it (marker is JSON, no migration). */\nfunction isReceiptChecked(delivery: PushNotificationDelivery): boolean {\n return (delivery.providerResponse as { receiptChecked?: unknown } | null | undefined)?.receiptChecked === true\n}\n\nfunction markReceiptChecked(delivery: PushNotificationDelivery, unregistered: boolean): void {\n delivery.providerResponse = {\n ...(delivery.providerResponse ?? {}),\n receiptChecked: true,\n ...(unregistered ? { unregistered: true } : {}),\n }\n}\n\n/**\n * Second-pass hygiene for push providers whose \"device unregistered\" signal arrives in an ASYNCHRONOUS\n * receipt phase rather than the synchronous send ticket (Expo). Runs on the per-tenant reclaim tick.\n *\n * For each `sent` delivery old enough to have a receipt, group ticket ids by provider, and for every\n * provider whose adapter implements the optional `PushReceiptChecker` capability, poll receipts using\n * the same channel credentials as the send path. A `DeviceNotRegistered` receipt is routed through the\n * SAME `softDeleteUnregisteredDevice` contract the synchronous path uses \u2014 no separate deletion path.\n * `MessageRateExceeded` and other transient receipt errors resolve the receipt but never kill the token.\n *\n * Idempotent: each resolved row is marked (`providerResponse.receiptChecked`) so it is never re-polled,\n * and `devices.deactivate` is itself idempotent, so an overlapping tick cannot double-delete.\n */\nexport async function checkPushReceipts(\n em: EntityManager,\n scope: { tenantId: string },\n resolve: Resolve,\n now: Date = new Date(),\n): Promise<ReceiptReaperResult> {\n const registry = resolve('channelAdapterRegistry') as ChannelAdapterRegistryLike | undefined\n if (!registry) return { checked: 0, unregistered: 0 }\n\n // Exclude non-workable rows in SQL, not in memory. A resolved row keeps `status: 'sent'` (the\n // `receiptChecked` marker lives in the `provider_response` JSON, not the status column), and rows\n // with no pollable ticket id (fcm/apns report unregistered synchronously and persist an empty\n // `externalMessageId`) can never be receipt-checked. Filtering those in memory after a\n // `LIMIT \u2026 ORDER BY sent_at ASC` fetch lets an oldest-first backlog of them fill the bounded batch\n // and starve every still-unchecked Expo row in the tenant \u2014 the batch does zero work forever until\n // the backlog ages past MAX_AGE. Push both predicates into the query so the budget only ever loads\n // rows that can actually make progress. (`provider_response` is `json`; `->>` reads it as text.)\n const workableRow = raw(\n `(nullif(provider_response->>'externalMessageId', '') is not null and coalesce((provider_response->>'receiptChecked')::boolean, false) = false)`,\n )\n const candidates = await em.find(\n PushNotificationDelivery,\n {\n tenantId: scope.tenantId,\n status: 'sent',\n sentAt: { $gte: new Date(now.getTime() - RECEIPT_MAX_AGE_MS), $lte: new Date(now.getTime() - RECEIPT_MIN_AGE_MS) },\n [workableRow as unknown as string]: true,\n },\n { limit: RECEIPT_BATCH_LIMIT, orderBy: { sentAt: 'asc' } },\n )\n\n // Group unchecked rows that have a real ticket id by provider. Providers whose adapter doesn't\n // implement receipt checking (fcm/apns) never issue a network call \u2014 they are dropped below.\n const byProvider = new Map<string, { ticketId: string; delivery: PushNotificationDelivery }[]>()\n for (const delivery of candidates) {\n if (isReceiptChecked(delivery)) continue\n const ticketId = ticketIdOf(delivery)\n if (!ticketId) continue\n const list = byProvider.get(delivery.provider) ?? []\n list.push({ ticketId, delivery })\n byProvider.set(delivery.provider, list)\n }\n if (byProvider.size === 0) return { checked: 0, unregistered: 0 }\n\n let credentialsService: CredentialsServiceLike | undefined\n try {\n credentialsService = resolve('integrationCredentialsService') as CredentialsServiceLike | undefined\n } catch {\n credentialsService = undefined\n }\n const ChannelRef = resolve('CommunicationChannel') as EntityName<CommunicationChannel>\n\n let checked = 0\n let unregistered = 0\n\n for (const [provider, entries] of byProvider) {\n const adapter = registry.get(provider)\n if (!adapter || !supportsReceiptChecking(adapter)) continue\n\n const channel = await em.findOne(ChannelRef, {\n tenantId: scope.tenantId,\n providerKey: provider,\n channelType: 'push',\n isActive: true,\n deletedAt: null,\n })\n if (!channel) continue\n\n // Credentials are keyed by the CHANNEL's org context, mirroring the send path (see push-delivery).\n const credentialScope = {\n tenantId: scope.tenantId,\n organizationId: channel.organizationId ?? scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n credentials = (await credentialsService.resolve(`channel_${provider}`, credentialScope).catch(() => null)) ?? {}\n }\n\n const byTicket = new Map<string, PushNotificationDelivery[]>()\n for (const entry of entries) {\n const list = byTicket.get(entry.ticketId) ?? []\n list.push(entry.delivery)\n byTicket.set(entry.ticketId, list)\n }\n\n let outcomes\n try {\n outcomes = await adapter.checkReceipts([...byTicket.keys()], credentials)\n } catch (error) {\n logger.error('Push receipt check failed', {\n tenantId: scope.tenantId,\n provider,\n error: error instanceof Error ? error.message : String(error),\n })\n continue\n }\n\n // Persist the receipt-checked marks first, then run the device deactivations AFTER the flush so a\n // command that reads on the same EntityManager can never discard the pending scalar marks.\n const toDeactivate: Array<{ id: string; tenantId: string; userId: string; organizationId: string | null }> = []\n for (const outcome of outcomes) {\n const deliveries = byTicket.get(outcome.ticketId)\n if (!deliveries) continue\n for (const delivery of deliveries) {\n markReceiptChecked(delivery, outcome.unregistered)\n checked += 1\n if (outcome.unregistered) {\n delivery.lastError = DEVICE_UNREGISTERED\n toDeactivate.push({\n id: delivery.userDeviceId,\n tenantId: delivery.tenantId,\n userId: delivery.userId,\n organizationId: delivery.organizationId ?? null,\n })\n unregistered += 1\n }\n }\n }\n await em.flush()\n for (const input of toDeactivate) {\n await softDeleteUnregisteredDevice(resolve, input)\n }\n }\n\n return { checked, unregistered }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
BasePushChannelAdapter,
|
|
4
|
+
deviceUnregisteredResult,
|
|
5
|
+
readPushToken
|
|
6
|
+
} from "@open-mercato/core/modules/communication_channels/lib/push-adapter";
|
|
7
|
+
import { readPushEnvelope } from "@open-mercato/core/modules/communication_channels/lib/push-envelope";
|
|
8
|
+
import {
|
|
9
|
+
hasChannelAdapter,
|
|
10
|
+
registerChannelAdapter
|
|
11
|
+
} from "@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton";
|
|
12
|
+
const PUSH_STUB_PROVIDER_KEY = "push_stub";
|
|
13
|
+
const PUSH_STUB_ENV = "OM_ENABLE_PUSH_STUB_ADAPTER";
|
|
14
|
+
function isPushStubEnabled() {
|
|
15
|
+
const raw = process.env[PUSH_STUB_ENV];
|
|
16
|
+
if (typeof raw !== "string") return false;
|
|
17
|
+
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
class PushStubChannelAdapter extends BasePushChannelAdapter {
|
|
20
|
+
constructor() {
|
|
21
|
+
super(...arguments);
|
|
22
|
+
this.providerKey = PUSH_STUB_PROVIDER_KEY;
|
|
23
|
+
this.credentialsSchema = z.object({}).passthrough();
|
|
24
|
+
}
|
|
25
|
+
async sendMessage(input) {
|
|
26
|
+
const token = readPushToken(input) ?? "";
|
|
27
|
+
if (token.includes("unregistered")) {
|
|
28
|
+
return deviceUnregisteredResult({ stub: true });
|
|
29
|
+
}
|
|
30
|
+
if (token.includes("fail")) {
|
|
31
|
+
return { externalMessageId: "", status: "failed", error: "push_stub_forced_failure", metadata: { stub: true } };
|
|
32
|
+
}
|
|
33
|
+
const envelope = readPushEnvelope(input.content);
|
|
34
|
+
return {
|
|
35
|
+
externalMessageId: `push-stub-${token.slice(-8) || "token"}`,
|
|
36
|
+
status: "sent",
|
|
37
|
+
metadata: {
|
|
38
|
+
stub: true,
|
|
39
|
+
silent: envelope.silent,
|
|
40
|
+
data: envelope.data,
|
|
41
|
+
options: envelope.options
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let cachedPushStubAdapter = null;
|
|
47
|
+
function getPushStubAdapter() {
|
|
48
|
+
if (!cachedPushStubAdapter) cachedPushStubAdapter = new PushStubChannelAdapter();
|
|
49
|
+
return cachedPushStubAdapter;
|
|
50
|
+
}
|
|
51
|
+
function ensurePushStubAdapterRegistered() {
|
|
52
|
+
if (!isPushStubEnabled()) return;
|
|
53
|
+
if (hasChannelAdapter(PUSH_STUB_PROVIDER_KEY)) return;
|
|
54
|
+
registerChannelAdapter(getPushStubAdapter());
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
PUSH_STUB_ENV,
|
|
58
|
+
PUSH_STUB_PROVIDER_KEY,
|
|
59
|
+
ensurePushStubAdapterRegistered,
|
|
60
|
+
getPushStubAdapter,
|
|
61
|
+
isPushStubEnabled
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=push-stub-adapter.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/push_notifications/lib/push-stub-adapter.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type {\n SendMessageInput,\n SendMessageResult,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n BasePushChannelAdapter,\n deviceUnregisteredResult,\n readPushToken,\n} from '@open-mercato/core/modules/communication_channels/lib/push-adapter'\nimport { readPushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'\nimport {\n hasChannelAdapter,\n registerChannelAdapter,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'\n\n/**\n * In-process, network-free `push` channel adapter used ONLY by tests.\n *\n * Real provider adapters (FCM/APNs/Expo) land in Phase 4 as separate channel packages. Phase 3 ships\n * this stub so the strategy \u2192 delivery-row \u2192 worker \u2192 `sendMessage` chain is exercisable end-to-end.\n *\n * Token sentinels let a test drive each worker branch deterministically:\n * - a token containing `unregistered` \u2192 the uniform `unregistered` shape (worker soft-deletes the device)\n * - a token containing `fail` \u2192 a retryable failure (worker retries then marks `failed`)\n * - otherwise \u2192 `sent`\n *\n * Production safety: never registered at module import. The integration harness calls\n * {@link ensurePushStubAdapterRegistered}, gated by `OM_ENABLE_PUSH_STUB_ADAPTER`.\n */\nexport const PUSH_STUB_PROVIDER_KEY = 'push_stub'\nexport const PUSH_STUB_ENV = 'OM_ENABLE_PUSH_STUB_ADAPTER'\n\nexport function isPushStubEnabled(): boolean {\n const raw = process.env[PUSH_STUB_ENV]\n if (typeof raw !== 'string') return false\n return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())\n}\n\nclass PushStubChannelAdapter extends BasePushChannelAdapter {\n readonly providerKey = PUSH_STUB_PROVIDER_KEY\n protected readonly credentialsSchema = z.object({}).passthrough()\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const token = readPushToken(input) ?? ''\n if (token.includes('unregistered')) {\n return deviceUnregisteredResult({ stub: true })\n }\n if (token.includes('fail')) {\n return { externalMessageId: '', status: 'failed', error: 'push_stub_forced_failure', metadata: { stub: true } }\n }\n // Surface the resolved envelope (silent flag, custom data, mapped options) so\n // integration tests can assert what the worker handed the adapter per delivery.\n const envelope = readPushEnvelope(input.content)\n return {\n externalMessageId: `push-stub-${token.slice(-8) || 'token'}`,\n status: 'sent',\n metadata: {\n stub: true,\n silent: envelope.silent,\n data: envelope.data,\n options: envelope.options,\n },\n }\n }\n}\n\nlet cachedPushStubAdapter: PushStubChannelAdapter | null = null\n\nexport function getPushStubAdapter(): PushStubChannelAdapter {\n if (!cachedPushStubAdapter) cachedPushStubAdapter = new PushStubChannelAdapter()\n return cachedPushStubAdapter\n}\n\n/** Register the push stub adapter once, ONLY when the test env flag is set. No-op otherwise. */\nexport function ensurePushStubAdapterRegistered(): void {\n if (!isPushStubEnabled()) return\n if (hasChannelAdapter(PUSH_STUB_PROVIDER_KEY)) return\n registerChannelAdapter(getPushStubAdapter())\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAKlB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,wBAAwB;AACjC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAgBA,MAAM,yBAAyB;AAC/B,MAAM,gBAAgB;AAEtB,SAAS,oBAA6B;AAC3C,QAAM,MAAM,QAAQ,IAAI,aAAa;AACrC,MAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,SAAO,CAAC,KAAK,QAAQ,OAAO,IAAI,EAAE,SAAS,IAAI,KAAK,EAAE,YAAY,CAAC;AACrE;AAEA,MAAM,+BAA+B,uBAAuB;AAAA,EAA5D;AAAA;AACE,SAAS,cAAc;AACvB,SAAmB,oBAAoB,EAAE,OAAO,CAAC,CAAC,EAAE,YAAY;AAAA;AAAA,EAEhE,MAAM,YAAY,OAAqD;AACrE,UAAM,QAAQ,cAAc,KAAK,KAAK;AACtC,QAAI,MAAM,SAAS,cAAc,GAAG;AAClC,aAAO,yBAAyB,EAAE,MAAM,KAAK,CAAC;AAAA,IAChD;AACA,QAAI,MAAM,SAAS,MAAM,GAAG;AAC1B,aAAO,EAAE,mBAAmB,IAAI,QAAQ,UAAU,OAAO,4BAA4B,UAAU,EAAE,MAAM,KAAK,EAAE;AAAA,IAChH;AAGA,UAAM,WAAW,iBAAiB,MAAM,OAAO;AAC/C,WAAO;AAAA,MACL,mBAAmB,aAAa,MAAM,MAAM,EAAE,KAAK,OAAO;AAAA,MAC1D,QAAQ;AAAA,MACR,UAAU;AAAA,QACR,MAAM;AAAA,QACN,QAAQ,SAAS;AAAA,QACjB,MAAM,SAAS;AAAA,QACf,SAAS,SAAS;AAAA,MACpB;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAI,wBAAuD;AAEpD,SAAS,qBAA6C;AAC3D,MAAI,CAAC,sBAAuB,yBAAwB,IAAI,uBAAuB;AAC/E,SAAO;AACT;AAGO,SAAS,kCAAwC;AACtD,MAAI,CAAC,kBAAkB,EAAG;AAC1B,MAAI,kBAAkB,sBAAsB,EAAG;AAC/C,yBAAuB,mBAAmB,CAAC;AAC7C;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createModuleQueue } from "@open-mercato/queue";
|
|
2
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
3
|
+
const logger = createLogger("push_notifications");
|
|
4
|
+
const PUSH_DELIVERIES_QUEUE = "push-deliveries";
|
|
5
|
+
const PUSH_STUCK_RECLAIM_QUEUE = "push-stuck-reclaim";
|
|
6
|
+
const queues = /* @__PURE__ */ new Map();
|
|
7
|
+
const LOCAL_WORKER_PROMISE_KEY = "__openMercatoPushLocalWorkerPromise__";
|
|
8
|
+
function getPushQueue(queueName = PUSH_DELIVERIES_QUEUE) {
|
|
9
|
+
const existing = queues.get(queueName);
|
|
10
|
+
if (existing) return existing;
|
|
11
|
+
const concurrency = Math.max(1, Number.parseInt(process.env.PUSH_QUEUE_CONCURRENCY ?? "8", 10) || 8);
|
|
12
|
+
const created = createModuleQueue(queueName, { concurrency });
|
|
13
|
+
queues.set(queueName, created);
|
|
14
|
+
return created;
|
|
15
|
+
}
|
|
16
|
+
async function ensureLocalPushQueueWorkerStarted() {
|
|
17
|
+
if (process.env.QUEUE_STRATEGY === "async") return;
|
|
18
|
+
const globalStore = globalThis;
|
|
19
|
+
if (globalStore[LOCAL_WORKER_PROMISE_KEY]) {
|
|
20
|
+
await globalStore[LOCAL_WORKER_PROMISE_KEY];
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
globalStore[LOCAL_WORKER_PROMISE_KEY] = (async () => {
|
|
24
|
+
const queue = getPushQueue();
|
|
25
|
+
await queue.process(async (job) => {
|
|
26
|
+
const [{ createRequestContainer }, { processPushDeliveryJob }] = await Promise.all([
|
|
27
|
+
import("@open-mercato/shared/lib/di/container"),
|
|
28
|
+
import("./push-delivery.js")
|
|
29
|
+
]);
|
|
30
|
+
const container = await createRequestContainer();
|
|
31
|
+
const em = container.resolve("em").fork();
|
|
32
|
+
await processPushDeliveryJob(em, job.payload, (name) => container.resolve(name));
|
|
33
|
+
});
|
|
34
|
+
})().catch((error) => {
|
|
35
|
+
delete globalStore[LOCAL_WORKER_PROMISE_KEY];
|
|
36
|
+
logger.error("Failed to start local delivery worker", { error });
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
await globalStore[LOCAL_WORKER_PROMISE_KEY];
|
|
40
|
+
}
|
|
41
|
+
async function enqueuePushDelivery(job, delayMs) {
|
|
42
|
+
const queue = getPushQueue();
|
|
43
|
+
const jobId = await queue.enqueue(job, delayMs && delayMs > 0 ? { delayMs } : void 0);
|
|
44
|
+
await ensureLocalPushQueueWorkerStarted();
|
|
45
|
+
return jobId;
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
PUSH_DELIVERIES_QUEUE,
|
|
49
|
+
PUSH_STUCK_RECLAIM_QUEUE,
|
|
50
|
+
enqueuePushDelivery,
|
|
51
|
+
getPushQueue
|
|
52
|
+
};
|
|
53
|
+
//# sourceMappingURL=queue.js.map
|