From 119622dbbdf8d53ebc3f71b82f6b5205d3bc8c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 11 Feb 2026 15:06:29 -0600 Subject: [PATCH 01/17] feat(notifications): add BulletinButton to proto and Rust service --- .../notifications/proto/notifications.proto | 5 + .../proto/notifications_grpc_pb.js | 2 +- .../notifications/proto/notifications_pb.d.ts | 26 ++ .../notifications/proto/notifications_pb.js | 365 ++++++++++++++---- core/notifications/proto/notifications.proto | 5 + core/notifications/src/graphql/convert.rs | 3 + core/notifications/src/graphql/types.rs | 6 + core/notifications/src/grpc/server/convert.rs | 8 + core/notifications/src/grpc/server/mod.rs | 4 + core/notifications/src/history/entity.rs | 6 +- .../marketing_notification_triggered.rs | 145 ++++++- .../src/notification_event/mod.rs | 9 + core/notifications/subgraph/schema.graphql | 7 + 13 files changed, 507 insertions(+), 84 deletions(-) diff --git a/core/api/src/services/notifications/proto/notifications.proto b/core/api/src/services/notifications/proto/notifications.proto index adf15bb8f3..0c011601ee 100644 --- a/core/api/src/services/notifications/proto/notifications.proto +++ b/core/api/src/services/notifications/proto/notifications.proto @@ -243,6 +243,7 @@ message MarketingNotificationTriggered { bool should_add_to_bulletin = 5; optional Action action = 6; optional Icon icon = 7; + optional BulletinButton bulletin_button = 8; } message LocalizedContent { @@ -250,6 +251,10 @@ message LocalizedContent { string body = 2; } +message BulletinButton { + string label = 1; +} + message Action { oneof data { DeepLink deep_link = 1; diff --git a/core/api/src/services/notifications/proto/notifications_grpc_pb.js b/core/api/src/services/notifications/proto/notifications_grpc_pb.js index fc1c710846..22d6fb4b19 100644 --- a/core/api/src/services/notifications/proto/notifications_grpc_pb.js +++ b/core/api/src/services/notifications/proto/notifications_grpc_pb.js @@ -371,4 +371,4 @@ var NotificationsServiceService = exports.NotificationsServiceService = { }, }; -exports.NotificationsServiceClient = grpc.makeGenericClientConstructor(NotificationsServiceService); +exports.NotificationsServiceClient = grpc.makeGenericClientConstructor(NotificationsServiceService, 'NotificationsService'); diff --git a/core/api/src/services/notifications/proto/notifications_pb.d.ts b/core/api/src/services/notifications/proto/notifications_pb.d.ts index 59f5a4e0b2..a7723b7551 100644 --- a/core/api/src/services/notifications/proto/notifications_pb.d.ts +++ b/core/api/src/services/notifications/proto/notifications_pb.d.ts @@ -915,6 +915,11 @@ export class MarketingNotificationTriggered extends jspb.Message { getIcon(): Icon | undefined; setIcon(value: Icon): MarketingNotificationTriggered; + hasBulletinButton(): boolean; + clearBulletinButton(): void; + getBulletinButton(): BulletinButton | undefined; + setBulletinButton(value?: BulletinButton): MarketingNotificationTriggered; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): MarketingNotificationTriggered.AsObject; static toObject(includeInstance: boolean, msg: MarketingNotificationTriggered): MarketingNotificationTriggered.AsObject; @@ -935,6 +940,7 @@ export namespace MarketingNotificationTriggered { shouldAddToBulletin: boolean, action?: Action.AsObject, icon?: Icon, + bulletinButton?: BulletinButton.AsObject, } } @@ -961,6 +967,26 @@ export namespace LocalizedContent { } } +export class BulletinButton extends jspb.Message { + getLabel(): string; + setLabel(value: string): BulletinButton; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): BulletinButton.AsObject; + static toObject(includeInstance: boolean, msg: BulletinButton): BulletinButton.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: BulletinButton, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): BulletinButton; + static deserializeBinaryFromReader(message: BulletinButton, reader: jspb.BinaryReader): BulletinButton; +} + +export namespace BulletinButton { + export type AsObject = { + label: string, + } +} + export class Action extends jspb.Message { hasDeepLink(): boolean; diff --git a/core/api/src/services/notifications/proto/notifications_pb.js b/core/api/src/services/notifications/proto/notifications_pb.js index f334b68454..c0ccbc8f25 100644 --- a/core/api/src/services/notifications/proto/notifications_pb.js +++ b/core/api/src/services/notifications/proto/notifications_pb.js @@ -25,6 +25,7 @@ goog.exportSymbol('proto.services.notifications.v1.Action', null, global); goog.exportSymbol('proto.services.notifications.v1.Action.DataCase', null, global); goog.exportSymbol('proto.services.notifications.v1.AddPushDeviceTokenRequest', null, global); goog.exportSymbol('proto.services.notifications.v1.AddPushDeviceTokenResponse', null, global); +goog.exportSymbol('proto.services.notifications.v1.BulletinButton', null, global); goog.exportSymbol('proto.services.notifications.v1.ChannelNotificationSettings', null, global); goog.exportSymbol('proto.services.notifications.v1.CircleGrew', null, global); goog.exportSymbol('proto.services.notifications.v1.CircleThresholdReached', null, global); @@ -849,6 +850,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.services.notifications.v1.LocalizedContent.displayName = 'proto.services.notifications.v1.LocalizedContent'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.notifications.v1.BulletinButton = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.notifications.v1.BulletinButton, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.notifications.v1.BulletinButton.displayName = 'proto.services.notifications.v1.BulletinButton'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -923,9 +945,9 @@ proto.services.notifications.v1.ShouldSendNotificationRequest.prototype.toObject */ proto.services.notifications.v1.ShouldSendNotificationRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - channel: jspb.Message.getFieldWithDefault(msg, 2, 0), - category: jspb.Message.getFieldWithDefault(msg, 3, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +channel: jspb.Message.getFieldWithDefault(msg, 2, 0), +category: jspb.Message.getFieldWithDefault(msg, 3, 0) }; if (includeInstance) { @@ -1113,8 +1135,8 @@ proto.services.notifications.v1.ShouldSendNotificationResponse.prototype.toObjec */ proto.services.notifications.v1.ShouldSendNotificationResponse.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - shouldSend: jspb.Message.getBooleanFieldWithDefault(msg, 2, false) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +shouldSend: jspb.Message.getBooleanFieldWithDefault(msg, 2, false) }; if (includeInstance) { @@ -1273,8 +1295,8 @@ proto.services.notifications.v1.EnableNotificationChannelRequest.prototype.toObj */ proto.services.notifications.v1.EnableNotificationChannelRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - channel: jspb.Message.getFieldWithDefault(msg, 2, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +channel: jspb.Message.getFieldWithDefault(msg, 2, 0) }; if (includeInstance) { @@ -1433,7 +1455,7 @@ proto.services.notifications.v1.EnableNotificationChannelResponse.prototype.toOb */ proto.services.notifications.v1.EnableNotificationChannelResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -1591,9 +1613,9 @@ proto.services.notifications.v1.NotificationSettings.prototype.toObject = functi */ proto.services.notifications.v1.NotificationSettings.toObject = function(includeInstance, msg) { var f, obj = { - push: (f = msg.getPush()) && proto.services.notifications.v1.ChannelNotificationSettings.toObject(includeInstance, f), - locale: jspb.Message.getFieldWithDefault(msg, 2, ""), - pushDeviceTokensList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f +push: (f = msg.getPush()) && proto.services.notifications.v1.ChannelNotificationSettings.toObject(includeInstance, f), +locale: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f, +pushDeviceTokensList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f }; if (includeInstance) { @@ -1846,8 +1868,8 @@ proto.services.notifications.v1.ChannelNotificationSettings.prototype.toObject = */ proto.services.notifications.v1.ChannelNotificationSettings.toObject = function(includeInstance, msg) { var f, obj = { - enabled: jspb.Message.getBooleanFieldWithDefault(msg, 1, false), - disabledCategoriesList: (f = jspb.Message.getRepeatedField(msg, 2)) == null ? undefined : f +enabled: jspb.Message.getBooleanFieldWithDefault(msg, 1, false), +disabledCategoriesList: (f = jspb.Message.getRepeatedField(msg, 2)) == null ? undefined : f }; if (includeInstance) { @@ -2027,8 +2049,8 @@ proto.services.notifications.v1.DisableNotificationChannelRequest.prototype.toOb */ proto.services.notifications.v1.DisableNotificationChannelRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - channel: jspb.Message.getFieldWithDefault(msg, 2, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +channel: jspb.Message.getFieldWithDefault(msg, 2, 0) }; if (includeInstance) { @@ -2187,7 +2209,7 @@ proto.services.notifications.v1.DisableNotificationChannelResponse.prototype.toO */ proto.services.notifications.v1.DisableNotificationChannelResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -2338,9 +2360,9 @@ proto.services.notifications.v1.DisableNotificationCategoryRequest.prototype.toO */ proto.services.notifications.v1.DisableNotificationCategoryRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - channel: jspb.Message.getFieldWithDefault(msg, 2, 0), - category: jspb.Message.getFieldWithDefault(msg, 3, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +channel: jspb.Message.getFieldWithDefault(msg, 2, 0), +category: jspb.Message.getFieldWithDefault(msg, 3, 0) }; if (includeInstance) { @@ -2528,7 +2550,7 @@ proto.services.notifications.v1.DisableNotificationCategoryResponse.prototype.to */ proto.services.notifications.v1.DisableNotificationCategoryResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -2679,9 +2701,9 @@ proto.services.notifications.v1.EnableNotificationCategoryRequest.prototype.toOb */ proto.services.notifications.v1.EnableNotificationCategoryRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - channel: jspb.Message.getFieldWithDefault(msg, 2, 0), - category: jspb.Message.getFieldWithDefault(msg, 3, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +channel: jspb.Message.getFieldWithDefault(msg, 2, 0), +category: jspb.Message.getFieldWithDefault(msg, 3, 0) }; if (includeInstance) { @@ -2869,7 +2891,7 @@ proto.services.notifications.v1.EnableNotificationCategoryResponse.prototype.toO */ proto.services.notifications.v1.EnableNotificationCategoryResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -3020,7 +3042,7 @@ proto.services.notifications.v1.GetNotificationSettingsRequest.prototype.toObjec */ proto.services.notifications.v1.GetNotificationSettingsRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, "") }; if (includeInstance) { @@ -3150,7 +3172,7 @@ proto.services.notifications.v1.GetNotificationSettingsResponse.prototype.toObje */ proto.services.notifications.v1.GetNotificationSettingsResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -3301,8 +3323,8 @@ proto.services.notifications.v1.UpdateUserLocaleRequest.prototype.toObject = fun */ proto.services.notifications.v1.UpdateUserLocaleRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - locale: jspb.Message.getFieldWithDefault(msg, 2, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +locale: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -3461,7 +3483,7 @@ proto.services.notifications.v1.UpdateUserLocaleResponse.prototype.toObject = fu */ proto.services.notifications.v1.UpdateUserLocaleResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -3612,8 +3634,8 @@ proto.services.notifications.v1.AddPushDeviceTokenRequest.prototype.toObject = f */ proto.services.notifications.v1.AddPushDeviceTokenRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - deviceToken: jspb.Message.getFieldWithDefault(msg, 2, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +deviceToken: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -3772,7 +3794,7 @@ proto.services.notifications.v1.AddPushDeviceTokenResponse.prototype.toObject = */ proto.services.notifications.v1.AddPushDeviceTokenResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -3923,8 +3945,8 @@ proto.services.notifications.v1.RemovePushDeviceTokenRequest.prototype.toObject */ proto.services.notifications.v1.RemovePushDeviceTokenRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - deviceToken: jspb.Message.getFieldWithDefault(msg, 2, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +deviceToken: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -4083,7 +4105,7 @@ proto.services.notifications.v1.RemovePushDeviceTokenResponse.prototype.toObject */ proto.services.notifications.v1.RemovePushDeviceTokenResponse.toObject = function(includeInstance, msg) { var f, obj = { - notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) +notificationSettings: (f = msg.getNotificationSettings()) && proto.services.notifications.v1.NotificationSettings.toObject(includeInstance, f) }; if (includeInstance) { @@ -4234,8 +4256,8 @@ proto.services.notifications.v1.UpdateEmailAddressRequest.prototype.toObject = f */ proto.services.notifications.v1.UpdateEmailAddressRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - emailAddress: jspb.Message.getFieldWithDefault(msg, 2, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +emailAddress: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -4495,7 +4517,7 @@ proto.services.notifications.v1.RemoveEmailAddressRequest.prototype.toObject = f */ proto.services.notifications.v1.RemoveEmailAddressRequest.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, "") }; if (includeInstance) { @@ -4726,7 +4748,7 @@ proto.services.notifications.v1.HandleNotificationEventRequest.prototype.toObjec */ proto.services.notifications.v1.HandleNotificationEventRequest.toObject = function(includeInstance, msg) { var f, obj = { - event: (f = msg.getEvent()) && proto.services.notifications.v1.NotificationEvent.toObject(includeInstance, f) +event: (f = msg.getEvent()) && proto.services.notifications.v1.NotificationEvent.toObject(includeInstance, f) }; if (includeInstance) { @@ -5010,14 +5032,14 @@ proto.services.notifications.v1.NotificationEvent.prototype.toObject = function( */ proto.services.notifications.v1.NotificationEvent.toObject = function(includeInstance, msg) { var f, obj = { - circleGrew: (f = msg.getCircleGrew()) && proto.services.notifications.v1.CircleGrew.toObject(includeInstance, f), - circleThresholdReached: (f = msg.getCircleThresholdReached()) && proto.services.notifications.v1.CircleThresholdReached.toObject(includeInstance, f), - identityVerificationApproved: (f = msg.getIdentityVerificationApproved()) && proto.services.notifications.v1.IdentityVerificationApproved.toObject(includeInstance, f), - identityVerificationDeclined: (f = msg.getIdentityVerificationDeclined()) && proto.services.notifications.v1.IdentityVerificationDeclined.toObject(includeInstance, f), - identityVerificationReviewStarted: (f = msg.getIdentityVerificationReviewStarted()) && proto.services.notifications.v1.IdentityVerificationReviewStarted.toObject(includeInstance, f), - transactionOccurred: (f = msg.getTransactionOccurred()) && proto.services.notifications.v1.TransactionOccurred.toObject(includeInstance, f), - price: (f = msg.getPrice()) && proto.services.notifications.v1.PriceChanged.toObject(includeInstance, f), - marketingNotificationTriggered: (f = msg.getMarketingNotificationTriggered()) && proto.services.notifications.v1.MarketingNotificationTriggered.toObject(includeInstance, f) +circleGrew: (f = msg.getCircleGrew()) && proto.services.notifications.v1.CircleGrew.toObject(includeInstance, f), +circleThresholdReached: (f = msg.getCircleThresholdReached()) && proto.services.notifications.v1.CircleThresholdReached.toObject(includeInstance, f), +identityVerificationApproved: (f = msg.getIdentityVerificationApproved()) && proto.services.notifications.v1.IdentityVerificationApproved.toObject(includeInstance, f), +identityVerificationDeclined: (f = msg.getIdentityVerificationDeclined()) && proto.services.notifications.v1.IdentityVerificationDeclined.toObject(includeInstance, f), +identityVerificationReviewStarted: (f = msg.getIdentityVerificationReviewStarted()) && proto.services.notifications.v1.IdentityVerificationReviewStarted.toObject(includeInstance, f), +transactionOccurred: (f = msg.getTransactionOccurred()) && proto.services.notifications.v1.TransactionOccurred.toObject(includeInstance, f), +price: (f = msg.getPrice()) && proto.services.notifications.v1.PriceChanged.toObject(includeInstance, f), +marketingNotificationTriggered: (f = msg.getMarketingNotificationTriggered()) && proto.services.notifications.v1.MarketingNotificationTriggered.toObject(includeInstance, f) }; if (includeInstance) { @@ -5518,10 +5540,10 @@ proto.services.notifications.v1.CircleGrew.prototype.toObject = function(opt_inc */ proto.services.notifications.v1.CircleGrew.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - circleType: jspb.Message.getFieldWithDefault(msg, 2, 0), - thisMonthCircleSize: jspb.Message.getFieldWithDefault(msg, 3, 0), - allTimeCircleSize: jspb.Message.getFieldWithDefault(msg, 4, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +circleType: jspb.Message.getFieldWithDefault(msg, 2, 0), +thisMonthCircleSize: jspb.Message.getFieldWithDefault(msg, 3, 0), +allTimeCircleSize: jspb.Message.getFieldWithDefault(msg, 4, 0) }; if (includeInstance) { @@ -5738,10 +5760,10 @@ proto.services.notifications.v1.CircleThresholdReached.prototype.toObject = func */ proto.services.notifications.v1.CircleThresholdReached.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - circleType: jspb.Message.getFieldWithDefault(msg, 2, 0), - timeFrame: jspb.Message.getFieldWithDefault(msg, 3, 0), - threshold: jspb.Message.getFieldWithDefault(msg, 4, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +circleType: jspb.Message.getFieldWithDefault(msg, 2, 0), +timeFrame: jspb.Message.getFieldWithDefault(msg, 3, 0), +threshold: jspb.Message.getFieldWithDefault(msg, 4, 0) }; if (includeInstance) { @@ -5958,7 +5980,7 @@ proto.services.notifications.v1.IdentityVerificationApproved.prototype.toObject */ proto.services.notifications.v1.IdentityVerificationApproved.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, "") }; if (includeInstance) { @@ -6088,8 +6110,8 @@ proto.services.notifications.v1.IdentityVerificationDeclined.prototype.toObject */ proto.services.notifications.v1.IdentityVerificationDeclined.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - declinedReason: jspb.Message.getFieldWithDefault(msg, 2, 0) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +declinedReason: jspb.Message.getFieldWithDefault(msg, 2, 0) }; if (includeInstance) { @@ -6248,7 +6270,7 @@ proto.services.notifications.v1.IdentityVerificationReviewStarted.prototype.toOb */ proto.services.notifications.v1.IdentityVerificationReviewStarted.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, "") +userId: jspb.Message.getFieldWithDefault(msg, 1, "") }; if (includeInstance) { @@ -6378,10 +6400,10 @@ proto.services.notifications.v1.TransactionOccurred.prototype.toObject = functio */ proto.services.notifications.v1.TransactionOccurred.toObject = function(includeInstance, msg) { var f, obj = { - userId: jspb.Message.getFieldWithDefault(msg, 1, ""), - type: jspb.Message.getFieldWithDefault(msg, 2, 0), - settlementAmount: (f = msg.getSettlementAmount()) && proto.services.notifications.v1.Money.toObject(includeInstance, f), - displayAmount: (f = msg.getDisplayAmount()) && proto.services.notifications.v1.Money.toObject(includeInstance, f) +userId: jspb.Message.getFieldWithDefault(msg, 1, ""), +type: jspb.Message.getFieldWithDefault(msg, 2, 0), +settlementAmount: (f = msg.getSettlementAmount()) && proto.services.notifications.v1.Money.toObject(includeInstance, f), +displayAmount: (f = msg.getDisplayAmount()) && proto.services.notifications.v1.Money.toObject(includeInstance, f) }; if (includeInstance) { @@ -6640,8 +6662,8 @@ proto.services.notifications.v1.Money.prototype.toObject = function(opt_includeI */ proto.services.notifications.v1.Money.toObject = function(includeInstance, msg) { var f, obj = { - currencyCode: jspb.Message.getFieldWithDefault(msg, 1, ""), - minorUnits: jspb.Message.getFieldWithDefault(msg, 2, 0) +currencyCode: jspb.Message.getFieldWithDefault(msg, 1, ""), +minorUnits: jspb.Message.getFieldWithDefault(msg, 2, 0) }; if (includeInstance) { @@ -6800,9 +6822,9 @@ proto.services.notifications.v1.PriceChanged.prototype.toObject = function(opt_i */ proto.services.notifications.v1.PriceChanged.toObject = function(includeInstance, msg) { var f, obj = { - priceOfOneBitcoin: (f = msg.getPriceOfOneBitcoin()) && proto.services.notifications.v1.Money.toObject(includeInstance, f), - direction: jspb.Message.getFieldWithDefault(msg, 2, 0), - priceChangePercentage: jspb.Message.getFloatingPointFieldWithDefault(msg, 3, 0.0) +priceOfOneBitcoin: (f = msg.getPriceOfOneBitcoin()) && proto.services.notifications.v1.Money.toObject(includeInstance, f), +direction: jspb.Message.getFieldWithDefault(msg, 2, 0), +priceChangePercentage: jspb.Message.getFloatingPointFieldWithDefault(msg, 3, 0.0) }; if (includeInstance) { @@ -7018,13 +7040,14 @@ proto.services.notifications.v1.MarketingNotificationTriggered.prototype.toObjec */ proto.services.notifications.v1.MarketingNotificationTriggered.toObject = function(includeInstance, msg) { var f, obj = { - userIdsList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f, - localizedContentMap: (f = msg.getLocalizedContentMap()) ? f.toObject(includeInstance, proto.services.notifications.v1.LocalizedContent.toObject) : [], - shouldSendPush: jspb.Message.getBooleanFieldWithDefault(msg, 3, false), - shouldAddToHistory: jspb.Message.getBooleanFieldWithDefault(msg, 4, false), - shouldAddToBulletin: jspb.Message.getBooleanFieldWithDefault(msg, 5, false), - action: (f = msg.getAction()) && proto.services.notifications.v1.Action.toObject(includeInstance, f), - icon: jspb.Message.getFieldWithDefault(msg, 7, 0) +userIdsList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f, +localizedContentMap: (f = msg.getLocalizedContentMap()) ? f.toObject(includeInstance, proto.services.notifications.v1.LocalizedContent.toObject) : [], +shouldSendPush: jspb.Message.getBooleanFieldWithDefault(msg, 3, false), +shouldAddToHistory: jspb.Message.getBooleanFieldWithDefault(msg, 4, false), +shouldAddToBulletin: jspb.Message.getBooleanFieldWithDefault(msg, 5, false), +action: (f = msg.getAction()) && proto.services.notifications.v1.Action.toObject(includeInstance, f), +icon: (f = jspb.Message.getField(msg, 7)) == null ? undefined : f, +bulletinButton: (f = msg.getBulletinButton()) && proto.services.notifications.v1.BulletinButton.toObject(includeInstance, f) }; if (includeInstance) { @@ -7092,6 +7115,11 @@ proto.services.notifications.v1.MarketingNotificationTriggered.deserializeBinary var value = /** @type {!proto.services.notifications.v1.Icon} */ (reader.readEnum()); msg.setIcon(value); break; + case 8: + var value = new proto.services.notifications.v1.BulletinButton; + reader.readMessage(value,proto.services.notifications.v1.BulletinButton.deserializeBinaryFromReader); + msg.setBulletinButton(value); + break; default: reader.skipField(); break; @@ -7168,6 +7196,14 @@ proto.services.notifications.v1.MarketingNotificationTriggered.serializeBinaryTo f ); } + f = message.getBulletinButton(); + if (f != null) { + writer.writeMessage( + 8, + f, + proto.services.notifications.v1.BulletinButton.serializeBinaryToWriter + ); + } }; @@ -7358,6 +7394,43 @@ proto.services.notifications.v1.MarketingNotificationTriggered.prototype.hasIcon }; +/** + * optional BulletinButton bulletin_button = 8; + * @return {?proto.services.notifications.v1.BulletinButton} + */ +proto.services.notifications.v1.MarketingNotificationTriggered.prototype.getBulletinButton = function() { + return /** @type{?proto.services.notifications.v1.BulletinButton} */ ( + jspb.Message.getWrapperField(this, proto.services.notifications.v1.BulletinButton, 8)); +}; + + +/** + * @param {?proto.services.notifications.v1.BulletinButton|undefined} value + * @return {!proto.services.notifications.v1.MarketingNotificationTriggered} returns this +*/ +proto.services.notifications.v1.MarketingNotificationTriggered.prototype.setBulletinButton = function(value) { + return jspb.Message.setWrapperField(this, 8, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.services.notifications.v1.MarketingNotificationTriggered} returns this + */ +proto.services.notifications.v1.MarketingNotificationTriggered.prototype.clearBulletinButton = function() { + return this.setBulletinButton(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.notifications.v1.MarketingNotificationTriggered.prototype.hasBulletinButton = function() { + return jspb.Message.getField(this, 8) != null; +}; + + @@ -7390,8 +7463,8 @@ proto.services.notifications.v1.LocalizedContent.prototype.toObject = function(o */ proto.services.notifications.v1.LocalizedContent.toObject = function(includeInstance, msg) { var f, obj = { - title: jspb.Message.getFieldWithDefault(msg, 1, ""), - body: jspb.Message.getFieldWithDefault(msg, 2, "") +title: jspb.Message.getFieldWithDefault(msg, 1, ""), +body: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -7519,6 +7592,136 @@ proto.services.notifications.v1.LocalizedContent.prototype.setBody = function(va + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.notifications.v1.BulletinButton.prototype.toObject = function(opt_includeInstance) { + return proto.services.notifications.v1.BulletinButton.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.notifications.v1.BulletinButton} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.notifications.v1.BulletinButton.toObject = function(includeInstance, msg) { + var f, obj = { +label: jspb.Message.getFieldWithDefault(msg, 1, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.notifications.v1.BulletinButton} + */ +proto.services.notifications.v1.BulletinButton.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.notifications.v1.BulletinButton; + return proto.services.notifications.v1.BulletinButton.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.notifications.v1.BulletinButton} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.notifications.v1.BulletinButton} + */ +proto.services.notifications.v1.BulletinButton.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setLabel(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.notifications.v1.BulletinButton.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.notifications.v1.BulletinButton.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.notifications.v1.BulletinButton} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.notifications.v1.BulletinButton.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getLabel(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } +}; + + +/** + * optional string label = 1; + * @return {string} + */ +proto.services.notifications.v1.BulletinButton.prototype.getLabel = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.notifications.v1.BulletinButton} returns this + */ +proto.services.notifications.v1.BulletinButton.prototype.setLabel = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + + /** * Oneof group definitions for this message. Each group defines the field * numbers belonging to that group. When of these fields' value is set, all @@ -7576,8 +7779,8 @@ proto.services.notifications.v1.Action.prototype.toObject = function(opt_include */ proto.services.notifications.v1.Action.toObject = function(includeInstance, msg) { var f, obj = { - deepLink: (f = msg.getDeepLink()) && proto.services.notifications.v1.DeepLink.toObject(includeInstance, f), - externalUrl: jspb.Message.getFieldWithDefault(msg, 2, "") +deepLink: (f = msg.getDeepLink()) && proto.services.notifications.v1.DeepLink.toObject(includeInstance, f), +externalUrl: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f }; if (includeInstance) { @@ -7775,8 +7978,8 @@ proto.services.notifications.v1.DeepLink.prototype.toObject = function(opt_inclu */ proto.services.notifications.v1.DeepLink.toObject = function(includeInstance, msg) { var f, obj = { - screen: jspb.Message.getFieldWithDefault(msg, 1, 0), - action: jspb.Message.getFieldWithDefault(msg, 2, 0) +screen: (f = jspb.Message.getField(msg, 1)) == null ? undefined : f, +action: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f }; if (includeInstance) { diff --git a/core/notifications/proto/notifications.proto b/core/notifications/proto/notifications.proto index adf15bb8f3..0c011601ee 100644 --- a/core/notifications/proto/notifications.proto +++ b/core/notifications/proto/notifications.proto @@ -243,6 +243,7 @@ message MarketingNotificationTriggered { bool should_add_to_bulletin = 5; optional Action action = 6; optional Icon icon = 7; + optional BulletinButton bulletin_button = 8; } message LocalizedContent { @@ -250,6 +251,10 @@ message LocalizedContent { string body = 2; } +message BulletinButton { + string label = 1; +} + message Action { oneof data { DeepLink deep_link = 1; diff --git a/core/notifications/src/graphql/convert.rs b/core/notifications/src/graphql/convert.rs index 762ed1c749..eb48d5cff9 100644 --- a/core/notifications/src/graphql/convert.rs +++ b/core/notifications/src/graphql/convert.rs @@ -30,6 +30,9 @@ impl From for types::StatefulNotification { }, ), }), + bulletin_button: notification + .bulletin_button() + .map(|b| types::BulletinButton { label: b.label }), icon: notification.icon().map(Into::into), } } diff --git a/core/notifications/src/graphql/types.rs b/core/notifications/src/graphql/types.rs index c44bb19230..31461eab53 100644 --- a/core/notifications/src/graphql/types.rs +++ b/core/notifications/src/graphql/types.rs @@ -38,6 +38,11 @@ impl ScalarType for Timestamp { } } +#[derive(SimpleObject)] +pub(super) struct BulletinButton { + pub label: String, +} + #[derive(SimpleObject)] pub(super) struct OpenDeepLinkAction { pub deep_link: String, @@ -61,6 +66,7 @@ pub(super) struct StatefulNotification { pub body: String, pub deep_link: Option, pub action: Option, + pub bulletin_button: Option, pub created_at: Timestamp, pub acknowledged_at: Option, pub bulletin_enabled: bool, diff --git a/core/notifications/src/grpc/server/convert.rs b/core/notifications/src/grpc/server/convert.rs index 9b6d3d2736..91619f39f7 100644 --- a/core/notifications/src/grpc/server/convert.rs +++ b/core/notifications/src/grpc/server/convert.rs @@ -287,6 +287,14 @@ impl TryFrom for notification_event::Action { } } +impl From for notification_event::BulletinButton { + fn from(button: proto::BulletinButton) -> Self { + Self { + label: button.label, + } + } +} + impl From for notification_event::Icon { fn from(icon: proto::Icon) -> Self { match icon { diff --git a/core/notifications/src/grpc/server/mod.rs b/core/notifications/src/grpc/server/mod.rs index 68315997b2..6404410593 100644 --- a/core/notifications/src/grpc/server/mod.rs +++ b/core/notifications/src/grpc/server/mod.rs @@ -410,6 +410,7 @@ impl NotificationsService for Notifications { user_ids, action, icon, + bulletin_button, }, )), }) => { @@ -441,6 +442,8 @@ impl NotificationsService for Notifications { .map(notification_event::Action::try_from) .transpose()?; + let bulletin_button = bulletin_button.map(notification_event::BulletinButton::from); + let icon = if let Some(icon) = icon { Some( proto::Icon::try_from(icon) @@ -461,6 +464,7 @@ impl NotificationsService for Notifications { should_add_to_history, should_send_push, action, + bulletin_button, icon, }, ) diff --git a/core/notifications/src/history/entity.rs b/core/notifications/src/history/entity.rs index 82077b7a0c..1ae058d644 100644 --- a/core/notifications/src/history/entity.rs +++ b/core/notifications/src/history/entity.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::{ messages::LocalizedStatefulMessage, - notification_event::{Action, DeepLink, Icon, NotificationEventPayload}, + notification_event::{Action, BulletinButton, DeepLink, Icon, NotificationEventPayload}, primitives::*, }; @@ -94,6 +94,10 @@ impl StatefulNotification { self.payload.action() } + pub fn bulletin_button(&self) -> Option { + self.payload.bulletin_button() + } + pub fn icon(&self) -> Option { self.payload.icon() } diff --git a/core/notifications/src/notification_event/marketing_notification_triggered.rs b/core/notifications/src/notification_event/marketing_notification_triggered.rs index f9ecafa6e7..6ca20f1283 100644 --- a/core/notifications/src/notification_event/marketing_notification_triggered.rs +++ b/core/notifications/src/notification_event/marketing_notification_triggered.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::{Action, Icon, NotificationEvent}; +use super::{Action, BulletinButton, Icon, NotificationEvent}; use crate::{messages::*, primitives::*}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -15,6 +15,8 @@ pub struct MarketingNotificationTriggered { #[serde(default)] pub action: Option, #[serde(default)] + pub bulletin_button: Option, + #[serde(default)] pub icon: Option, } @@ -27,6 +29,10 @@ impl NotificationEvent for MarketingNotificationTriggered { self.action.clone() } + fn bulletin_button(&self) -> Option { + self.bulletin_button.clone() + } + fn should_send_push(&self) -> bool { self.should_send_push } @@ -59,3 +65,140 @@ impl NotificationEvent for MarketingNotificationTriggered { self.icon.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn default_event() -> MarketingNotificationTriggered { + let default_content = LocalizedStatefulMessage { + locale: GaloyLocale::from("en".to_string()), + title: "Test Title".to_string(), + body: "Test Body".to_string(), + }; + MarketingNotificationTriggered { + content: HashMap::new(), + default_content, + should_send_push: true, + should_add_to_history: true, + should_add_to_bulletin: true, + action: None, + bulletin_button: None, + icon: None, + } + } + + #[test] + fn bulletin_button_returns_some_when_set() { + let mut event = default_event(); + event.bulletin_button = Some(BulletinButton { + label: "Deposit".to_string(), + }); + + let button = event + .bulletin_button() + .expect("should return bulletin button"); + assert_eq!(button.label, "Deposit"); + } + + #[test] + fn bulletin_button_returns_none_when_not_set() { + let event = default_event(); + assert!(event.bulletin_button().is_none()); + } + + #[test] + fn action_and_bulletin_button_coexist() { + let mut event = default_event(); + event.action = Some(Action::OpenDeepLink(DeepLink { + screen: None, + action: None, + })); + event.bulletin_button = Some(BulletinButton { + label: "Click me".to_string(), + }); + + assert!(event.action().is_some()); + let button = event + .bulletin_button() + .expect("should return bulletin button"); + assert_eq!(button.label, "Click me"); + } + + #[test] + fn push_msg_uses_default_content() { + let event = default_event(); + let msg = event.to_localized_push_msg(&GaloyLocale::from("en".to_string())); + assert_eq!(msg.title, "Test Title"); + assert_eq!(msg.body, "Test Body"); + } + + #[test] + fn push_msg_uses_localized_content_when_available() { + let mut event = default_event(); + let es_locale = GaloyLocale::from("es".to_string()); + event.content.insert( + es_locale.clone(), + LocalizedStatefulMessage { + locale: es_locale.clone(), + title: "Localized Title".to_string(), + body: "Localized Body".to_string(), + }, + ); + + let msg = event.to_localized_push_msg(&es_locale); + assert_eq!(msg.title, "Localized Title"); + assert_eq!(msg.body, "Localized Body"); + } + + #[test] + fn serde_roundtrip_with_bulletin_button() { + let mut event = default_event(); + event.bulletin_button = Some(BulletinButton { + label: "See more".to_string(), + }); + event.action = Some(Action::OpenExternalUrl(ExternalUrl::from( + "https://example.com".to_string(), + ))); + + let json = serde_json::to_string(&event).expect("should serialize"); + let deserialized: MarketingNotificationTriggered = + serde_json::from_str(&json).expect("should deserialize"); + + let button = deserialized + .bulletin_button() + .expect("should have bulletin button"); + assert_eq!(button.label, "See more"); + assert!(deserialized.action().is_some()); + } + + #[test] + fn serde_backward_compat_without_bulletin_button() { + let json = r#"{ + "content": {}, + "default_content": { + "locale": "en", + "title": "Old notification", + "body": "No bulletin button field" + }, + "should_send_push": false, + "should_add_to_history": true, + "should_add_to_bulletin": false + }"#; + + let event: MarketingNotificationTriggered = + serde_json::from_str(json).expect("should deserialize without bulletin_button"); + assert!(event.bulletin_button().is_none()); + assert!(event.action().is_none()); + assert!(event.icon().is_none()); + } + + #[test] + fn icon_returns_value_when_set() { + let mut event = default_event(); + event.icon = Some(Icon::Bell); + + let icon = event.icon().expect("should return icon"); + assert!(matches!(icon, Icon::Bell)); + } +} diff --git a/core/notifications/src/notification_event/mod.rs b/core/notifications/src/notification_event/mod.rs index db3cc400e5..c1897b4f94 100644 --- a/core/notifications/src/notification_event/mod.rs +++ b/core/notifications/src/notification_event/mod.rs @@ -115,6 +115,11 @@ pub enum DeepLinkAction { UpgradeAccountModal, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BulletinButton { + pub label: String, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub enum Action { OpenDeepLink(DeepLink), @@ -208,6 +213,10 @@ pub trait NotificationEvent: std::fmt::Debug + Send + Sync { None } + fn bulletin_button(&self) -> Option { + None + } + fn icon(&self) -> Option { None } diff --git a/core/notifications/subgraph/schema.graphql b/core/notifications/subgraph/schema.graphql index 842ca56e71..46a075d4e5 100644 --- a/core/notifications/subgraph/schema.graphql +++ b/core/notifications/subgraph/schema.graphql @@ -1,3 +1,9 @@ +type BulletinButton { + label: String! +} + + + enum Icon { ARROW_RIGHT ARROW_LEFT @@ -96,6 +102,7 @@ type StatefulNotification { body: String! deepLink: String action: NotificationAction + bulletinButton: BulletinButton createdAt: Timestamp! acknowledgedAt: Timestamp bulletinEnabled: Boolean! From 30f94cec9597836dad31f02501bc62211c7ccf92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 11 Feb 2026 15:06:39 -0600 Subject: [PATCH 02/17] feat(notifications): add BulletinButton support to API --- core/api/src/app/admin/index.types.d.ts | 5 +++++ .../app/admin/trigger-marketing-notification.ts | 2 ++ .../api/src/domain/notifications/index.types.d.ts | 5 +++++ .../mutation/marketing-notification-trigger.ts | 15 +++++++++++++++ core/api/src/graphql/admin/schema.graphql | 5 +++++ core/api/src/services/notifications/index.ts | 10 ++++++++++ dev/config/apollo-federation/supergraph.graphql | 9 ++++++++- 7 files changed, 50 insertions(+), 1 deletion(-) diff --git a/core/api/src/app/admin/index.types.d.ts b/core/api/src/app/admin/index.types.d.ts index e089d8e8d9..b3d8c52d9e 100644 --- a/core/api/src/app/admin/index.types.d.ts +++ b/core/api/src/app/admin/index.types.d.ts @@ -12,6 +12,11 @@ type AdminTriggerMarketingNotificationArgs = { url: string } | undefined + bulletinButton: + | { + label: string + } + | undefined shouldSendPush: boolean shouldAddToHistory: boolean shouldAddToBulletin: boolean diff --git a/core/api/src/app/admin/trigger-marketing-notification.ts b/core/api/src/app/admin/trigger-marketing-notification.ts index afd463d98c..94c47eaf5e 100644 --- a/core/api/src/app/admin/trigger-marketing-notification.ts +++ b/core/api/src/app/admin/trigger-marketing-notification.ts @@ -8,6 +8,7 @@ export const triggerMarketingNotification = async ({ phoneCountryCodesFilter, openDeepLink, openExternalUrl, + bulletinButton, shouldSendPush, icon, shouldAddToHistory, @@ -49,6 +50,7 @@ export const triggerMarketingNotification = async ({ userIds: userIdsToNotify, openDeepLink, openExternalUrl, + bulletinButton, shouldSendPush, shouldAddToHistory, shouldAddToBulletin, diff --git a/core/api/src/domain/notifications/index.types.d.ts b/core/api/src/domain/notifications/index.types.d.ts index 896c3fce42..65de4b7dbc 100644 --- a/core/api/src/domain/notifications/index.types.d.ts +++ b/core/api/src/domain/notifications/index.types.d.ts @@ -152,6 +152,11 @@ type TriggerMarketingNotificationArgs = { url: string } | undefined + bulletinButton: + | { + label: string + } + | undefined shouldSendPush: boolean shouldAddToHistory: boolean shouldAddToBulletin: boolean diff --git a/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts b/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts index 36ec89d1a4..1d81aa4178 100644 --- a/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts +++ b/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts @@ -45,6 +45,15 @@ const OpenExternalUrlInput = GT.Input({ }), }) +const BulletinButtonInput = GT.Input({ + name: "BulletinButtonInput", + fields: () => ({ + label: { + type: GT.NonNull(GT.String), + }, + }), +}) + const MarketingNotificationTriggerInput = GT.Input({ name: "MarketingNotificationTriggerInput", fields: () => ({ @@ -69,6 +78,9 @@ const MarketingNotificationTriggerInput = GT.Input({ openExternalUrl: { type: OpenExternalUrlInput, }, + bulletinButton: { + type: BulletinButtonInput, + }, icon: { type: NotificationIcon, }, @@ -96,6 +108,7 @@ const MarketingNotificationTriggerMutation = GT.Field< } | undefined openExternalUrl: { url: string | Error } | undefined + bulletinButton: { label: string } | undefined localizedNotificationContents: { title: string body: string @@ -121,6 +134,7 @@ const MarketingNotificationTriggerMutation = GT.Field< icon, openDeepLink, openExternalUrl, + bulletinButton, localizedNotificationContents, } = args.input @@ -192,6 +206,7 @@ const MarketingNotificationTriggerMutation = GT.Field< phoneCountryCodesFilter: nonErrorPhoneCountryCodesFilter, openDeepLink: nonErrorOpenDeepLink, openExternalUrl: nonErrorOpenExternalUrl, + bulletinButton, shouldSendPush, shouldAddToHistory, shouldAddToBulletin, diff --git a/core/api/src/graphql/admin/schema.graphql b/core/api/src/graphql/admin/schema.graphql index 7f9a020e2f..c8541c24de 100644 --- a/core/api/src/graphql/admin/schema.graphql +++ b/core/api/src/graphql/admin/schema.graphql @@ -137,6 +137,10 @@ type BTCWallet implements Wallet { walletCurrency: WalletCurrency! } +input BulletinButtonInput { + label: String! +} + type Coordinates { latitude: Float! longitude: Float! @@ -311,6 +315,7 @@ input LocalizedNotificationContentInput { } input MarketingNotificationTriggerInput { + bulletinButton: BulletinButtonInput icon: NotificationIcon localizedNotificationContents: [LocalizedNotificationContentInput!]! openDeepLink: OpenDeepLinkInput diff --git a/core/api/src/services/notifications/index.ts b/core/api/src/services/notifications/index.ts index 78debd639c..5005acf98d 100644 --- a/core/api/src/services/notifications/index.ts +++ b/core/api/src/services/notifications/index.ts @@ -28,6 +28,7 @@ import { DeepLink as ProtoDeepLink, HandleNotificationEventResponse, Action, + BulletinButton, } from "./proto/notifications_pb" import * as notificationsGrpc from "./grpc-client" @@ -638,6 +639,7 @@ export const NotificationsService = (): INotificationsService => { localizedContents: localizedPushContents, openDeepLink, openExternalUrl, + bulletinButton, shouldSendPush, shouldAddToHistory, shouldAddToBulletin, @@ -655,6 +657,12 @@ export const NotificationsService = (): INotificationsService => { const externalUrl = openExternalUrl?.url + let protoBulletinButton: BulletinButton | undefined = undefined + if (bulletinButton) { + protoBulletinButton = new BulletinButton() + protoBulletinButton.setLabel(bulletinButton.label) + } + let action: Action | undefined = undefined if (deepLink !== undefined || externalUrl !== undefined) { action = new Action() @@ -677,6 +685,8 @@ export const NotificationsService = (): INotificationsService => { marketingNotification.getLocalizedContentMap().set(language, localizedContent) }) if (action !== undefined) marketingNotification.setAction(action) + if (protoBulletinButton !== undefined) + marketingNotification.setBulletinButton(protoBulletinButton) if (protoIcon !== undefined) marketingNotification.setIcon(protoIcon) marketingNotification.setShouldSendPush(shouldSendPush) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index 4aacd5a640..74a4b42693 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -319,6 +319,12 @@ type BuildInformation helmRevision: Int } +type BulletinButton + @join__type(graph: NOTIFICATIONS) +{ + label: String! +} + type CallbackEndpoint @join__type(graph: PUBLIC) { @@ -1938,6 +1944,7 @@ type StatefulNotification body: String! deepLink: String action: NotificationAction + bulletinButton: BulletinButton createdAt: Timestamp! acknowledgedAt: Timestamp bulletinEnabled: Boolean! @@ -2587,4 +2594,4 @@ enum WalletCurrency """Unique identifier of a wallet""" scalar WalletId - @join__type(graph: PUBLIC) \ No newline at end of file + @join__type(graph: PUBLIC) From c7a7f169ea5f199a8bcc2abfb0b3b762a4197b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 11 Feb 2026 15:06:46 -0600 Subject: [PATCH 03/17] test(notifications): add e2e tests for BulletinButton --- bats/core/notifications/notifications.bats | 128 ++++++++++++++++++ ...notifications-without-bulletin-enabled.gql | 12 ++ bats/gql/list-stateful-notifications.gql | 12 ++ ...ul-notifications-with-bulletin-enabled.gql | 12 ++ 4 files changed, 164 insertions(+) diff --git a/bats/core/notifications/notifications.bats b/bats/core/notifications/notifications.bats index 56d153c0e7..3e2bcd2bb3 100644 --- a/bats/core/notifications/notifications.bats +++ b/bats/core/notifications/notifications.bats @@ -239,3 +239,131 @@ setup_file() { count=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithoutBulletinEnabledCount') [[ $count -eq 2 ]] || exit 1 } + +@test "notifications: bulletin with button and external url action" { + admin_token="$(read_value 'admin.token')" + + variables=$( + jq -n \ + '{ + input: { + localizedNotificationContents: [ + { + language: "en", + title: "New feature available", + body: "Check out our latest update" + } + ], + shouldSendPush: false, + shouldAddToHistory: true, + shouldAddToBulletin: true, + bulletinButton: { + label: "Learn more" + }, + openExternalUrl: { + url: "https://example.com/update" + }, + icon: "BELL" + } + }' + ) + + exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" + + local button_label + local action_url + local icon + for i in {1..10}; do + exec_graphql 'alice' 'list-unacknowledged-stateful-notifications-with-bulletin-enabled' + button_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].bulletinButton.label') + [[ "$button_label" = "Learn more" ]] && break; + sleep 1 + done + [[ "$button_label" = "Learn more" ]] || exit 1 + + action_url=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.url') + [[ "$action_url" = "https://example.com/update" ]] || exit 1 + + icon=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].icon') + [[ "$icon" = "BELL" ]] || exit 1 +} + +@test "notifications: bulletin without button has null bulletinButton" { + admin_token="$(read_value 'admin.token')" + + variables=$( + jq -n \ + '{ + input: { + localizedNotificationContents: [ + { + language: "en", + title: "Simple notification", + body: "No button here" + } + ], + shouldSendPush: false, + shouldAddToHistory: true, + shouldAddToBulletin: true, + } + }' + ) + + exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" + + local button + local title + for i in {1..10}; do + exec_graphql 'alice' 'list-stateful-notifications' '{"first": 1}' + title=$(graphql_output '.data.me.statefulNotifications.nodes[0].title') + [[ "$title" = "Simple notification" ]] && break; + sleep 1 + done + [[ "$title" = "Simple notification" ]] || exit 1 + + button=$(graphql_output '.data.me.statefulNotifications.nodes[0].bulletinButton') + [[ "$button" = "null" ]] || exit 1 +} + +@test "notifications: bulletin with button and deep link action" { + admin_token="$(read_value 'admin.token')" + + variables=$( + jq -n \ + '{ + input: { + localizedNotificationContents: [ + { + language: "en", + title: "Complete your profile", + body: "Set up your account to get started" + } + ], + shouldSendPush: false, + shouldAddToHistory: true, + shouldAddToBulletin: true, + bulletinButton: { + label: "Go to settings" + }, + openDeepLink: { + screen: "SETTINGS" + } + } + }' + ) + + exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" + + local button_label + local deep_link + for i in {1..10}; do + exec_graphql 'alice' 'list-unacknowledged-stateful-notifications-with-bulletin-enabled' + button_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].bulletinButton.label') + [[ "$button_label" = "Go to settings" ]] && break; + sleep 1 + done + [[ "$button_label" = "Go to settings" ]] || exit 1 + + deep_link=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.deepLink') + [[ "$deep_link" = "/settings" ]] || exit 1 +} diff --git a/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql b/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql index 55145e887e..09ded51859 100644 --- a/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql +++ b/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql @@ -14,6 +14,18 @@ query listStatefulNotificationsWithoutBulletinEnabled($first: Int = 2, $after: S acknowledgedAt createdAt bulletinEnabled + action { + ... on OpenDeepLinkAction { + deepLink + } + ... on OpenExternalLinkAction { + url + } + } + bulletinButton { + label + } + icon } } } diff --git a/bats/gql/list-stateful-notifications.gql b/bats/gql/list-stateful-notifications.gql index 3af9a7f90c..7a54401587 100644 --- a/bats/gql/list-stateful-notifications.gql +++ b/bats/gql/list-stateful-notifications.gql @@ -14,6 +14,18 @@ query listStatefulNotifications($first: Int = 2, $after: String = null) { acknowledgedAt createdAt bulletinEnabled + action { + ... on OpenDeepLinkAction { + deepLink + } + ... on OpenExternalLinkAction { + url + } + } + bulletinButton { + label + } + icon } } } diff --git a/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql b/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql index 4c56c0a8fb..319d8f4550 100644 --- a/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql +++ b/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql @@ -13,6 +13,18 @@ query listUnacknowledgedStatefulNotificationsWithBulletinEnabled($first: Int = 2 deepLink acknowledgedAt createdAt + action { + ... on OpenDeepLinkAction { + deepLink + } + ... on OpenExternalLinkAction { + url + } + } + bulletinButton { + label + } + icon } } } From 2b7d7fcf2630245be25f1700c344df2859bb16d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 11 Feb 2026 15:31:47 -0600 Subject: [PATCH 04/17] fix(notifications): add missing imports in BulletinButton tests --- .../src/notification_event/marketing_notification_triggered.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/notifications/src/notification_event/marketing_notification_triggered.rs b/core/notifications/src/notification_event/marketing_notification_triggered.rs index 6ca20f1283..2cffe59362 100644 --- a/core/notifications/src/notification_event/marketing_notification_triggered.rs +++ b/core/notifications/src/notification_event/marketing_notification_triggered.rs @@ -69,6 +69,7 @@ impl NotificationEvent for MarketingNotificationTriggered { #[cfg(test)] mod tests { use super::*; + use crate::notification_event::{DeepLink, ExternalUrl}; fn default_event() -> MarketingNotificationTriggered { let default_content = LocalizedStatefulMessage { From d88b8ed65c064850fc40c599f9b2da2df3beb58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 11 Feb 2026 15:40:35 -0600 Subject: [PATCH 05/17] fix(dev): remove trailing newline from supergraph.graphql --- dev/config/apollo-federation/supergraph.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index 74a4b42693..23d67ec996 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -2594,4 +2594,4 @@ enum WalletCurrency """Unique identifier of a wallet""" scalar WalletId - @join__type(graph: PUBLIC) + @join__type(graph: PUBLIC) \ No newline at end of file From 6e4935fe53e9afca2fcf4c941aebd38629f5e865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 18 Feb 2026 09:46:25 -0600 Subject: [PATCH 06/17] fix: remove extra blank lines in notifications subgraph schema --- core/notifications/subgraph/schema.graphql | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/notifications/subgraph/schema.graphql b/core/notifications/subgraph/schema.graphql index 46a075d4e5..5d135680f9 100644 --- a/core/notifications/subgraph/schema.graphql +++ b/core/notifications/subgraph/schema.graphql @@ -2,8 +2,6 @@ type BulletinButton { label: String! } - - enum Icon { ARROW_RIGHT ARROW_LEFT From b22aaa48833582b03d64ff6d0eef31411a11a988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 20 Feb 2026 08:31:05 -0600 Subject: [PATCH 07/17] fix(notifications): match generated SDL blank lines in subgraph schema --- core/notifications/subgraph/schema.graphql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/notifications/subgraph/schema.graphql b/core/notifications/subgraph/schema.graphql index 5d135680f9..46a075d4e5 100644 --- a/core/notifications/subgraph/schema.graphql +++ b/core/notifications/subgraph/schema.graphql @@ -2,6 +2,8 @@ type BulletinButton { label: String! } + + enum Icon { ARROW_RIGHT ARROW_LEFT From 52e6106ec742bbd4ea33a8837c0ba7584f4e4175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 20 Feb 2026 08:32:29 -0600 Subject: [PATCH 08/17] fix(ci): pin DOCKER_API_VERSION=1.44 for GA runner compatibility --- .github/workflows/e2e-test.yml | 2 ++ .github/workflows/integration-test.yml | 2 ++ .github/workflows/mongodb-migrate.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 93ffccf411..35c3612fbc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -28,6 +28,8 @@ jobs: uses: DeterminateSystems/magic-nix-cache-action@v8 - name: Run bats tests + env: + DOCKER_API_VERSION: "1.44" run: | nix develop -c ./bats/ci_run.sh diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index e4a50210f9..8c01509077 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -95,6 +95,8 @@ jobs: - name: Build via buck2 run: nix develop -c buck2 build ${{ matrix.build_args }} - name: Start deps and run tests via tilt + env: + DOCKER_API_VERSION: "1.44" run: nix develop -c xvfb-run ./dev/bin/tilt-ci.sh ${{ matrix.component }} - name: Prepare Tilt log id: prepare_tilt_log diff --git a/.github/workflows/mongodb-migrate.yml b/.github/workflows/mongodb-migrate.yml index 7969ac6929..7b863a6b24 100644 --- a/.github/workflows/mongodb-migrate.yml +++ b/.github/workflows/mongodb-migrate.yml @@ -20,4 +20,6 @@ jobs: uses: DeterminateSystems/magic-nix-cache-action@v8 - uses: actions/checkout@v4 - name: Run clean mongodb migration + env: + DOCKER_API_VERSION: "1.44" run: nix develop -c ./dev/bin/tilt-ci.sh mongodb-migrate From 312de21f946fe5865d2a8bd186acbba649cb7f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 24 Feb 2026 15:31:49 -0600 Subject: [PATCH 09/17] Revert "fix(ci): pin DOCKER_API_VERSION=1.44 for GA runner compatibility" This reverts commit 2a9a3e8009f31fbfb398cfac17d9484ac5a014d2. --- .github/workflows/e2e-test.yml | 2 -- .github/workflows/integration-test.yml | 2 -- .github/workflows/mongodb-migrate.yml | 2 -- 3 files changed, 6 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 35c3612fbc..93ffccf411 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -28,8 +28,6 @@ jobs: uses: DeterminateSystems/magic-nix-cache-action@v8 - name: Run bats tests - env: - DOCKER_API_VERSION: "1.44" run: | nix develop -c ./bats/ci_run.sh diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 8c01509077..e4a50210f9 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -95,8 +95,6 @@ jobs: - name: Build via buck2 run: nix develop -c buck2 build ${{ matrix.build_args }} - name: Start deps and run tests via tilt - env: - DOCKER_API_VERSION: "1.44" run: nix develop -c xvfb-run ./dev/bin/tilt-ci.sh ${{ matrix.component }} - name: Prepare Tilt log id: prepare_tilt_log diff --git a/.github/workflows/mongodb-migrate.yml b/.github/workflows/mongodb-migrate.yml index 7b863a6b24..7969ac6929 100644 --- a/.github/workflows/mongodb-migrate.yml +++ b/.github/workflows/mongodb-migrate.yml @@ -20,6 +20,4 @@ jobs: uses: DeterminateSystems/magic-nix-cache-action@v8 - uses: actions/checkout@v4 - name: Run clean mongodb migration - env: - DOCKER_API_VERSION: "1.44" run: nix develop -c ./dev/bin/tilt-ci.sh mongodb-migrate From bbff5e332c0fa399f0253c2e4e8c13ef6d301e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 25 Feb 2026 21:04:37 -0600 Subject: [PATCH 10/17] refactor(notifications): move label from BulletinButton to action types in proto and Rust Co-Authored-By: Claude Opus 4.6 --- .../notifications/proto/notifications.proto | 6 +- .../notifications/proto/notifications_pb.d.ts | 32 +-- .../notifications/proto/notifications_pb.js | 255 ++++-------------- core/notifications/proto/notifications.proto | 6 +- core/notifications/src/graphql/convert.rs | 18 +- core/notifications/src/graphql/types.rs | 8 +- core/notifications/src/grpc/server/convert.rs | 22 +- core/notifications/src/grpc/server/mod.rs | 4 - core/notifications/src/history/entity.rs | 6 +- .../src/notification_event/circle_grew.rs | 1 + .../circle_threshold_reached.rs | 1 + .../marketing_notification_triggered.rs | 103 +++---- .../src/notification_event/mod.rs | 58 +++- core/notifications/subgraph/schema.graphql | 9 +- 14 files changed, 186 insertions(+), 343 deletions(-) diff --git a/core/api/src/services/notifications/proto/notifications.proto b/core/api/src/services/notifications/proto/notifications.proto index 0c011601ee..1f14a60c54 100644 --- a/core/api/src/services/notifications/proto/notifications.proto +++ b/core/api/src/services/notifications/proto/notifications.proto @@ -243,7 +243,6 @@ message MarketingNotificationTriggered { bool should_add_to_bulletin = 5; optional Action action = 6; optional Icon icon = 7; - optional BulletinButton bulletin_button = 8; } message LocalizedContent { @@ -251,15 +250,12 @@ message LocalizedContent { string body = 2; } -message BulletinButton { - string label = 1; -} - message Action { oneof data { DeepLink deep_link = 1; string external_url = 2; } + optional string label = 3; } message DeepLink { diff --git a/core/api/src/services/notifications/proto/notifications_pb.d.ts b/core/api/src/services/notifications/proto/notifications_pb.d.ts index a7723b7551..c986532966 100644 --- a/core/api/src/services/notifications/proto/notifications_pb.d.ts +++ b/core/api/src/services/notifications/proto/notifications_pb.d.ts @@ -915,11 +915,6 @@ export class MarketingNotificationTriggered extends jspb.Message { getIcon(): Icon | undefined; setIcon(value: Icon): MarketingNotificationTriggered; - hasBulletinButton(): boolean; - clearBulletinButton(): void; - getBulletinButton(): BulletinButton | undefined; - setBulletinButton(value?: BulletinButton): MarketingNotificationTriggered; - serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): MarketingNotificationTriggered.AsObject; static toObject(includeInstance: boolean, msg: MarketingNotificationTriggered): MarketingNotificationTriggered.AsObject; @@ -940,7 +935,6 @@ export namespace MarketingNotificationTriggered { shouldAddToBulletin: boolean, action?: Action.AsObject, icon?: Icon, - bulletinButton?: BulletinButton.AsObject, } } @@ -967,26 +961,6 @@ export namespace LocalizedContent { } } -export class BulletinButton extends jspb.Message { - getLabel(): string; - setLabel(value: string): BulletinButton; - - serializeBinary(): Uint8Array; - toObject(includeInstance?: boolean): BulletinButton.AsObject; - static toObject(includeInstance: boolean, msg: BulletinButton): BulletinButton.AsObject; - static extensions: {[key: number]: jspb.ExtensionFieldInfo}; - static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; - static serializeBinaryToWriter(message: BulletinButton, writer: jspb.BinaryWriter): void; - static deserializeBinary(bytes: Uint8Array): BulletinButton; - static deserializeBinaryFromReader(message: BulletinButton, reader: jspb.BinaryReader): BulletinButton; -} - -export namespace BulletinButton { - export type AsObject = { - label: string, - } -} - export class Action extends jspb.Message { hasDeepLink(): boolean; @@ -999,6 +973,11 @@ export class Action extends jspb.Message { getExternalUrl(): string; setExternalUrl(value: string): Action; + hasLabel(): boolean; + clearLabel(): void; + getLabel(): string | undefined; + setLabel(value: string): Action; + getDataCase(): Action.DataCase; serializeBinary(): Uint8Array; @@ -1015,6 +994,7 @@ export namespace Action { export type AsObject = { deepLink?: DeepLink.AsObject, externalUrl: string, + label?: string, } export enum DataCase { diff --git a/core/api/src/services/notifications/proto/notifications_pb.js b/core/api/src/services/notifications/proto/notifications_pb.js index c0ccbc8f25..5e8eda8242 100644 --- a/core/api/src/services/notifications/proto/notifications_pb.js +++ b/core/api/src/services/notifications/proto/notifications_pb.js @@ -25,7 +25,6 @@ goog.exportSymbol('proto.services.notifications.v1.Action', null, global); goog.exportSymbol('proto.services.notifications.v1.Action.DataCase', null, global); goog.exportSymbol('proto.services.notifications.v1.AddPushDeviceTokenRequest', null, global); goog.exportSymbol('proto.services.notifications.v1.AddPushDeviceTokenResponse', null, global); -goog.exportSymbol('proto.services.notifications.v1.BulletinButton', null, global); goog.exportSymbol('proto.services.notifications.v1.ChannelNotificationSettings', null, global); goog.exportSymbol('proto.services.notifications.v1.CircleGrew', null, global); goog.exportSymbol('proto.services.notifications.v1.CircleThresholdReached', null, global); @@ -850,27 +849,6 @@ if (goog.DEBUG && !COMPILED) { */ proto.services.notifications.v1.LocalizedContent.displayName = 'proto.services.notifications.v1.LocalizedContent'; } -/** - * Generated by JsPbCodeGenerator. - * @param {Array=} opt_data Optional initial data array, typically from a - * server response, or constructed directly in Javascript. The array is used - * in place and becomes part of the constructed object. It is not cloned. - * If no data is provided, the constructed object will be empty, but still - * valid. - * @extends {jspb.Message} - * @constructor - */ -proto.services.notifications.v1.BulletinButton = function(opt_data) { - jspb.Message.initialize(this, opt_data, 0, -1, null, null); -}; -goog.inherits(proto.services.notifications.v1.BulletinButton, jspb.Message); -if (goog.DEBUG && !COMPILED) { - /** - * @public - * @override - */ - proto.services.notifications.v1.BulletinButton.displayName = 'proto.services.notifications.v1.BulletinButton'; -} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -7046,8 +7024,7 @@ shouldSendPush: jspb.Message.getBooleanFieldWithDefault(msg, 3, false), shouldAddToHistory: jspb.Message.getBooleanFieldWithDefault(msg, 4, false), shouldAddToBulletin: jspb.Message.getBooleanFieldWithDefault(msg, 5, false), action: (f = msg.getAction()) && proto.services.notifications.v1.Action.toObject(includeInstance, f), -icon: (f = jspb.Message.getField(msg, 7)) == null ? undefined : f, -bulletinButton: (f = msg.getBulletinButton()) && proto.services.notifications.v1.BulletinButton.toObject(includeInstance, f) +icon: (f = jspb.Message.getField(msg, 7)) == null ? undefined : f }; if (includeInstance) { @@ -7115,11 +7092,6 @@ proto.services.notifications.v1.MarketingNotificationTriggered.deserializeBinary var value = /** @type {!proto.services.notifications.v1.Icon} */ (reader.readEnum()); msg.setIcon(value); break; - case 8: - var value = new proto.services.notifications.v1.BulletinButton; - reader.readMessage(value,proto.services.notifications.v1.BulletinButton.deserializeBinaryFromReader); - msg.setBulletinButton(value); - break; default: reader.skipField(); break; @@ -7196,14 +7168,6 @@ proto.services.notifications.v1.MarketingNotificationTriggered.serializeBinaryTo f ); } - f = message.getBulletinButton(); - if (f != null) { - writer.writeMessage( - 8, - f, - proto.services.notifications.v1.BulletinButton.serializeBinaryToWriter - ); - } }; @@ -7394,43 +7358,6 @@ proto.services.notifications.v1.MarketingNotificationTriggered.prototype.hasIcon }; -/** - * optional BulletinButton bulletin_button = 8; - * @return {?proto.services.notifications.v1.BulletinButton} - */ -proto.services.notifications.v1.MarketingNotificationTriggered.prototype.getBulletinButton = function() { - return /** @type{?proto.services.notifications.v1.BulletinButton} */ ( - jspb.Message.getWrapperField(this, proto.services.notifications.v1.BulletinButton, 8)); -}; - - -/** - * @param {?proto.services.notifications.v1.BulletinButton|undefined} value - * @return {!proto.services.notifications.v1.MarketingNotificationTriggered} returns this -*/ -proto.services.notifications.v1.MarketingNotificationTriggered.prototype.setBulletinButton = function(value) { - return jspb.Message.setWrapperField(this, 8, value); -}; - - -/** - * Clears the message field making it undefined. - * @return {!proto.services.notifications.v1.MarketingNotificationTriggered} returns this - */ -proto.services.notifications.v1.MarketingNotificationTriggered.prototype.clearBulletinButton = function() { - return this.setBulletinButton(undefined); -}; - - -/** - * Returns whether this field is set. - * @return {boolean} - */ -proto.services.notifications.v1.MarketingNotificationTriggered.prototype.hasBulletinButton = function() { - return jspb.Message.getField(this, 8) != null; -}; - - @@ -7592,136 +7519,6 @@ proto.services.notifications.v1.LocalizedContent.prototype.setBody = function(va - - -if (jspb.Message.GENERATE_TO_OBJECT) { -/** - * Creates an object representation of this proto. - * Field names that are reserved in JavaScript and will be renamed to pb_name. - * Optional fields that are not set will be set to undefined. - * To access a reserved field use, foo.pb_, eg, foo.pb_default. - * For the list of reserved names please see: - * net/proto2/compiler/js/internal/generator.cc#kKeyword. - * @param {boolean=} opt_includeInstance Deprecated. whether to include the - * JSPB instance for transitional soy proto support: - * http://goto/soy-param-migration - * @return {!Object} - */ -proto.services.notifications.v1.BulletinButton.prototype.toObject = function(opt_includeInstance) { - return proto.services.notifications.v1.BulletinButton.toObject(opt_includeInstance, this); -}; - - -/** - * Static version of the {@see toObject} method. - * @param {boolean|undefined} includeInstance Deprecated. Whether to include - * the JSPB instance for transitional soy proto support: - * http://goto/soy-param-migration - * @param {!proto.services.notifications.v1.BulletinButton} msg The msg instance to transform. - * @return {!Object} - * @suppress {unusedLocalVariables} f is only used for nested messages - */ -proto.services.notifications.v1.BulletinButton.toObject = function(includeInstance, msg) { - var f, obj = { -label: jspb.Message.getFieldWithDefault(msg, 1, "") - }; - - if (includeInstance) { - obj.$jspbMessageInstance = msg; - } - return obj; -}; -} - - -/** - * Deserializes binary data (in protobuf wire format). - * @param {jspb.ByteSource} bytes The bytes to deserialize. - * @return {!proto.services.notifications.v1.BulletinButton} - */ -proto.services.notifications.v1.BulletinButton.deserializeBinary = function(bytes) { - var reader = new jspb.BinaryReader(bytes); - var msg = new proto.services.notifications.v1.BulletinButton; - return proto.services.notifications.v1.BulletinButton.deserializeBinaryFromReader(msg, reader); -}; - - -/** - * Deserializes binary data (in protobuf wire format) from the - * given reader into the given message object. - * @param {!proto.services.notifications.v1.BulletinButton} msg The message object to deserialize into. - * @param {!jspb.BinaryReader} reader The BinaryReader to use. - * @return {!proto.services.notifications.v1.BulletinButton} - */ -proto.services.notifications.v1.BulletinButton.deserializeBinaryFromReader = function(msg, reader) { - while (reader.nextField()) { - if (reader.isEndGroup()) { - break; - } - var field = reader.getFieldNumber(); - switch (field) { - case 1: - var value = /** @type {string} */ (reader.readString()); - msg.setLabel(value); - break; - default: - reader.skipField(); - break; - } - } - return msg; -}; - - -/** - * Serializes the message to binary data (in protobuf wire format). - * @return {!Uint8Array} - */ -proto.services.notifications.v1.BulletinButton.prototype.serializeBinary = function() { - var writer = new jspb.BinaryWriter(); - proto.services.notifications.v1.BulletinButton.serializeBinaryToWriter(this, writer); - return writer.getResultBuffer(); -}; - - -/** - * Serializes the given message to binary data (in protobuf wire - * format), writing to the given BinaryWriter. - * @param {!proto.services.notifications.v1.BulletinButton} message - * @param {!jspb.BinaryWriter} writer - * @suppress {unusedLocalVariables} f is only used for nested messages - */ -proto.services.notifications.v1.BulletinButton.serializeBinaryToWriter = function(message, writer) { - var f = undefined; - f = message.getLabel(); - if (f.length > 0) { - writer.writeString( - 1, - f - ); - } -}; - - -/** - * optional string label = 1; - * @return {string} - */ -proto.services.notifications.v1.BulletinButton.prototype.getLabel = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); -}; - - -/** - * @param {string} value - * @return {!proto.services.notifications.v1.BulletinButton} returns this - */ -proto.services.notifications.v1.BulletinButton.prototype.setLabel = function(value) { - return jspb.Message.setProto3StringField(this, 1, value); -}; - - - /** * Oneof group definitions for this message. Each group defines the field * numbers belonging to that group. When of these fields' value is set, all @@ -7780,7 +7577,8 @@ proto.services.notifications.v1.Action.prototype.toObject = function(opt_include proto.services.notifications.v1.Action.toObject = function(includeInstance, msg) { var f, obj = { deepLink: (f = msg.getDeepLink()) && proto.services.notifications.v1.DeepLink.toObject(includeInstance, f), -externalUrl: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f +externalUrl: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f, +label: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f }; if (includeInstance) { @@ -7826,6 +7624,10 @@ proto.services.notifications.v1.Action.deserializeBinaryFromReader = function(ms var value = /** @type {string} */ (reader.readString()); msg.setExternalUrl(value); break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.setLabel(value); + break; default: reader.skipField(); break; @@ -7870,6 +7672,13 @@ proto.services.notifications.v1.Action.serializeBinaryToWriter = function(messag f ); } + f = /** @type {string} */ (jspb.Message.getField(message, 3)); + if (f != null) { + writer.writeString( + 3, + f + ); + } }; @@ -7946,6 +7755,42 @@ proto.services.notifications.v1.Action.prototype.hasExternalUrl = function() { }; +/** + * optional string label = 3; + * @return {string} + */ +proto.services.notifications.v1.Action.prototype.getLabel = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.notifications.v1.Action} returns this + */ +proto.services.notifications.v1.Action.prototype.setLabel = function(value) { + return jspb.Message.setField(this, 3, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.notifications.v1.Action} returns this + */ +proto.services.notifications.v1.Action.prototype.clearLabel = function() { + return jspb.Message.setField(this, 3, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.notifications.v1.Action.prototype.hasLabel = function() { + return jspb.Message.getField(this, 3) != null; +}; + + diff --git a/core/notifications/proto/notifications.proto b/core/notifications/proto/notifications.proto index 0c011601ee..1f14a60c54 100644 --- a/core/notifications/proto/notifications.proto +++ b/core/notifications/proto/notifications.proto @@ -243,7 +243,6 @@ message MarketingNotificationTriggered { bool should_add_to_bulletin = 5; optional Action action = 6; optional Icon icon = 7; - optional BulletinButton bulletin_button = 8; } message LocalizedContent { @@ -251,15 +250,12 @@ message LocalizedContent { string body = 2; } -message BulletinButton { - string label = 1; -} - message Action { oneof data { DeepLink deep_link = 1; string external_url = 2; } + optional string label = 3; } message DeepLink { diff --git a/core/notifications/src/graphql/convert.rs b/core/notifications/src/graphql/convert.rs index eb48d5cff9..b1eab4ce6f 100644 --- a/core/notifications/src/graphql/convert.rs +++ b/core/notifications/src/graphql/convert.rs @@ -21,18 +21,20 @@ impl From for types::StatefulNotification { action: notification.action().map(|a| match a { Action::OpenDeepLink(deep_link) => { types::NotificationAction::OpenDeepLinkAction(types::OpenDeepLinkAction { + label: deep_link.label.clone(), deep_link: deep_link.to_link_string(), }) } - Action::OpenExternalUrl(url) => types::NotificationAction::OpenExternalLinkAction( - types::OpenExternalLinkAction { - url: url.into_inner(), - }, - ), + Action::OpenExternalUrl(url) => { + let label = url.label.clone(); + types::NotificationAction::OpenExternalLinkAction( + types::OpenExternalLinkAction { + url: url.into_inner(), + label, + }, + ) + } }), - bulletin_button: notification - .bulletin_button() - .map(|b| types::BulletinButton { label: b.label }), icon: notification.icon().map(Into::into), } } diff --git a/core/notifications/src/graphql/types.rs b/core/notifications/src/graphql/types.rs index 31461eab53..0eaaa52c14 100644 --- a/core/notifications/src/graphql/types.rs +++ b/core/notifications/src/graphql/types.rs @@ -38,19 +38,16 @@ impl ScalarType for Timestamp { } } -#[derive(SimpleObject)] -pub(super) struct BulletinButton { - pub label: String, -} - #[derive(SimpleObject)] pub(super) struct OpenDeepLinkAction { pub deep_link: String, + pub label: Option, } #[derive(SimpleObject)] pub(super) struct OpenExternalLinkAction { pub url: String, + pub label: Option, } #[derive(Union)] @@ -66,7 +63,6 @@ pub(super) struct StatefulNotification { pub body: String, pub deep_link: Option, pub action: Option, - pub bulletin_button: Option, pub created_at: Timestamp, pub acknowledged_at: Option, pub bulletin_enabled: bool, diff --git a/core/notifications/src/grpc/server/convert.rs b/core/notifications/src/grpc/server/convert.rs index 91619f39f7..a83e733bcc 100644 --- a/core/notifications/src/grpc/server/convert.rs +++ b/core/notifications/src/grpc/server/convert.rs @@ -249,6 +249,8 @@ impl TryFrom for notification_event::Action { type Error = tonic::Status; fn try_from(action: proto::Action) -> Result { + let label = action.label; + match action.data { Some(proto::action::Data::DeepLink(deep_link)) => { let screen = if let Some(screen) = deep_link.screen { @@ -271,13 +273,17 @@ impl TryFrom for notification_event::Action { None }; - let dl = notification_event::DeepLink { screen, action }; + let dl = notification_event::DeepLink { + screen, + action, + label, + }; Ok(notification_event::Action::OpenDeepLink(dl)) } Some(proto::action::Data::ExternalUrl(url)) => { - Ok(notification_event::Action::OpenExternalUrl( - notification_event::ExternalUrl::from(url), - )) + let mut eu = notification_event::ExternalUrl::from(url); + eu.label = label; + Ok(notification_event::Action::OpenExternalUrl(eu)) } None => Err(tonic::Status::new( tonic::Code::InvalidArgument, @@ -287,14 +293,6 @@ impl TryFrom for notification_event::Action { } } -impl From for notification_event::BulletinButton { - fn from(button: proto::BulletinButton) -> Self { - Self { - label: button.label, - } - } -} - impl From for notification_event::Icon { fn from(icon: proto::Icon) -> Self { match icon { diff --git a/core/notifications/src/grpc/server/mod.rs b/core/notifications/src/grpc/server/mod.rs index 6404410593..68315997b2 100644 --- a/core/notifications/src/grpc/server/mod.rs +++ b/core/notifications/src/grpc/server/mod.rs @@ -410,7 +410,6 @@ impl NotificationsService for Notifications { user_ids, action, icon, - bulletin_button, }, )), }) => { @@ -442,8 +441,6 @@ impl NotificationsService for Notifications { .map(notification_event::Action::try_from) .transpose()?; - let bulletin_button = bulletin_button.map(notification_event::BulletinButton::from); - let icon = if let Some(icon) = icon { Some( proto::Icon::try_from(icon) @@ -464,7 +461,6 @@ impl NotificationsService for Notifications { should_add_to_history, should_send_push, action, - bulletin_button, icon, }, ) diff --git a/core/notifications/src/history/entity.rs b/core/notifications/src/history/entity.rs index 1ae058d644..82077b7a0c 100644 --- a/core/notifications/src/history/entity.rs +++ b/core/notifications/src/history/entity.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::{ messages::LocalizedStatefulMessage, - notification_event::{Action, BulletinButton, DeepLink, Icon, NotificationEventPayload}, + notification_event::{Action, DeepLink, Icon, NotificationEventPayload}, primitives::*, }; @@ -94,10 +94,6 @@ impl StatefulNotification { self.payload.action() } - pub fn bulletin_button(&self) -> Option { - self.payload.bulletin_button() - } - pub fn icon(&self) -> Option { self.payload.icon() } diff --git a/core/notifications/src/notification_event/circle_grew.rs b/core/notifications/src/notification_event/circle_grew.rs index e1825c3988..0e89ccdc8b 100644 --- a/core/notifications/src/notification_event/circle_grew.rs +++ b/core/notifications/src/notification_event/circle_grew.rs @@ -20,6 +20,7 @@ impl NotificationEvent for CircleGrew { Some(Action::OpenDeepLink(DeepLink { screen: Some(DeepLinkScreen::Circles), action: None, + label: None, })) } diff --git a/core/notifications/src/notification_event/circle_threshold_reached.rs b/core/notifications/src/notification_event/circle_threshold_reached.rs index afd73135b0..54dcb4304f 100644 --- a/core/notifications/src/notification_event/circle_threshold_reached.rs +++ b/core/notifications/src/notification_event/circle_threshold_reached.rs @@ -20,6 +20,7 @@ impl NotificationEvent for CircleThresholdReached { Some(Action::OpenDeepLink(DeepLink { screen: Some(DeepLinkScreen::Circles), action: None, + label: None, })) } diff --git a/core/notifications/src/notification_event/marketing_notification_triggered.rs b/core/notifications/src/notification_event/marketing_notification_triggered.rs index 2cffe59362..01a3138969 100644 --- a/core/notifications/src/notification_event/marketing_notification_triggered.rs +++ b/core/notifications/src/notification_event/marketing_notification_triggered.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::{Action, BulletinButton, Icon, NotificationEvent}; +use super::{Action, Icon, NotificationEvent}; use crate::{messages::*, primitives::*}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -15,8 +15,6 @@ pub struct MarketingNotificationTriggered { #[serde(default)] pub action: Option, #[serde(default)] - pub bulletin_button: Option, - #[serde(default)] pub icon: Option, } @@ -29,10 +27,6 @@ impl NotificationEvent for MarketingNotificationTriggered { self.action.clone() } - fn bulletin_button(&self) -> Option { - self.bulletin_button.clone() - } - fn should_send_push(&self) -> bool { self.should_send_push } @@ -84,46 +78,45 @@ mod tests { should_add_to_history: true, should_add_to_bulletin: true, action: None, - bulletin_button: None, icon: None, } } #[test] - fn bulletin_button_returns_some_when_set() { + fn action_label_returns_some_when_set_on_deep_link() { let mut event = default_event(); - event.bulletin_button = Some(BulletinButton { - label: "Deposit".to_string(), - }); + event.action = Some(Action::OpenDeepLink(DeepLink { + screen: None, + action: None, + label: Some("Deposit".to_string()), + })); - let button = event - .bulletin_button() - .expect("should return bulletin button"); - assert_eq!(button.label, "Deposit"); + let action = event.action().expect("should return action"); + assert_eq!(action.label(), Some("Deposit")); } #[test] - fn bulletin_button_returns_none_when_not_set() { - let event = default_event(); - assert!(event.bulletin_button().is_none()); - } - - #[test] - fn action_and_bulletin_button_coexist() { + fn action_label_returns_none_when_not_set() { let mut event = default_event(); event.action = Some(Action::OpenDeepLink(DeepLink { screen: None, action: None, + label: None, })); - event.bulletin_button = Some(BulletinButton { - label: "Click me".to_string(), - }); - assert!(event.action().is_some()); - let button = event - .bulletin_button() - .expect("should return bulletin button"); - assert_eq!(button.label, "Click me"); + let action = event.action().expect("should return action"); + assert_eq!(action.label(), None); + } + + #[test] + fn action_label_on_external_url() { + let mut event = default_event(); + event.action = Some(Action::OpenExternalUrl(ExternalUrl::from( + "https://example.com".to_string(), + ))); + + let action = event.action().expect("should return action"); + assert_eq!(action.label(), None); } #[test] @@ -153,34 +146,30 @@ mod tests { } #[test] - fn serde_roundtrip_with_bulletin_button() { + fn serde_roundtrip_with_action_label() { let mut event = default_event(); - event.bulletin_button = Some(BulletinButton { - label: "See more".to_string(), - }); - event.action = Some(Action::OpenExternalUrl(ExternalUrl::from( - "https://example.com".to_string(), - ))); + event.action = Some(Action::OpenDeepLink(DeepLink { + screen: None, + action: None, + label: Some("See more".to_string()), + })); let json = serde_json::to_string(&event).expect("should serialize"); let deserialized: MarketingNotificationTriggered = serde_json::from_str(&json).expect("should deserialize"); - let button = deserialized - .bulletin_button() - .expect("should have bulletin button"); - assert_eq!(button.label, "See more"); - assert!(deserialized.action().is_some()); + let action = deserialized.action().expect("should have action"); + assert_eq!(action.label(), Some("See more")); } #[test] - fn serde_backward_compat_without_bulletin_button() { + fn serde_backward_compat_without_label() { let json = r#"{ "content": {}, "default_content": { "locale": "en", "title": "Old notification", - "body": "No bulletin button field" + "body": "No label field" }, "should_send_push": false, "should_add_to_history": true, @@ -188,12 +177,32 @@ mod tests { }"#; let event: MarketingNotificationTriggered = - serde_json::from_str(json).expect("should deserialize without bulletin_button"); - assert!(event.bulletin_button().is_none()); + serde_json::from_str(json).expect("should deserialize without label"); assert!(event.action().is_none()); assert!(event.icon().is_none()); } + #[test] + fn serde_backward_compat_external_url_as_string() { + let json = r#"{ + "content": {}, + "default_content": { + "locale": "en", + "title": "Test", + "body": "Test" + }, + "should_send_push": true, + "should_add_to_history": true, + "should_add_to_bulletin": false, + "action": { "OpenExternalUrl": "https://example.com" } + }"#; + + let event: MarketingNotificationTriggered = + serde_json::from_str(json).expect("should deserialize old ExternalUrl format"); + let action = event.action().expect("should have action"); + assert_eq!(action.label(), None); + } + #[test] fn icon_returns_value_when_set() { let mut event = default_event(); diff --git a/core/notifications/src/notification_event/mod.rs b/core/notifications/src/notification_event/mod.rs index c1897b4f94..40d94f2cdc 100644 --- a/core/notifications/src/notification_event/mod.rs +++ b/core/notifications/src/notification_event/mod.rs @@ -81,6 +81,8 @@ pub enum Icon { pub struct DeepLink { pub screen: Option, pub action: Option, + #[serde(default)] + pub label: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -115,29 +117,63 @@ pub enum DeepLinkAction { UpgradeAccountModal, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct BulletinButton { - pub label: String, -} - #[derive(Debug, Serialize, Deserialize, Clone)] pub enum Action { OpenDeepLink(DeepLink), OpenExternalUrl(ExternalUrl), } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExternalUrl(String); +impl Action { + pub fn label(&self) -> Option<&str> { + match self { + Action::OpenDeepLink(dl) => dl.label.as_deref(), + Action::OpenExternalUrl(eu) => eu.label.as_deref(), + } + } +} + +#[derive(Debug, Serialize, Clone)] +pub struct ExternalUrl { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} impl ExternalUrl { pub fn into_inner(self) -> String { - self.0 + self.url } } impl From for ExternalUrl { fn from(s: String) -> Self { - Self(s) + Self { + url: s, + label: None, + } + } +} + +impl<'de> Deserialize<'de> for ExternalUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum ExternalUrlRepr { + Simple(String), + Struct { + url: String, + #[serde(default)] + label: Option, + }, + } + + match ExternalUrlRepr::deserialize(deserializer)? { + ExternalUrlRepr::Simple(url) => Ok(ExternalUrl { url, label: None }), + ExternalUrlRepr::Struct { url, label } => Ok(ExternalUrl { url, label }), + } } } @@ -213,10 +249,6 @@ pub trait NotificationEvent: std::fmt::Debug + Send + Sync { None } - fn bulletin_button(&self) -> Option { - None - } - fn icon(&self) -> Option { None } diff --git a/core/notifications/subgraph/schema.graphql b/core/notifications/subgraph/schema.graphql index 46a075d4e5..54552c14f3 100644 --- a/core/notifications/subgraph/schema.graphql +++ b/core/notifications/subgraph/schema.graphql @@ -1,9 +1,3 @@ -type BulletinButton { - label: String! -} - - - enum Icon { ARROW_RIGHT ARROW_LEFT @@ -67,10 +61,12 @@ union NotificationAction = OpenDeepLinkAction | OpenExternalLinkAction type OpenDeepLinkAction { deepLink: String! + label: String } type OpenExternalLinkAction { url: String! + label: String } """ @@ -102,7 +98,6 @@ type StatefulNotification { body: String! deepLink: String action: NotificationAction - bulletinButton: BulletinButton createdAt: Timestamp! acknowledgedAt: Timestamp bulletinEnabled: Boolean! From 401f113990bc78f7f36ccc4969375d41abf862ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 25 Feb 2026 21:04:50 -0600 Subject: [PATCH 11/17] refactor(notifications): move label from BulletinButton to action types in API Co-Authored-By: Claude Opus 4.6 --- core/api/src/app/admin/index.types.d.ts | 7 ++---- .../admin/trigger-marketing-notification.ts | 2 -- .../src/domain/notifications/index.types.d.ts | 7 ++---- .../marketing-notification-trigger.ts | 22 +++++++------------ core/api/src/graphql/admin/schema.graphql | 7 ++---- core/api/src/services/notifications/index.ts | 15 +++++-------- .../apollo-federation/supergraph.graphql | 11 +++------- 7 files changed, 22 insertions(+), 49 deletions(-) diff --git a/core/api/src/app/admin/index.types.d.ts b/core/api/src/app/admin/index.types.d.ts index b3d8c52d9e..78f8b5285e 100644 --- a/core/api/src/app/admin/index.types.d.ts +++ b/core/api/src/app/admin/index.types.d.ts @@ -5,16 +5,13 @@ type AdminTriggerMarketingNotificationArgs = { | { screen: DeepLinkScreen | undefined action: DeepLinkAction | undefined + label: string | undefined } | undefined openExternalUrl: | { url: string - } - | undefined - bulletinButton: - | { - label: string + label: string | undefined } | undefined shouldSendPush: boolean diff --git a/core/api/src/app/admin/trigger-marketing-notification.ts b/core/api/src/app/admin/trigger-marketing-notification.ts index 94c47eaf5e..afd463d98c 100644 --- a/core/api/src/app/admin/trigger-marketing-notification.ts +++ b/core/api/src/app/admin/trigger-marketing-notification.ts @@ -8,7 +8,6 @@ export const triggerMarketingNotification = async ({ phoneCountryCodesFilter, openDeepLink, openExternalUrl, - bulletinButton, shouldSendPush, icon, shouldAddToHistory, @@ -50,7 +49,6 @@ export const triggerMarketingNotification = async ({ userIds: userIdsToNotify, openDeepLink, openExternalUrl, - bulletinButton, shouldSendPush, shouldAddToHistory, shouldAddToBulletin, diff --git a/core/api/src/domain/notifications/index.types.d.ts b/core/api/src/domain/notifications/index.types.d.ts index 65de4b7dbc..7fb1bbd782 100644 --- a/core/api/src/domain/notifications/index.types.d.ts +++ b/core/api/src/domain/notifications/index.types.d.ts @@ -145,16 +145,13 @@ type TriggerMarketingNotificationArgs = { | { screen: DeepLinkScreen | undefined action: DeepLinkAction | undefined + label: string | undefined } | undefined openExternalUrl: | { url: string - } - | undefined - bulletinButton: - | { - label: string + label: string | undefined } | undefined shouldSendPush: boolean diff --git a/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts b/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts index 1d81aa4178..80ee7aeece 100644 --- a/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts +++ b/core/api/src/graphql/admin/root/mutation/marketing-notification-trigger.ts @@ -33,6 +33,9 @@ const OpenDeepLinkInput = GT.Input({ action: { type: DeepLinkAction, }, + label: { + type: GT.String, + }, }), }) @@ -42,14 +45,8 @@ const OpenExternalUrlInput = GT.Input({ url: { type: GT.NonNull(ExternalUrl), }, - }), -}) - -const BulletinButtonInput = GT.Input({ - name: "BulletinButtonInput", - fields: () => ({ label: { - type: GT.NonNull(GT.String), + type: GT.String, }, }), }) @@ -78,9 +75,6 @@ const MarketingNotificationTriggerInput = GT.Input({ openExternalUrl: { type: OpenExternalUrlInput, }, - bulletinButton: { - type: BulletinButtonInput, - }, icon: { type: NotificationIcon, }, @@ -105,10 +99,10 @@ const MarketingNotificationTriggerMutation = GT.Field< | { screen: DeepLinkScreen | Error | undefined action: DeepLinkAction | Error | undefined + label: string | undefined } | undefined - openExternalUrl: { url: string | Error } | undefined - bulletinButton: { label: string } | undefined + openExternalUrl: { url: string | Error; label: string | undefined } | undefined localizedNotificationContents: { title: string body: string @@ -134,7 +128,6 @@ const MarketingNotificationTriggerMutation = GT.Field< icon, openDeepLink, openExternalUrl, - bulletinButton, localizedNotificationContents, } = args.input @@ -159,6 +152,7 @@ const MarketingNotificationTriggerMutation = GT.Field< nonErrorOpenDeepLink = { screen: openDeepLink.screen, action: openDeepLink.action, + label: openDeepLink.label, } } @@ -173,6 +167,7 @@ const MarketingNotificationTriggerMutation = GT.Field< } nonErrorOpenExternalUrl = { url: openExternalUrl.url, + label: openExternalUrl.label, } } @@ -206,7 +201,6 @@ const MarketingNotificationTriggerMutation = GT.Field< phoneCountryCodesFilter: nonErrorPhoneCountryCodesFilter, openDeepLink: nonErrorOpenDeepLink, openExternalUrl: nonErrorOpenExternalUrl, - bulletinButton, shouldSendPush, shouldAddToHistory, shouldAddToBulletin, diff --git a/core/api/src/graphql/admin/schema.graphql b/core/api/src/graphql/admin/schema.graphql index c8541c24de..fd1d8b606d 100644 --- a/core/api/src/graphql/admin/schema.graphql +++ b/core/api/src/graphql/admin/schema.graphql @@ -137,10 +137,6 @@ type BTCWallet implements Wallet { walletCurrency: WalletCurrency! } -input BulletinButtonInput { - label: String! -} - type Coordinates { latitude: Float! longitude: Float! @@ -315,7 +311,6 @@ input LocalizedNotificationContentInput { } input MarketingNotificationTriggerInput { - bulletinButton: BulletinButtonInput icon: NotificationIcon localizedNotificationContents: [LocalizedNotificationContentInput!]! openDeepLink: OpenDeepLinkInput @@ -431,10 +426,12 @@ scalar OnChainTxHash input OpenDeepLinkInput { action: DeepLinkAction + label: String screen: DeepLinkScreen } input OpenExternalUrlInput { + label: String url: ExternalUrl! } diff --git a/core/api/src/services/notifications/index.ts b/core/api/src/services/notifications/index.ts index 5005acf98d..2c26b898d2 100644 --- a/core/api/src/services/notifications/index.ts +++ b/core/api/src/services/notifications/index.ts @@ -28,7 +28,6 @@ import { DeepLink as ProtoDeepLink, HandleNotificationEventResponse, Action, - BulletinButton, } from "./proto/notifications_pb" import * as notificationsGrpc from "./grpc-client" @@ -639,7 +638,6 @@ export const NotificationsService = (): INotificationsService => { localizedContents: localizedPushContents, openDeepLink, openExternalUrl, - bulletinButton, shouldSendPush, shouldAddToHistory, shouldAddToBulletin, @@ -657,12 +655,6 @@ export const NotificationsService = (): INotificationsService => { const externalUrl = openExternalUrl?.url - let protoBulletinButton: BulletinButton | undefined = undefined - if (bulletinButton) { - protoBulletinButton = new BulletinButton() - protoBulletinButton.setLabel(bulletinButton.label) - } - let action: Action | undefined = undefined if (deepLink !== undefined || externalUrl !== undefined) { action = new Action() @@ -670,6 +662,11 @@ export const NotificationsService = (): INotificationsService => { if (externalUrl !== undefined) action.setExternalUrl(externalUrl) } + const label = openDeepLink?.label || openExternalUrl?.label + if (action !== undefined && label !== undefined) { + action.setLabel(label) + } + const protoIcon = icon ? iconToGrpcIcon(icon) : undefined const marketingNotificationRequests: Promise[] = [] @@ -685,8 +682,6 @@ export const NotificationsService = (): INotificationsService => { marketingNotification.getLocalizedContentMap().set(language, localizedContent) }) if (action !== undefined) marketingNotification.setAction(action) - if (protoBulletinButton !== undefined) - marketingNotification.setBulletinButton(protoBulletinButton) if (protoIcon !== undefined) marketingNotification.setIcon(protoIcon) marketingNotification.setShouldSendPush(shouldSendPush) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index 23d67ec996..bd1a2547c4 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -319,12 +319,6 @@ type BuildInformation helmRevision: Int } -type BulletinButton - @join__type(graph: NOTIFICATIONS) -{ - label: String! -} - type CallbackEndpoint @join__type(graph: PUBLIC) { @@ -1592,12 +1586,14 @@ type OpenDeepLinkAction @join__type(graph: NOTIFICATIONS) { deepLink: String! + label: String } type OpenExternalLinkAction @join__type(graph: NOTIFICATIONS) { url: String! + label: String } """Information about pagination in a connection""" @@ -1944,7 +1940,6 @@ type StatefulNotification body: String! deepLink: String action: NotificationAction - bulletinButton: BulletinButton createdAt: Timestamp! acknowledgedAt: Timestamp bulletinEnabled: Boolean! @@ -2594,4 +2589,4 @@ enum WalletCurrency """Unique identifier of a wallet""" scalar WalletId - @join__type(graph: PUBLIC) \ No newline at end of file + @join__type(graph: PUBLIC) From d453b076cf9c5437b8f5e011e0c802f74205eb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 25 Feb 2026 21:05:09 -0600 Subject: [PATCH 12/17] test(notifications): update e2e tests for label in action types Co-Authored-By: Claude Opus 4.6 --- bats/core/notifications/notifications.bats | 42 +++++++++---------- ...notifications-without-bulletin-enabled.gql | 5 +-- bats/gql/list-stateful-notifications.gql | 5 +-- ...ul-notifications-with-bulletin-enabled.gql | 5 +-- 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/bats/core/notifications/notifications.bats b/bats/core/notifications/notifications.bats index 3e2bcd2bb3..f074bae3fb 100644 --- a/bats/core/notifications/notifications.bats +++ b/bats/core/notifications/notifications.bats @@ -240,7 +240,7 @@ setup_file() { [[ $count -eq 2 ]] || exit 1 } -@test "notifications: bulletin with button and external url action" { +@test "notifications: bulletin with label and external url action" { admin_token="$(read_value 'admin.token')" variables=$( @@ -257,11 +257,9 @@ setup_file() { shouldSendPush: false, shouldAddToHistory: true, shouldAddToBulletin: true, - bulletinButton: { - label: "Learn more" - }, openExternalUrl: { - url: "https://example.com/update" + url: "https://example.com/update", + label: "Learn more" }, icon: "BELL" } @@ -270,16 +268,16 @@ setup_file() { exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" - local button_label + local action_label local action_url local icon for i in {1..10}; do exec_graphql 'alice' 'list-unacknowledged-stateful-notifications-with-bulletin-enabled' - button_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].bulletinButton.label') - [[ "$button_label" = "Learn more" ]] && break; + action_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.label') + [[ "$action_label" = "Learn more" ]] && break; sleep 1 done - [[ "$button_label" = "Learn more" ]] || exit 1 + [[ "$action_label" = "Learn more" ]] || exit 1 action_url=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.url') [[ "$action_url" = "https://example.com/update" ]] || exit 1 @@ -288,7 +286,7 @@ setup_file() { [[ "$icon" = "BELL" ]] || exit 1 } -@test "notifications: bulletin without button has null bulletinButton" { +@test "notifications: bulletin without label has null action label" { admin_token="$(read_value 'admin.token')" variables=$( @@ -299,7 +297,7 @@ setup_file() { { language: "en", title: "Simple notification", - body: "No button here" + body: "No label here" } ], shouldSendPush: false, @@ -311,7 +309,7 @@ setup_file() { exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" - local button + local action local title for i in {1..10}; do exec_graphql 'alice' 'list-stateful-notifications' '{"first": 1}' @@ -321,11 +319,11 @@ setup_file() { done [[ "$title" = "Simple notification" ]] || exit 1 - button=$(graphql_output '.data.me.statefulNotifications.nodes[0].bulletinButton') - [[ "$button" = "null" ]] || exit 1 + action=$(graphql_output '.data.me.statefulNotifications.nodes[0].action') + [[ "$action" = "null" ]] || exit 1 } -@test "notifications: bulletin with button and deep link action" { +@test "notifications: bulletin with label and deep link action" { admin_token="$(read_value 'admin.token')" variables=$( @@ -342,11 +340,9 @@ setup_file() { shouldSendPush: false, shouldAddToHistory: true, shouldAddToBulletin: true, - bulletinButton: { - label: "Go to settings" - }, openDeepLink: { - screen: "SETTINGS" + screen: "SETTINGS", + label: "Go to settings" } } }' @@ -354,15 +350,15 @@ setup_file() { exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" - local button_label + local action_label local deep_link for i in {1..10}; do exec_graphql 'alice' 'list-unacknowledged-stateful-notifications-with-bulletin-enabled' - button_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].bulletinButton.label') - [[ "$button_label" = "Go to settings" ]] && break; + action_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.label') + [[ "$action_label" = "Go to settings" ]] && break; sleep 1 done - [[ "$button_label" = "Go to settings" ]] || exit 1 + [[ "$action_label" = "Go to settings" ]] || exit 1 deep_link=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.deepLink') [[ "$deep_link" = "/settings" ]] || exit 1 diff --git a/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql b/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql index 09ded51859..417eab01ed 100644 --- a/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql +++ b/bats/gql/list-stateful-notifications-without-bulletin-enabled.gql @@ -17,14 +17,13 @@ query listStatefulNotificationsWithoutBulletinEnabled($first: Int = 2, $after: S action { ... on OpenDeepLinkAction { deepLink + label } ... on OpenExternalLinkAction { url + label } } - bulletinButton { - label - } icon } } diff --git a/bats/gql/list-stateful-notifications.gql b/bats/gql/list-stateful-notifications.gql index 7a54401587..6febd45714 100644 --- a/bats/gql/list-stateful-notifications.gql +++ b/bats/gql/list-stateful-notifications.gql @@ -17,14 +17,13 @@ query listStatefulNotifications($first: Int = 2, $after: String = null) { action { ... on OpenDeepLinkAction { deepLink + label } ... on OpenExternalLinkAction { url + label } } - bulletinButton { - label - } icon } } diff --git a/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql b/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql index 319d8f4550..b928f87c21 100644 --- a/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql +++ b/bats/gql/list-unacknowledged-stateful-notifications-with-bulletin-enabled.gql @@ -16,14 +16,13 @@ query listUnacknowledgedStatefulNotificationsWithBulletinEnabled($first: Int = 2 action { ... on OpenDeepLinkAction { deepLink + label } ... on OpenExternalLinkAction { url + label } } - bulletinButton { - label - } icon } } From 424a366c4271f09142f0615ac66dc1f3af1f5077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 25 Feb 2026 21:45:55 -0600 Subject: [PATCH 13/17] fix(notifications): remove trailing newline from supergraph.graphql --- dev/config/apollo-federation/supergraph.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index bd1a2547c4..87e74afe84 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -2589,4 +2589,4 @@ enum WalletCurrency """Unique identifier of a wallet""" scalar WalletId - @join__type(graph: PUBLIC) + @join__type(graph: PUBLIC) \ No newline at end of file From f52009dcf494aec5d82b717dd7df791bd23ff48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Wed, 25 Feb 2026 22:07:06 -0600 Subject: [PATCH 14/17] refactor(notifications): use nullish coalescing and rename variable for clarity --- core/api/src/services/notifications/index.ts | 2 +- core/notifications/src/grpc/server/convert.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/api/src/services/notifications/index.ts b/core/api/src/services/notifications/index.ts index 2c26b898d2..eed74931ee 100644 --- a/core/api/src/services/notifications/index.ts +++ b/core/api/src/services/notifications/index.ts @@ -662,7 +662,7 @@ export const NotificationsService = (): INotificationsService => { if (externalUrl !== undefined) action.setExternalUrl(externalUrl) } - const label = openDeepLink?.label || openExternalUrl?.label + const label = openDeepLink?.label ?? openExternalUrl?.label if (action !== undefined && label !== undefined) { action.setLabel(label) } diff --git a/core/notifications/src/grpc/server/convert.rs b/core/notifications/src/grpc/server/convert.rs index a83e733bcc..a66dc7126e 100644 --- a/core/notifications/src/grpc/server/convert.rs +++ b/core/notifications/src/grpc/server/convert.rs @@ -281,9 +281,9 @@ impl TryFrom for notification_event::Action { Ok(notification_event::Action::OpenDeepLink(dl)) } Some(proto::action::Data::ExternalUrl(url)) => { - let mut eu = notification_event::ExternalUrl::from(url); - eu.label = label; - Ok(notification_event::Action::OpenExternalUrl(eu)) + let mut external_url = notification_event::ExternalUrl::from(url); + external_url.label = label; + Ok(notification_event::Action::OpenExternalUrl(external_url)) } None => Err(tonic::Status::new( tonic::Code::InvalidArgument, From 4aa186299ee00bb906575038de314b3f2bbafcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Thu, 26 Feb 2026 10:53:36 -0600 Subject: [PATCH 15/17] fix(notifications): validate label with trim before setting on action --- core/api/src/services/notifications/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/api/src/services/notifications/index.ts b/core/api/src/services/notifications/index.ts index eed74931ee..a3ce119673 100644 --- a/core/api/src/services/notifications/index.ts +++ b/core/api/src/services/notifications/index.ts @@ -662,8 +662,8 @@ export const NotificationsService = (): INotificationsService => { if (externalUrl !== undefined) action.setExternalUrl(externalUrl) } - const label = openDeepLink?.label ?? openExternalUrl?.label - if (action !== undefined && label !== undefined) { + const label = openDeepLink?.label || openExternalUrl?.label + if (action && typeof label === "string" && label.trim()) { action.setLabel(label) } From 96f32340ee9d5c9f11656dbd27391daa669f7508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Thu, 26 Feb 2026 10:57:10 -0600 Subject: [PATCH 16/17] test(notifications): rename serde_ test prefix to serialize_/deserialize_ --- .../notification_event/marketing_notification_triggered.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/notifications/src/notification_event/marketing_notification_triggered.rs b/core/notifications/src/notification_event/marketing_notification_triggered.rs index 01a3138969..7713410118 100644 --- a/core/notifications/src/notification_event/marketing_notification_triggered.rs +++ b/core/notifications/src/notification_event/marketing_notification_triggered.rs @@ -146,7 +146,7 @@ mod tests { } #[test] - fn serde_roundtrip_with_action_label() { + fn serialize_roundtrip_with_action_label() { let mut event = default_event(); event.action = Some(Action::OpenDeepLink(DeepLink { screen: None, @@ -163,7 +163,7 @@ mod tests { } #[test] - fn serde_backward_compat_without_label() { + fn deserialize_backward_compat_without_label() { let json = r#"{ "content": {}, "default_content": { @@ -183,7 +183,7 @@ mod tests { } #[test] - fn serde_backward_compat_external_url_as_string() { + fn deserialize_backward_compat_external_url_as_string() { let json = r#"{ "content": {}, "default_content": { From 8d8f10b869c9f70995526f5470c6ed60a24a894d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Thu, 26 Feb 2026 11:24:56 -0600 Subject: [PATCH 17/17] test(notifications): add e2e test for label priority when both actions are set --- bats/core/notifications/notifications.bats | 45 ++++++++++++++++++++ core/api/src/services/notifications/index.ts | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/bats/core/notifications/notifications.bats b/bats/core/notifications/notifications.bats index f074bae3fb..905f6d7739 100644 --- a/bats/core/notifications/notifications.bats +++ b/bats/core/notifications/notifications.bats @@ -363,3 +363,48 @@ setup_file() { deep_link=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.deepLink') [[ "$deep_link" = "/settings" ]] || exit 1 } + +@test "notifications: bulletin with label on both deep link and external url uses deep link" { + admin_token="$(read_value 'admin.token')" + + variables=$( + jq -n \ + '{ + input: { + localizedNotificationContents: [ + { + language: "en", + title: "Both actions label", + body: "Deep link label should take priority" + } + ], + shouldSendPush: false, + shouldAddToHistory: true, + shouldAddToBulletin: true, + openDeepLink: { + screen: "SETTINGS", + label: "Deep link label" + }, + openExternalUrl: { + url: "https://example.com/fallback", + label: "External label" + } + } + }' + ) + + exec_admin_graphql "$admin_token" 'marketing-notification-trigger' "$variables" + + local action_label + local action_url + for i in {1..10}; do + exec_graphql 'alice' 'list-unacknowledged-stateful-notifications-with-bulletin-enabled' + action_label=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.label') + [[ "$action_label" = "External label" ]] && break; + sleep 1 + done + [[ "$action_label" = "External label" ]] || exit 1 + + action_url=$(graphql_output '.data.me.unacknowledgedStatefulNotificationsWithBulletinEnabled.nodes[0].action.url') + [[ "$action_url" = "https://example.com/fallback" ]] || exit 1 +} diff --git a/core/api/src/services/notifications/index.ts b/core/api/src/services/notifications/index.ts index a3ce119673..877c7abedc 100644 --- a/core/api/src/services/notifications/index.ts +++ b/core/api/src/services/notifications/index.ts @@ -662,7 +662,7 @@ export const NotificationsService = (): INotificationsService => { if (externalUrl !== undefined) action.setExternalUrl(externalUrl) } - const label = openDeepLink?.label || openExternalUrl?.label + const label = openExternalUrl?.label || openDeepLink?.label if (action && typeof label === "string" && label.trim()) { action.setLabel(label) }