diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/04- Background Jobs and CancellationToken Management.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/04- Background Jobs and CancellationToken Management.md index 99c3679506..02cde81d78 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/04- Background Jobs and CancellationToken Management.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/04- Background Jobs and CancellationToken Management.md @@ -325,14 +325,14 @@ public virtual async Task SendSms(string messageText, string phoneNumber) var from = appSettings.Sms!.FromPhoneNumber!; // Enqueue the job - this returns immediately - backgroundJobClient.Enqueue(x => x.SendSms(phoneNumber, from, messageText, default)); + backgroundJobClient.Enqueue(x => x.SendSms(phoneNumber, from, messageText)); } ``` **Key points:** - `backgroundJobClient.Enqueue()` schedules the job to run in the background - The method returns **immediately** - the user doesn't wait -- The `default` parameter is a placeholder for the `CancellationToken` (Hangfire will provide it) +- Hangfire will automatically provide the `PerformContext` and `CancellationToken` parameters to the job runner method #### Step 2: The Job Runner Executes the Task @@ -344,7 +344,9 @@ public partial class PhoneServiceJobsRunner [AutoInject] private ServerExceptionHandler serverExceptionHandler = default!; [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30])] - public async Task SendSms(string phoneNumber, string from, string messageText, CancellationToken cancellationToken) + public async Task SendSms(string phoneNumber, string from, string messageText, + PerformContext context = null!, + CancellationToken cancellationToken = default) { try { @@ -357,20 +359,18 @@ public partial class PhoneServiceJobsRunner var smsMessage = MessageResource.Create(messageOptions); if (smsMessage.ErrorCode is not null) - throw new InvalidOperationException(smsMessage.ErrorMessage) - .WithData(new() { { "Code", smsMessage.ErrorCode } }); + throw new InvalidOperationException(smsMessage.ErrorMessage).WithData(new() { { "Code", smsMessage.ErrorCode } }); } catch (Exception exp) { - serverExceptionHandler.Handle(exp, new() { { "PhoneNumber", phoneNumber } }); - + serverExceptionHandler.Handle(exp, new() + { + { "PhoneNumber", phoneNumber }, + { "JobId", context.BackgroundJob.Id } + }); if (exp is not KnownException && cancellationToken.IsCancellationRequested is false) throw; // To retry the job } - } -} -``` - **Key features:** 1. **AutomaticRetry**: If the job fails, Hangfire automatically retries it @@ -378,11 +378,20 @@ public partial class PhoneServiceJobsRunner - `DelaysInSeconds = [30]`: Wait 30 seconds between retries - This is perfect for SMS tokens that expire after 2 minutes -2. **Exception Handling**: - - Logs the error with context (`PhoneNumber`) +2. **Hangfire-Provided Parameters**: The method accepts two special parameters that Hangfire automatically provides: + - `PerformContext context`: Provides access to job metadata (like `JobId`, `BackgroundJob`, etc.) + - `CancellationToken cancellationToken`: Signals when the job should be cancelled (e.g., server shutdown) + - These parameters have default values (`null!` and `default`) so Hangfire can invoke the method + +3. **Exception Handling**: + - Logs the error with context (`PhoneNumber`, `JobId`) - For unknown exceptions, re-throws to trigger retry - For known exceptions (business logic errors), doesn't retry +4. **CancellationToken**: Even background jobs support cancellation (e.g., if the server is shutting down) + +**Important**: Inside background job, there is **NO** `IHttpContextAccessor` or `User` object available. So if user context is needed, it must be passed as parameters to the job method. + 3. **CancellationToken**: Even background jobs support cancellation (e.g., if the server is shutting down) **Important**: Inside background job, there is **NO** `IHttpContextAccessor` or `User` object available. So if user context is needed, it must be passed as parameters to the job method. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/22- Messaging.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/22- Messaging.md index 780d4e3318..6923538bc9 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/22- Messaging.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/22- Messaging.md @@ -1,233 +1,291 @@ # Stage 22: Messaging -Welcome to Stage 22! In this stage, you will learn about the comprehensive **messaging and real-time communication system** built into the Boilerplate project. This system provides multiple communication channels for different scenarios, from in-app component communication to real-time server updates and push notifications. +Welcome to Stage 22! In this stage, you will learn about the comprehensive **messaging and real-time communication system** built into the Boilerplate project. This system provides a unified messaging architecture that enables communication across multiple channels and platforms. --- -## 📋 Topics Covered - -1. **In-App Messaging with PubSubService** - - Publish-Subscribe Pattern - - Real-World Example: Profile Picture Updates - - Cross-Component Communication - -2. **AppJsBridge - JavaScript-to-C# Communication** - - Bridging JavaScript and C# Code - - Publishing Messages from JavaScript - - window.postMessage Integration - -3. **SignalR Real-Time Communication** - - SignalR Hub Architecture - - Server-to-Client Messaging - - Targeting Specific Clients, Groups, or Users - - Client-to-Server Invocations - -4. **AppClientCoordinator - Orchestrating Everything** - - SignalR Event Subscriptions - - Authentication State Propagation - - Coordinating All Messaging Services - -5. **Push Notifications** - - Platform-Specific Push Notification Services - - Deep Linking - Opening Specific Pages - - Web Push (VAPID) - - Native Push (Android, iOS, Windows, macOS) - - Subscription Management - -6. **Browser Notification API** - - Bit.Butil.Notification Integration - - Permission Management - - Local Notifications - -7. **Practical Examples** - - Session Revocation Example - - Dashboard Data Changed Example +## 1. AppMessages - The Centralized Messaging System ---- +### Overview -## 1. In-App Messaging with PubSubService +At the heart of the Boilerplate messaging architecture is **AppMessages** - a centralized messaging system that provides a consistent way to communicate across different parts of your application, regardless of whether the communication happens: -### What is PubSubService? +- Between C# components on the client side +- From server to client through SignalR +- From JavaScript to C# code +- From web service workers to the C# code -The **PubSubService** implements a publish/subscribe pattern that enables **decoupled communication** between different parts of the application. It allows components to communicate without having direct references to each other, maintaining **loose coupling** and improving maintainability. +**Location**: [`src/Shared/Services/SharedAppMessages.cs`](/src/Shared/Services/SharedAppMessages.cs) -**Location**: [`src/Client/Boilerplate.Client.Core/Services/PubSubService.cs`](/src/Client/Boilerplate.Client.Core/Services/PubSubService.cs) +**Location**: [`src/Shared/Services/ClientAppMessages.cs`](/src/Client/Boilerplate.Client.Core/Services/ClientAppMessages.cs) -### Real-World Example: Profile Picture Updates +### Message Structure -One of the most common use cases demonstrates the power of PubSubService: +**SharedAppMessages** (Server ↔ Client): -**Scenario**: When a user changes their profile picture in the Settings/Profile page, the profile picture in the Header is automatically updated without any direct coupling between these components. +```csharp +public partial class SharedAppMessages +{ + // Data change notifications + public const string DASHBOARD_DATA_CHANGED = nameof(DASHBOARD_DATA_CHANGED); + + // Session management + public const string SESSION_REVOKED = nameof(SESSION_REVOKED); + + // Profile updates + public const string PROFILE_UPDATED = nameof(PROFILE_UPDATED); + + // Navigation and UI changes + public const string NAVIGATE_TO = nameof(NAVIGATE_TO); + public const string CHANGE_CULTURE = nameof(CHANGE_CULTURE); + public const string CHANGE_THEME = nameof(CHANGE_THEME); + + // ... and more +} +``` -**How it works**: +**ClientAppMessages** (Client-Only): -1. **Publishing the Update** (from `ProfileSection.razor.cs`): +**Location**: [`src/Client/Boilerplate.Client.Core/Services/ClientAppMessages.cs`](/src/Client/Boilerplate.Client.Core/Services/ClientAppMessages.cs) ```csharp -// After successfully updating the profile -PubSubService.Publish(ClientAppMessages.PROFILE_UPDATED, CurrentUser); +public partial class ClientAppMessages : SharedAppMessages +{ + // Theme and culture + public const string THEME_CHANGED = nameof(THEME_CHANGED); + public const string CULTURE_CHANGED = nameof(CULTURE_CHANGED); + + // Diagnostics + public const string SHOW_DIAGNOSTIC_MODAL = nameof(SHOW_DIAGNOSTIC_MODAL); + + // ... and more +} ``` -2. **Subscribing to Updates** (from `MainLayout.razor.cs`): +**Note**: `ClientAppMessages` inherits from `SharedAppMessages`, so client-side code has access to both shared and client-only messages. -```csharp -unsubscribers.Add(pubSubService.Subscribe(ClientAppMessages.PROFILE_UPDATED, async payload => -{ - currentUser = payload is JsonElement jsonDocument - ? jsonDocument.Deserialize(jsonSerializerOptions.GetTypeInfo())! // PROFILE_UPDATED can be invoked from server through SignalR - : (UserDto)payload; - await InvokeAsync(StateHasChanged); -})); -``` +--- -This pattern ensures that: -- The Settings page doesn't need to know about the Header component -- The Header component doesn't need to pull for updates -- New components can easily subscribe to profile updates -- The code remains maintainable and testable +## 2. Communication Channels -### Where PubSubService Can Be Used +The Boilerplate project provides multiple communication channels that all work with the same centralized message system. Let's explore each channel and understand when to use them. -PubSubService is versatile and can be used in multiple contexts: +### Channel 1: PubSubService (Client-Side Component Communication) -- Within Blazor components and pages -- Outside Blazor components (e.g., MAUI XAML pages) -- From JavaScript code using `window.postMessage` (via AppJsBridge) -- From server-side code using SignalR +**PubSubService** is the foundation for client-side messaging. It implements a publish/subscribe pattern for decoupled communication between components. -**Persistent Messages**: If `persistent = true`, the message is stored and delivered to handlers that subscribe **after** the message was published. This is useful for scenarios where a component needs data that was published before it was created. +**Location**: [`src/Client/Boilerplate.Client.Core/Services/PubSubService.cs`](/src/Client/Boilerplate.Client.Core/Services/PubSubService.cs) -### Message Types +**When to use**: +- Communication between Blazor components +- Communication with non-Blazor components (e.g., MAUI XAML pages) +- Broadcasting UI state changes +- Triggering actions across unrelated components -#### ClientAppMessages +**Publishing a message**: -**Location**: [`src/Client/Boilerplate.Client.Core/Services/ClientAppMessages.cs`](/src/Client/Boilerplate.Client.Core/Services/ClientAppMessages.cs) +```csharp +// From any component or service +PubSubService.Publish(ClientAppMessages.THEME_CHANGED, newTheme); +``` -These messages are for **client-only** pub/sub communication: +**Subscribing to messages**: ```csharp -public partial class ClientAppMessages : SharedAppMessages +// In component code +private Action? unsubscribe; + +protected override void OnInitialized() { - public const string PROFILE_UPDATED = nameof(PROFILE_UPDATED); - // ... and more + unsubscribe = PubSubService.Subscribe(ClientAppMessages.THEME_CHANGED, async payload => + { + currentTheme = (string)payload; + await InvokeAsync(StateHasChanged); + }); +} + +protected override void Dispose(bool disposing) +{ + unsubscribe?.Invoke(); + base.Dispose(disposing); } ``` -#### SharedAppMessages +**Persistent Messages**: -**Location**: [`src/Shared/Services/SharedAppMessages.cs`](/src/Shared/Services/SharedAppMessages.cs) +If `persistent = true`, the message is stored and delivered to handlers that **subscribe after** the message was published: + +```csharp +PubSubService.Publish(ClientAppMessages.PROFILE_UPDATED, user, persistent: true); +``` + +### Channel 2: SignalR (Server-to-Client Real-Time Communication) -These messages are used for **server-to-client communication** through SignalR: +**SignalR** enables the server to send messages to clients in real-time. In the Boilerplate project, SignalR messages are automatically bridged to PubSubService, creating a seamless experience. + +**Server-Side Hub**: [`src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs`](/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs) + +**When to use**: +- Notifying clients of data changes +- Broadcasting updates to all users or specific users +- Pushing real-time notifications +- Synchronizing state across multiple devices + +**Publishing from server to all authenticated clients**: ```csharp -public partial class SharedAppMessages -{ - public const string DASHBOARD_DATA_CHANGED = nameof(DASHBOARD_DATA_CHANGED); - // ... and more -} +// Notify all authenticated users that dashboard data changed +await appHubContext.Clients.Group("AuthenticatedClients") + .Publish(SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); ``` -**Note**: `ClientAppMessages` inherits from `SharedAppMessages`, so it includes both client-only and server-to-client messages. +**Publishing from server to specific user's all devices**: ---- +```csharp +// When user updates profile on one device, notify all their other devices +await appHubContext.Clients.User(userId.ToString()) + .Publish(SharedAppMessages.PROFILE_UPDATED, userDto, cancellationToken); +``` -## 2. AppJsBridge - JavaScript-to-C# Communication +**Client-Side Reception**: -### What is AppJsBridge? +On the client side, SignalR messages are automatically bridged to PubSubService (see `AppClientCoordinator.cs`). This means any component can subscribe to these messages using PubSubService: -**AppJsBridge** is a component that enables bidirectional communication between JavaScript/TypeScript code and C# .NET code. This is particularly useful for: +```csharp +// Component subscribes to server-sent messages the same way as client-only messages +unsubscribe = PubSubService.Subscribe(SharedAppMessages.DASHBOARD_DATA_CHANGED, async (_) => +{ + await LoadDashboardData(); + await InvokeAsync(StateHasChanged); +}); +``` -- Integrating third-party JavaScript libraries -- Handling browser events that need C# processing -- Enabling service workers to communicate with the Blazor app -- Publishing messages from JavaScript to the PubSubService +### Channel 3: AppJsBridge (JavaScript-to-C# Communication) + +**AppJsBridge** enables JavaScript code to publish messages to the C# PubSubService. **Location**: [`src/Client/Boilerplate.Client.Core/Components/Layout/AppJsBridge.razor.cs`](/src/Client/Boilerplate.Client.Core/Components/Layout/AppJsBridge.razor.cs) -#### AppJsBridge Usage Examples From JavaScript Code +**When to use**: +- Integrating third-party JavaScript libraries +- Handling browser events that need C# processing +- Calling C# code from JavaScript + +**Publishing from JavaScript**: ```javascript -// Publish a message to PubSubService +// From any JavaScript code App.publishMessage('CUSTOM_EVENT', { data: 'some data' }); -// Show diagnostic modal by publishing `SHOW_DIAGNOSTIC_MODAL` message to C# PubSubService -App.showDiagnostic(); +// Show diagnostic modal +App.showDiagnostic(); // Publishes SHOW_DIAGNOSTIC_MODAL message ``` ---- - -## 3. SignalR Real-Time Communication - -### What is SignalR? +**How it works**: -**SignalR** is ASP.NET Core's real-time communication library that enables bi-directional communication between server and clients. The Boilerplate project uses SignalR to: +```csharp +// AppJsBridge.razor.cs +[JSInvokable(nameof(PublishMessage))] +public async Task PublishMessage(string message, string? payload) +{ + // JavaScript messages are published to PubSubService + PubSubService.Publish(message, payload); +} +``` -- Send messages from server to specific clients, groups, or all clients -- Invoke client-side methods from the server +### Channel 4: window.postMessage (Cross-Context JavaScript Communication) -### AppHub - The SignalR Hub +The `window.postMessage` API allows communication between different JavaScript contexts (e.g., iframes, service workers). The Boilerplate project bridges this to PubSubService. -The main SignalR hub is located at: +**Location**: [`src/Client/Boilerplate.Client.Core/Scripts/events.ts`](/src/Client/Boilerplate.Client.Core/Scripts/events.ts) -**Location**: [`src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs`](/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs) +**When to use**: +- Communication from iframes +- Integration with third-party scripts +- Cross-origin messaging -### Hub Capabilities and Messaging Targets +**Publishing via window.postMessage**: -The `AppHub` provides enhanced messaging capabilities beyond basic SignalR. The server can send messages to: +```javascript +// From any JavaScript context (including iframes) +window.postMessage({ + key: 'PUBLISH_MESSAGE', + message: 'CUSTOM_EVENT', + payload: { data: 'value' } +}, '*'); +``` -1. **`Clients.All()`**: Broadcasts to all SignalR connections (authenticated or not) -2. **`Clients.Group("AuthenticatedClients")`**: Sends messages to all signed-in browser tabs and apps -3. **`Clients.User(userId)`**: Sends messages to **all devices of a specific user** (a user might have multiple sessions - web app open twice, mobile app, desktop app, etc.) -4. **`Clients.Client(connectionId)`**: Targets a **specific connection** (e.g., a specific browser tab or app instance) +**How it works**: -**Understanding Multi-Device Targeting**: When you use `Clients.User(userId)`, SignalR sends the message to ALL devices and sessions where that user is signed in. +```typescript +// events.ts +window.addEventListener('message', handleMessage); -### Message Types +function handleMessage(e: MessageEvent) { + if (e.data?.key === 'PUBLISH_MESSAGE') { + // Bridge to C# PubSubService via AppJsBridge + App.publishMessage(e.data?.message, e.data?.payload); + } +} +``` -**Key Message Types**: +### Channel 5: Service Worker (Background Message Handling) -1. **`PUBLISH_MESSAGE`**: Bridges SignalR and PubSubService - - Server sends this event to publish a message on the client's PubSubService - - Example: `SharedAppMessages.SESSION_REVOKED` - Redirects the device to the Sign In page when a session is revoked - - Example: `SharedAppMessages.DASHBOARD_DATA_CHANGED` - Notifies clients to refresh dashboard data +Service workers can communicate with the Blazor application using the same messaging system. This is particularly useful for handling push notification clicks. -2. **`SHOW_MESSAGE`**: Displays a text message to the user - - Shows as a browser notification if available - - Falls back to snackbar if notifications aren't available - - Can include custom data for handling notification clicks +**Location**: [`src/Client/Boilerplate.Client.Web/wwwroot/service-worker.js`](/src/Client/Boilerplate.Client.Web/wwwroot/service-worker.js) -3. **`EXCEPTION_THROWN`**: Pushes exceptions to the client for display - - Allows server to notify clients of hangfire background job errors - - Uses the same exception handling UI as client-side errors +**When to use**: +- Handling push notification clicks +- Background synchronization +- Cache management notifications +**Publishing from service worker**: -#### Authentication State Management +```javascript +// service-worker.js +self.addEventListener('notificationclick', (event) => { + const pageUrl = event.notification.data?.pageUrl; + // ... + // Send NAVIGATE_TO message to open specific page in app + client.postMessage({ + key: 'PUBLISH_MESSAGE', + message: 'NAVIGATE_TO', + payload: pageUrl + }); + return client.focus(); +}); +``` -Each user session tracks its **SignalR connection ID** in the database. This enables powerful scenarios like: +**How it works**: -**Example**: When an admin revokes a user session, the server can send a SignalR message directly to that specific browser tab or app: +```typescript +// events.ts - Listens for service worker messages +if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', handleMessage); +} -```csharp -// From UserController.cs - RevokeSession method -_ = await appHubContext.Clients.Client(userSession.SignalRConnectionId) - .InvokeAsync(SharedAppMessages.SHOW_MESSAGE, message, null, cancellationToken); +function handleMessage(e: MessageEvent) { + if (e.data?.key === 'PUBLISH_MESSAGE') { + // Bridge to C# PubSubService + App.publishMessage(e.data?.message, e.data?.payload); + } +} ``` -This ensures the corresponding browser tab or app immediately: -- Clears its access/refresh tokens from storage -- Navigates to the sign-in page automatically +--- -### SendAsync vs InvokeAsync +## 3. SignalR Details: SendAsync vs InvokeAsync -Understanding when to use `SendAsync` vs `InvokeAsync` is crucial for reliable server-to-client communication: +Understanding when to use `SendAsync` (or its wrapper `Publish`) vs `InvokeAsync` is crucial for reliable server-to-client communication. -#### When to Use InvokeAsync +### When to Use InvokeAsync Use `InvokeAsync` when: -- The server is sending a message to a **specific SignalR connection ID** -- It's **important to know** if the message arrived on the client side or not -- You need to wait for a response from the client +- Sending a message to a **specific SignalR connection ID** +- It's **important to know** if the message arrived on the client side - You need confirmation that the operation completed successfully +- You're waiting for a response from the client **Example**: @@ -242,27 +300,7 @@ if (messageShown) } ``` -#### When to Use SendAsync - -Use `SendAsync` when: -- Broadcasting to multiple clients (e.g., `Clients.All()`, `Clients.Group()`) -- You don't need confirmation of delivery -- Fire-and-forget messaging is acceptable - -**Example**: - -```csharp -// Notify all authenticated clients - no need to wait for confirmation -await appHubContext.Clients.Group("AuthenticatedClients") - .SendAsync(SharedAppMessages.PUBLISH_MESSAGE, - SharedAppMessages.DASHBOARD_DATA_CHANGED, - null, - cancellationToken); -``` - -#### Making InvokeAsync Work - -For `InvokeAsync` to work properly, the client-side `hubConnection.On` listeners (located in `AppClientCoordinator`) must return a value (typically `true`) to acknowledge receipt: +**Important**: For `InvokeAsync` to work, the client-side `hubConnection.On` listener must be registered in **`AppClientCoordinator.cs`** in the `SubscribeToSignalRSharedAppMessages` method, and it **must return a value** even a simple `true` const: ```csharp // Client-side in AppClientCoordinator.cs @@ -270,82 +308,63 @@ hubConnection.On?, bool>(SharedAppMessages.S { logger.LogInformation("SignalR Message {Message} received from server to show.", message); - // ... show message logic ... + await ShowNotificationOrSnack(message, data); - return true; // This return value enables InvokeAsync on the server + return true; }); ``` -### SignalR Hub Configuration +### When to Use SendAsync (or Publish) -The SignalR hub is configured in [`src/Server/Boilerplate.Server.Api/Program.Middlewares.cs`](/src/Server/Boilerplate.Server.Api/Program.Middlewares.cs): +Use `SendAsync` or the `Publish` extension method when: +- Broadcasting to multiple clients (e.g., `Clients.All()`, `Clients.Group()`, `Clients.User()`) +- Fire-and-forget messaging is acceptable +- You don't need confirmation of delivery + +**Example**: ```csharp -app.MapHub("/app-hub", options => options.AllowStatefulReconnects = true ...); +// Notify all authenticated clients - no need to wait for confirmation +await appHubContext.Clients.Group("AuthenticatedClients").SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); + +// OR: Simplified with Publish extension method (internally uses SendAsync) +await appHubContext.Clients.Group("AuthenticatedClients").Publish(SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); ``` -### Client-Side SignalR Connection +**Note**: The `Publish` extension method uses `SendAsync` internally and has the same fire-and-forget behavior. Both `SendAsync` and `Publish` work **without requiring special registration** in `AppClientCoordinator.cs` - they automatically bridge to PubSubService through the `PUBLISH_MESSAGE` handler. -The SignalR connection is configured on the client side in: +### SignalR Messaging Targets -**Location**: [`src/Client/Boilerplate.Client.Core/Extensions/IClientCoreServiceCollectionExtensions.cs`](/src/Client/Boilerplate.Client.Core/Extensions/IClientCoreServiceCollectionExtensions.cs) +The server can send messages to different targets: + +1. **`Clients.All()`**: All SignalR connections (authenticated or not) +2. **`Clients.Group("AuthenticatedClients")`**: All authenticated users (all their devices) +3. **`Clients.User(userId)`**: All devices of a specific user (web, mobile, desktop) +4. **`Clients.Client(connectionId)`**: A specific connection (one browser tab or app) -```csharp -var hubConnection = new HubConnectionBuilder() - .WithStatefulReconnect() - .WithAutomaticReconnect(...) - .WithUrl(...) - .Build(); -``` --- -## 4. AppClientCoordinator - Orchestrating Everything +## 5. AppClientCoordinator - Orchestrating Everything -The **AppClientCoordinator** component is responsible for coordinating all messaging services when the application starts. +The **AppClientCoordinator** is responsible for initializing and coordinating all messaging services when the application starts. **Location**: [`src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs`](/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs) ### Key Responsibilities 1. **Initialize SignalR Connection** -2. **Subscribe to SignalR Events** +2. **Subscribe to SignalR Events** (via `SubscribeToSignalRSharedAppMessages` method) 3. **Manage Authentication State Propagation** 4. **Handle Push Notification Subscriptions** 5. **Coordinate Telemetry Services** ### SignalR Event Subscriptions -#### SHOW_MESSAGE Event +The `SubscribeToSignalRSharedAppMessages` method registers handlers for SignalR messages: -```csharp -hubConnection.On?, bool>(SharedAppMessages.SHOW_MESSAGE, async (message, data) => -{ - logger.LogInformation("SignalR Message {Message} received from server to show.", message); - - ... -}); -``` - -This event handler: -- Receives a message from the server -- Shows it as a **native browser notification** if available -- Falls back to **BitSnackBar** if notifications aren't available -- Returns `true` if the message was shown successfully - -**Server-Side Usage** (from `UserController.cs`): - -```csharp -if (userSession.SignalRConnectionId != null) -{ - _ = await appHubContext.Clients.Client(userSession.SignalRConnectionId) - .InvokeAsync(SharedAppMessages.SHOW_MESSAGE, - (string)Localizer[nameof(AppStrings.TestNotificationMessage2)], - null, - cancellationToken); -} -``` +#### PUBLISH_MESSAGE Handler -#### PUBLISH_MESSAGE Event +Bridges SignalR messages to PubSubService: ```csharp hubConnection.On(SharedAppMessages.PUBLISH_MESSAGE, async (string message, object? payload) => @@ -356,30 +375,7 @@ hubConnection.On(SharedAppMessages.PUBLISH_MESSAGE, async (string message, objec }); ``` -This bridges **SignalR** and **PubSubService**, allowing server-side code to publish messages that client-side components can subscribe to. - -#### EXCEPTION_THROWN Event - -```csharp -hubConnection.On(SharedAppMessages.EXCEPTION_THROWN, async (AppProblemDetails appProblemDetails) => -{ - ExceptionHandler.Handle(appProblemDetails, displayKind: ExceptionDisplayKind.NonInterrupting); - return true; -}); -``` - -This allows the server to push exceptions to the client for display (e.g., showing an error that occurred in a hangfire background job). - -#### UPLOAD_DIAGNOSTIC_LOGGER_STORE Method - -```csharp -hubConnection.On(SharedAppMessages.UPLOAD_DIAGNOSTIC_LOGGER_STORE, async () => -{ - return DiagnosticLogger.Store.ToArray(); -}); -``` - -This allows the server to **invoke a method on the client** and receive the client's diagnostic logs in response. +This is the foundation that allows server-side code to publish messages that client-side components can subscribe to. ### Authentication State Propagation @@ -397,18 +393,15 @@ public async Task PropagateAuthState(bool firstRun, Task ta if (lastPropagatedUserId == userId) return; + // Update telemetry context TelemetryContext.UserId = userId; TelemetryContext.UserSessionId = isAuthenticated ? user.GetSessionId() : null; // Update App Insights if (isAuthenticated) - { _ = appInsights.SetAuthenticatedUserContext(user.GetUserId().ToString()); - } else - { _ = appInsights.ClearAuthenticatedUserContext(); - } // Start SignalR connection await EnsureSignalRStarted(); @@ -418,19 +411,17 @@ public async Task PropagateAuthState(bool firstRun, Task ta // Update user session info if (isAuthenticated) - { await UpdateUserSession(); - } lastPropagatedUserId = userId; } ``` -This method ensures all services are updated when authentication state changes. +This ensures all services are updated when authentication state changes. --- -## 5. Push Notifications +## 6. Push Notifications ### Push Notification Architecture @@ -512,7 +503,7 @@ Each platform has its own implementation: --- -## 6. Bit.Butil.Notification - Browser Notification API +## 7. Bit.Butil.Notification - Browser Notification API The project uses **Bit.Butil.Notification** to access the browser's native Notification API. @@ -569,7 +560,7 @@ private async Task ShowNotification() --- -## 7. Testing Push Notifications - Understanding the Four Scenarios +## 8. Testing Push Notifications - Understanding the Four Scenarios When testing push notifications, it's critical to understand that there are **four distinct scenarios** based on the app state when the notification is sent and when the user taps on it. The Boilerplate project handles all four scenarios across all platforms. @@ -589,43 +580,6 @@ When testing push notifications, it's critical to understand that there are **fo --- -## 8. Dashboard Data Changed Example - -Another common scenario is notifying all authenticated clients when data changes. - -### Scenario: Product is Created/Updated/Deleted - -**Server-Side** (from `ProductController.cs`): - -```csharp -private async Task PublishDashboardDataChanged(CancellationToken cancellationToken) -{ - // Notify all authenticated clients - await appHubContext.Clients.Group("AuthenticatedClients") - .SendAsync(SharedAppMessages.PUBLISH_MESSAGE, - SharedAppMessages.DASHBOARD_DATA_CHANGED, - null, - cancellationToken); -} -``` - -This is called after creating, updating, or deleting a product. - -**Client-Side** (any component can subscribe): - -```csharp -unsubscribe = PubSubService.Subscribe(SharedAppMessages.DASHBOARD_DATA_CHANGED, async (_) => -{ - // Refresh dashboard data - await LoadDashboardData(); - await InvokeAsync(StateHasChanged); -}); -``` - -Components subscribe to `DASHBOARD_DATA_CHANGED` and automatically refresh when notified. - ---- - ### AI Wiki: Answered Questions * [Describe the workflow of bit Boilerplate's AI chat feature and provide a high-level overview. ](https://deepwiki.com/search/describe-the-workflow-of-bit-b_822b9510-8e1d-456f-99bf-fb1778374a9a) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/prompts/getting-started.prompt.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/prompts/getting-started.prompt.md index 0c3ed9406d..9ac6d937ff 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.github/prompts/getting-started.prompt.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/prompts/getting-started.prompt.md @@ -1047,15 +1047,17 @@ At the end of Stage 21, ask: **"Do you have any questions about .NET MAUI, nativ ### Instructions -1. **Explain In-App Messaging with PubSubService**: - - **Purpose**: A publish-subscribe messaging system for communication between components within the application - - **Real-world example**: Explain to the developer that when a user changes their profile picture in Settings/Profile page, the profile picture in the Header is automatically updated - - **Search and demonstrate**: Find usages of `PubSubService` in the codebase and explain how it works - - Show how to publish a message and how to subscribe to messages - -2. **Explain AppJsBridge for JavaScript-to-C# Communication**: - - **Purpose**: Enables sending messages from JavaScript/TypeScript code to C# .NET code - - **Search and demonstrate**: Find `AppJsBridge` implementation and show examples of how JavaScript code can communicate with C# code +1. **Explain Shared AppMessages** + - **Purpose**: A centralized messaging system for communication between server and C# Client throgugh SignalR, + between C# components at client side through PubSubService, + between JavaScript and C# code through AppJsBridge and `window.addEventListener('message',...)` inside `events.ts` + between Service Worker and C# code through `navigator.serviceWorker.addEventListener('message',...)` inside `events.ts` + +2. **Explain features** + - Developer can publish messages like User's profile has been updated to the rest of the user's devices through SignalR, + so all devices get updated profile information without manual refresh. + - The same published message would update profile information in app's header through PubSubService without relying on SignalR. + - The developer can publish a message to navigate to specific page when a user taps on a push notification `Boilerplate.Client.Web/wwwroot/service-worker.js` 3. **Explain Server-to-Client Messaging with SignalR**: @@ -1065,11 +1067,13 @@ At the end of Stage 21, ask: **"Do you have any questions about .NET MAUI, nativ - `AuthenticatedClients` group (all authenticated users) - All devices of a specific user (a user might have multiple sessions - web app open twice, mobile app, etc.) - A specific device/connection -- **AppMessage Types**: - - **SharedAppMessages**: Application-specific messages, for example: - - `SharedAppMessages.SESSION_REVOKED`: Redirects the device to the Sign In page when a session is revoked - - `SharedAppMessages.SHOW_MESSAGE`: Displays a text message to the user - - **Search and demonstrate**: Find SignalR hub implementations and show examples of server-to-client messaging +- **Explain SendAsync vs InvokeAsync**: + - `InvokeAsync`: Waits for a response from the client. The client might simply return a `true` value, + but this ensures the message was received and processed. + - `SendAsync`: Fire-and-forget, no response expected + - `Publish`: Would use `SendAsync` internally to publish `SharedAppMessages` to the client and has the same fire-and-forget behavior. + - In order to make `InvokeAsync` work, the HubConnection listerner must be registered in `src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs` `SubscribeToSignalRSharedAppMessages` method, + but the `SendAsync` and `Publish` methods would work without any additional code. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index 823c39bcab..1cf7063c9b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -514,9 +514,9 @@ "src/Shared/Enums/PromptKind.cs", "src/Shared/Controllers/Chatbot/**", "src/Shared/Services/SharedAppMessages.cs", - "src/Shared/Services/SharedChatProcessMessages.cs", "src/Server/Boilerplate.Server.Api/SignalR/**", "src/Server/Boilerplate.Server.Api/Models/Chatbot/**", + "src/Server/Boilerplate.Server.Api/Extensions/IClientProxyExtensions.cs", "src/Server/Boilerplate.Server.Api/Controllers/Chatbot/**", "src/Server/Boilerplate.Server.Api/Mappers/ChatbotMapper.cs", "src/Server/Boilerplate.Server.Api/Data/Configurations/Chatbot/**", diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs index 565c12e46a..e884757008 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs @@ -2,6 +2,7 @@ using System.Web; //#if (signalR == true) using Microsoft.AspNetCore.SignalR; +using Boilerplate.Shared.Dtos.SignalR; using Microsoft.AspNetCore.SignalR.Client; //#endif //#if (appInsights == true) @@ -41,7 +42,7 @@ public partial class AppClientCoordinator : AppComponentBase [AutoInject] private IPushNotificationService pushNotificationService = default!; //#endif - private Action? unsubscribe; + private List unsubscribes = []; protected override async Task OnInitAsync() { @@ -54,13 +55,26 @@ protected override async Task OnInitAsync() if (InPrerenderSession is false) { - unsubscribe = PubSubService.Subscribe(ClientAppMessages.NAVIGATE_TO, async (uri) => + unsubscribes.Add(PubSubService.Subscribe(ClientAppMessages.NAVIGATE_TO, async (uri) => { var uriValue = uri?.ToString()!; var replace = uriValue.Contains("replace=true", StringComparison.InvariantCultureIgnoreCase); var forceLoad = uriValue.Contains("forceLoad=true", StringComparison.InvariantCultureIgnoreCase); NavigationManager.NavigateTo(uriValue.Replace("replace=true", "", StringComparison.InvariantCultureIgnoreCase).Replace("forceLoad=true", "", StringComparison.InvariantCultureIgnoreCase).TrimEnd('&'), forceLoad, replace); - }); + })); + //#if (signalR == true) + unsubscribes.Add(PubSubService.Subscribe(SharedAppMessages.EXCEPTION_THROWN, async (payload) => + { + if (payload is null) return; + + var appProblemDetails = payload is JsonElement jsonDocument + ? jsonDocument.Deserialize(JsonSerializerOptions.GetTypeInfo())! /* Message gets published from server through SignalR */ + : (AppProblemDetails)payload; + + ExceptionHandler.Handle(appProblemDetails, displayKind: ExceptionDisplayKind.NonInterrupting); + })); + //#endif + if (AppPlatform.IsBlazorHybrid is false) { try @@ -179,6 +193,18 @@ private void AuthenticationStateChanged(Task task) //#if (signalR == true) private void SubscribeToSignalRSharedAppMessages() { + hubConnection.Remove(SharedAppMessages.PUBLISH_MESSAGE); + signalROnDisposables.Add(hubConnection.On(SharedAppMessages.PUBLISH_MESSAGE, async (string message, object? payload) => + { + logger.LogInformation("SignalR Message {Message} received from server to publish.", message); + PubSubService.Publish(message, payload); + return true; + })); + // Generally, you're expected to use ShardAppMessages.PUBLISH_MESSAGE at server side to publish messages to clients through SignalR using Server.Api/Extensions/IClientProxyExtensions.cs's Publish method. + // However, in some scenarios, you might want client to return a value to server, or simply return `true` confirming that the message is received and processed successfully, + // so the server can use `InvokeAsync` instead of `SendAsync` when sending the message. + // That's why in the following code block, we subscribe to **some** SharedAppMessages directly using HubConnection: + hubConnection.Remove(SharedAppMessages.SHOW_MESSAGE); signalROnDisposables.Add(hubConnection.On(SharedAppMessages.SHOW_MESSAGE, async (string message, Dictionary? data) => { @@ -212,21 +238,6 @@ private void SubscribeToSignalRSharedAppMessages() // You can also leverage IPubSubService to notify other components in the application. })); - hubConnection.Remove(SharedAppMessages.PUBLISH_MESSAGE); - signalROnDisposables.Add(hubConnection.On(SharedAppMessages.PUBLISH_MESSAGE, async (string message, object? payload) => - { - logger.LogInformation("SignalR Message {Message} received from server to publish.", message); - PubSubService.Publish(message, payload); - return true; - })); - - hubConnection.Remove(SharedAppMessages.EXCEPTION_THROWN); - signalROnDisposables.Add(hubConnection.On(SharedAppMessages.EXCEPTION_THROWN, async (AppProblemDetails appProblemDetails) => - { - ExceptionHandler.Handle(appProblemDetails, displayKind: ExceptionDisplayKind.NonInterrupting); - return true; - })); - hubConnection.Remove(SharedAppMessages.UPLOAD_DIAGNOSTIC_LOGGER_STORE); signalROnDisposables.Add(hubConnection.On(SharedAppMessages.UPLOAD_DIAGNOSTIC_LOGGER_STORE, async () => { @@ -285,7 +296,7 @@ private async Task EnsureSignalRStarted() { try { - if (hubConnection.State is not HubConnectionState.Connected or HubConnectionState.Connecting) + if (hubConnection.State is not (HubConnectionState.Connected or HubConnectionState.Connecting)) { await hubConnection.StartAsync(CurrentCancellationToken); await HubConnectionConnected(null); @@ -359,7 +370,8 @@ await storageService.GetItem("Culture") ?? // 2- User settings private List signalROnDisposables = []; protected override async ValueTask DisposeAsync(bool disposing) { - unsubscribe?.Invoke(); + unsubscribes.ForEach(unsubscribe => unsubscribe()); + unsubscribes = []; NavigationManager.LocationChanged -= NavigationManager_LocationChanged; AuthManager.AuthenticationStateChanged -= AuthenticationStateChanged; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor new file mode 100644 index 0000000000..8a81a4820b --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor @@ -0,0 +1,19 @@ +@inherits AppComponentBase + +@* This component gets shown by SharedAppMessages.BACKGROUND_JOB_PROGRESS to show background job progress *@ + +@if (isVisible) +{ + + + +} \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor.cs new file mode 100644 index 0000000000..3c5b7091e3 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor.cs @@ -0,0 +1,87 @@ +using Boilerplate.Shared.Dtos.SignalR; + +namespace Boilerplate.Client.Core.Components.Common; + +public partial class JobProgress +{ + private bool isVisible; + + // This component shows progress for only one job at a time. + private string? currentJobTitle; + private double currentJobProgressPercentage; + private BackgroundJobProgressDto? currentlyBeingShownJobProgress; + private string? toBeIgnoredJobId; + + private Action? disposable; + + protected override async Task OnInitAsync() + { + await base.OnInitAsync(); + + if (InPrerenderSession is false) + { + disposable = PubSubService.Subscribe(SharedAppMessages.BACKGROUND_JOB_PROGRESS, async payload => + { + if (payload is null) return; + + var jobProgress = payload is JsonElement jsonDocument + ? jsonDocument.Deserialize(JsonSerializerOptions.GetTypeInfo())! /* Message gets published from server through SignalR */ + : (BackgroundJobProgressDto)payload; + + await InvokeAsync(async () => + { + currentlyBeingShownJobProgress = jobProgress; + + currentJobTitle = Localizer[jobProgress.JobTitle]; + + currentJobProgressPercentage = jobProgress.TotalItems == 0 + ? 0 + : (double)(jobProgress.SucceededItems + jobProgress.FailedItems) / jobProgress.TotalItems * 100; + + isVisible = jobProgress.JobId != toBeIgnoredJobId; + + StateHasChanged(); + + await RestartHideTimer(); + }); + }); + } + } + + private async Task RestartHideTimer() + { + await Abort(); + _ = HideAfterDelayAsync(); + } + + private async Task HideAfterDelayAsync() + { + try + { + await Task.Delay(TimeSpan.FromSeconds(5), CurrentCancellationToken); + + await InvokeAsync(() => + { + isVisible = false; + StateHasChanged(); + }); + } + catch (TaskCanceledException) + { + + } + } + + private async Task Ignore() + { + toBeIgnoredJobId = currentlyBeingShownJobProgress?.JobId; + isVisible = false; + } + + protected override async ValueTask DisposeAsync(bool disposing) + { + disposable?.Invoke(); + + await base.DisposeAsync(disposing); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor.scss b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/JobProgress.razor.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor index 8f1e0aecf4..705471574a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor @@ -2,26 +2,27 @@ @inherits LayoutComponentBase - - - - + + + + - - @Body - + + @Body + - - @*#if (signalR == true)*@ - - @*#endif*@ - - - - + + @*#if (signalR == true)*@ + + @*#endif*@ + + + + + - - - - + + + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs index 0ecabbc73d..fb053601ff 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs @@ -87,7 +87,7 @@ protected override async Task OnInitializedAsync() if (payload is null) return; currentUser = payload is JsonElement jsonDocument - ? jsonDocument.Deserialize(jsonSerializerOptions.GetTypeInfo())! // PROFILE_UPDATED can be invoked from server through SignalR + ? jsonDocument.Deserialize(jsonSerializerOptions.GetTypeInfo())! /* Message gets published from server through SignalR */ : (UserDto)payload; await InvokeAsync(StateHasChanged); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs index 3dc99feb6f..9812052529 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs @@ -107,7 +107,7 @@ private async Task DoSignIn() isWaiting = true; successfulSignIn = false; - await InvokeAsync(StateHasChanged); // Social sign-in callback will eventually call this method, so we need to update the UI immediately. See ClientAppMessages.SOCIAL_SIGN_IN references. + await InvokeAsync(StateHasChanged); // Social sign-in callback will eventually call this method, so we need to update the UI immediately. See ClientAppMessages.SOCIAL_SIGN_IN_CALLBACK references. try { @@ -202,7 +202,7 @@ private async Task DoSignIn() finally { isWaiting = false; - await InvokeAsync(StateHasChanged); // Social sign-in callback will eventually call this method, so we need to update the UI immediately. See ClientAppMessages.SOCIAL_SIGN_IN references. + await InvokeAsync(StateHasChanged); // Social sign-in callback will eventually call this method, so we need to update the UI immediately. See ClientAppMessages.SOCIAL_SIGN_IN_CALLBACK references. } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebInteropApp.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebInteropApp.ts index f765503d77..d00783a2b3 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebInteropApp.ts +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebInteropApp.ts @@ -64,7 +64,7 @@ export class WebInteropApp { if (!localHttpPort) { // Blazor WebAssembly, Auto or Server: if (window.opener) { - window.opener.postMessage({ key: 'PUBLISH_MESSAGE', message: 'SOCIAL_SIGN_IN', payload: urlToOpen }); + window.opener.postMessage({ key: 'PUBLISH_MESSAGE', message: 'SOCIAL_SIGN_IN_CALLBACK', payload: urlToOpen }); } else { WebInteropApp.autoClose = false; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/events.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/events.ts index cc05015acc..9fbf379838 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/events.ts +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/events.ts @@ -4,9 +4,9 @@ import { App } from './App'; (function () { window.addEventListener('load', handleLoad); - window.addEventListener('message', handleMessage); window.addEventListener('resize', setCssWindowSizes); + window.addEventListener('message', handleMessage); if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', handleMessage); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/AttachmentController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/AttachmentController.cs index c2069bf96d..76736bfde5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/AttachmentController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/AttachmentController.cs @@ -103,7 +103,7 @@ private async Task PublishUserProfileUpdated(User user, CancellationToken cancel .Where(us => us.UserId == user.Id && us.Id != currentUserSessionId && us.SignalRConnectionId != null) .Select(us => us.SignalRConnectionId!) .ToArrayAsync(cancellationToken); - await appHubContext.Clients.Clients(userSessionIdsExceptCurrentUserSessionId).SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.PROFILE_UPDATED, user.Map(), cancellationToken); + await appHubContext.Clients.Clients(userSessionIdsExceptCurrentUserSessionId).Publish(SharedAppMessages.PROFILE_UPDATED, user.Map(), cancellationToken); } //#endif diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Categories/CategoryController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Categories/CategoryController.cs index 9c526907e8..47437e3a4e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Categories/CategoryController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Categories/CategoryController.cs @@ -108,7 +108,7 @@ private async Task PublishDashboardDataChanged(CancellationToken cancellationTok { // Check out AppHub's comments for more info. // In order to exclude current user session, gets its signalR connection id from database and use GroupExcept instead. - await appHubContext.Clients.Group("AuthenticatedClients").SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); + await appHubContext.Clients.Group("AuthenticatedClients").Publish(SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); } //#endif diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Diagnostics/DiagnosticsController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Diagnostics/DiagnosticsController.cs index b3157469cd..28697209a7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Diagnostics/DiagnosticsController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Diagnostics/DiagnosticsController.cs @@ -52,7 +52,14 @@ public async Task PerformDiagnostics([FromQuery] string? signalRConnecti result.AppendLine($"Subscription exists: {(subscription is not null).ToString().ToLowerInvariant()}"); - await pushNotificationService.RequestPush("Test Push", $"Open terms page. {DateTimeOffset.Now:HH:mm:ss}", "testAction", PageUrls.Terms, userRelatedPush: false, s => s.DeviceId == pushNotificationSubscriptionDeviceId, cancellationToken); + await pushNotificationService.RequestPush(new() + { + Title = "Test Push", + Message = $"Open terms page. {DateTimeOffset.Now:HH:mm:ss}", + Action = "testAction", + PageUrl = PageUrls.Terms, + UserRelatedPush = false + }, s => s.DeviceId == pushNotificationSubscriptionDeviceId, cancellationToken); } //#endif diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs index bd7124522d..adbf24095e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs @@ -66,7 +66,11 @@ public async Task SendResetPasswordToken(SendResetPasswordTokenRequestDto reques //#endif //#if (notification == true) - sendMessagesTasks.Add(pushNotificationService.RequestPush(message: message, userRelatedPush: true, customSubscriptionFilter: s => s.UserSession!.UserId == user.Id, cancellationToken: cancellationToken)); + sendMessagesTasks.Add(pushNotificationService.RequestPush(new() + { + Message = message, + UserRelatedPush = true + }, customSubscriptionFilter: s => s.UserSession!.UserId == user.Id, cancellationToken: cancellationToken)); //#endif await Task.WhenAll(sendMessagesTasks); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs index 0d431c6415..3895858bc5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs @@ -348,7 +348,11 @@ public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null, //#endif //#if (notification == true) - sendMessagesTasks.Add(pushNotificationService.RequestPush(message: pushMessage, userRelatedPush: true, customSubscriptionFilter: s => s.UserSession!.UserId == user.Id, cancellationToken: cancellationToken)); + sendMessagesTasks.Add(pushNotificationService.RequestPush(new() + { + Message = pushMessage, + UserRelatedPush = true + }, customSubscriptionFilter: s => s.UserSession!.UserId == user.Id, cancellationToken: cancellationToken)); //#endif await Task.WhenAll(sendMessagesTasks); @@ -416,7 +420,11 @@ private async Task SendTwoFactorToken(SignInRequestDto request, User user, Cance sendMessagesTasks.Add(appHubContext.Clients.Clients(userConnectionIds).SendAsync(SharedAppMessages.SHOW_MESSAGE, message, null, cancellationToken)); //#endif //#if (notification == true) - sendMessagesTasks.Add(pushNotificationService.RequestPush(message: message, userRelatedPush: true, customSubscriptionFilter: s => s.UserSession!.UserId == user.Id, cancellationToken: cancellationToken)); + sendMessagesTasks.Add(pushNotificationService.RequestPush(new() + { + Message = message, + UserRelatedPush = true + }, customSubscriptionFilter: s => s.UserSession!.UserId == user.Id, cancellationToken: cancellationToken)); //#endif } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/RoleManagementController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/RoleManagementController.cs index 8141c37fea..0b0ebcd396 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/RoleManagementController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/RoleManagementController.cs @@ -231,10 +231,13 @@ await appHubContext.Clients.Clients(signalRConnectionIds) //#endif //#if (notification == true) - await pushNotificationService.RequestPush(message: dto.Message, - pageUrl: dto.PageUrl, - userRelatedPush: true, - customSubscriptionFilter: s => s.UserSession!.User!.Roles.Any(r => r.RoleId == dto.RoleId), + await pushNotificationService.RequestPush(new() + { + Message = dto.Message, + PageUrl = dto.PageUrl, + UserRelatedPush = true, + RequesterUserSessionId = User.GetSessionId() + }, customSubscriptionFilter: s => s.UserSession!.User!.Roles.Any(r => r.RoleId == dto.RoleId), cancellationToken: cancellationToken); //#endif } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs index dd883a981e..1d168461ea 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs @@ -90,7 +90,7 @@ public async Task RevokeSession(Guid id, CancellationToken cancellationToken) if (userSession.SignalRConnectionId is not null) { await appHubContext.Clients.Client(userSession.SignalRConnectionId) - .SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.SESSION_REVOKED, null, cancellationToken); + .Publish(SharedAppMessages.SESSION_REVOKED, null, cancellationToken); } //#endif } @@ -131,7 +131,7 @@ public async Task Update(EditUserRequestDto userDto, CancellationToken .Where(us => us.UserId == user.Id && us.Id != currentUserSessionId && us.SignalRConnectionId != null) .Select(us => us.SignalRConnectionId!) .ToArrayAsync(cancellationToken); - await appHubContext.Clients.Clients(userSessionIdsExceptCurrentUserSessionId).SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.PROFILE_UPDATED, updatedUser, cancellationToken); + await appHubContext.Clients.Clients(userSessionIdsExceptCurrentUserSessionId).Publish(SharedAppMessages.PROFILE_UPDATED, updatedUser, cancellationToken); //#endif return updatedUser; @@ -434,7 +434,11 @@ public async Task SendElevatedAccessToken(CancellationToken cancellationToken) //#endif //#if (notification == true) - sendMessagesTasks.Add(pushNotificationService.RequestPush(message: message, userRelatedPush: true, customSubscriptionFilter: us => us.UserSession!.UserId == user.Id && us.UserSessionId != currentUserSessionId, cancellationToken: cancellationToken)); + sendMessagesTasks.Add(pushNotificationService.RequestPush(new() + { + Message = message, + UserRelatedPush = true + }, customSubscriptionFilter: us => us.UserSession!.UserId == user.Id && us.UserSessionId != currentUserSessionId, cancellationToken: cancellationToken)); //#endif } @@ -458,7 +462,11 @@ public async Task ToggleNotification(Guid userSes if (userSession.NotificationStatus is UserSessionNotificationStatus.Allowed) { //#if (notification == true) - await pushNotificationService.RequestPush(message: Localizer[nameof(AppStrings.TestNotificationMessage1)], userRelatedPush: true, customSubscriptionFilter: us => us.UserSessionId == userSessionId, cancellationToken: cancellationToken); + await pushNotificationService.RequestPush(new() + { + Message = Localizer[nameof(AppStrings.TestNotificationMessage1)], + UserRelatedPush = true + }, customSubscriptionFilter: us => us.UserSessionId == userSessionId, cancellationToken: cancellationToken); //#endif //#if (signalR == true) if (userSession.SignalRConnectionId != null) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserManagementController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserManagementController.cs index 14e552559b..6172e4caf2 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserManagementController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserManagementController.cs @@ -131,7 +131,7 @@ private async Task RevokeSession(string connectionId, CancellationToken cancella { // Check out AppHub's comments for more info. await appHubContext.Clients.Client(connectionId) - .SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.SESSION_REVOKED, null, cancellationToken); + .Publish(SharedAppMessages.SESSION_REVOKED, null, cancellationToken); } //#endif } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Products/ProductController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Products/ProductController.cs index 6efa68406c..0ac2ae3d63 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Products/ProductController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Products/ProductController.cs @@ -163,7 +163,7 @@ private async Task PublishDashboardDataChanged(CancellationToken cancellationTok { // Check out AppHub's comments for more info. // In order to exclude current user session, gets its signalR connection id from database and use GroupExcept instead. - await appHubContext.Clients.Group("AuthenticatedClients").SendAsync(SharedAppMessages.PUBLISH_MESSAGE, SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); + await appHubContext.Clients.Group("AuthenticatedClients").Publish(SharedAppMessages.DASHBOARD_DATA_CHANGED, null, cancellationToken); } //#endif diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/IClientProxyExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/IClientProxyExtensions.cs new file mode 100644 index 0000000000..21937a7f8a --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/IClientProxyExtensions.cs @@ -0,0 +1,20 @@ +namespace Microsoft.AspNetCore.SignalR; + +public static class IClientProxyExtensions +{ + /// + /// Publishing a shared app message to the client through SignalR. + /// + public static async Task Publish(this IClientProxy clientProxy, string sharedAppMessage, CancellationToken cancellationToken) + { + await clientProxy.SendAsync(SharedAppMessages.PUBLISH_MESSAGE, sharedAppMessage, null, cancellationToken); + } + + /// + /// Publishing a shared app message to the client through SignalR. + /// + public static async Task Publish(this IClientProxy clientProxy, string sharedAppMessage, object? args, CancellationToken cancellationToken) + { + await clientProxy.SendAsync(SharedAppMessages.PUBLISH_MESSAGE, sharedAppMessage, args, cancellationToken); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/EmailService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/EmailService.cs index dd71108f8b..8f30249bb4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/EmailService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/EmailService.cs @@ -130,7 +130,7 @@ private async Task BuildBody(Dictionary para private async Task SendEmail(string body, string toEmailAddress, string toName, string subject) { - backgroundJobClient.Enqueue(jobRunner => jobRunner.SendEmailJob(toEmailAddress, toName, subject, body, default)); + backgroundJobClient.Enqueue(jobRunner => jobRunner.SendEmailJob(toEmailAddress, toName, subject, body)); } [LoggerMessage(Level = LogLevel.Information, Message = "{type} e-mail with subject '{subject}' to {toEmailAddress}. {link}")] diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/EmailServiceJobsRunner.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/EmailServiceJobsRunner.cs index 0ecfc8c9c0..f338cb7353 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/EmailServiceJobsRunner.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/EmailServiceJobsRunner.cs @@ -1,5 +1,6 @@ //+:cnd:noEmit using FluentEmail.Core; +using Hangfire.Server; namespace Boilerplate.Server.Api.Services.Jobs; @@ -12,7 +13,9 @@ public partial class EmailServiceJobsRunner [AutoInject] private IStringLocalizer emailLocalizer = default!; [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30] /*We primarily send tokens via email, which expire after 2 minutes by default. It's not worth retrying more than 3 times, with a 30-second delay between attempts.*/)] - public async Task SendEmailJob(string toEmailAddress, string toName, string subject, string body, CancellationToken cancellationToken) + public async Task SendEmailJob(string toEmailAddress, string toName, string subject, string body, + PerformContext context = null!, + CancellationToken cancellationToken = default) { try { @@ -31,7 +34,12 @@ public async Task SendEmailJob(string toEmailAddress, string toName, string subj } catch (Exception exp) { - serverExceptionHandler.Handle(exp, new() { { "Subject", subject }, { "ToEmailAddress", toEmailAddress } }); + serverExceptionHandler.Handle(exp, new() + { + { "Subject", subject }, + { "ToEmailAddress", toEmailAddress }, + { "JobId", context.BackgroundJob.Id } + }); if (exp is not KnownException && cancellationToken.IsCancellationRequested is false) throw; // To retry the job } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PhoneServiceJobsRunner.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PhoneServiceJobsRunner.cs index 661a6dba45..7557eb9fc4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PhoneServiceJobsRunner.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PhoneServiceJobsRunner.cs @@ -1,4 +1,5 @@ //+:cnd:noEmit +using Hangfire.Server; using Twilio.Rest.Api.V2010.Account; namespace Boilerplate.Server.Api.Services.Jobs; @@ -8,7 +9,9 @@ public partial class PhoneServiceJobsRunner [AutoInject] private ServerExceptionHandler serverExceptionHandler = default!; [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30] /*We primarily send tokens via sms, which expire after 2 minutes by default. It's not worth retrying more than 3 times, with a 30-second delay between attempts.*/)] - public async Task SendSms(string phoneNumber, string from, string messageText, CancellationToken cancellationToken) + public async Task SendSms(string phoneNumber, string from, string messageText, + PerformContext context = null!, + CancellationToken cancellationToken = default) { try @@ -26,7 +29,11 @@ public async Task SendSms(string phoneNumber, string from, string messageText, C } catch (Exception exp) { - serverExceptionHandler.Handle(exp, new() { { "PhoneNumber", phoneNumber } }); + serverExceptionHandler.Handle(exp, new() + { + { "PhoneNumber", phoneNumber }, + { "JobId", context.BackgroundJob.Id } + }); if (exp is not KnownException && cancellationToken.IsCancellationRequested is false) throw; // To retry the job } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PushNotificationJobRunner.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PushNotificationJobRunner.cs index e4f08be097..7128efee4a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PushNotificationJobRunner.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Jobs/PushNotificationJobRunner.cs @@ -1,6 +1,13 @@ -using AdsPush; +//+:cnd:noEmit +using AdsPush; using AdsPush.Abstraction; using System.Collections.Concurrent; +using Hangfire.Server; +//#if (signalR == true) +using Microsoft.AspNetCore.SignalR; +using Boilerplate.Server.Api.SignalR; +using Boilerplate.Shared.Dtos.SignalR; +//#endif namespace Boilerplate.Server.Api.Services.Jobs; @@ -9,13 +16,13 @@ public partial class PushNotificationJobRunner [AutoInject] private AppDbContext dbContext = default!; [AutoInject] private IAdsPushSender adsPushSender = default!; [AutoInject] private ServerExceptionHandler serverExceptionHandler = default!; + //#if (signalR == true) + [AutoInject] private IHubContext hubContext = default!; + //#endif public async Task RequestPush(int[] pushNotificationSubscriptionIds, - string? title = null, - string? message = null, - string? action = null, - string? pageUrl = null, - bool userRelatedPush = false, + PushNotificationRequest request, + PerformContext context = null!, CancellationToken cancellationToken = default) { var subscriptions = await dbContext.PushNotificationSubscriptions @@ -24,26 +31,40 @@ public async Task RequestPush(int[] pushNotificationSubscriptionIds, var payload = new AdsPushBasicSendPayload() { - Title = AdsPushText.CreateUsingString(title ?? "Boilerplate push"), - Detail = AdsPushText.CreateUsingString(message ?? string.Empty) + Title = AdsPushText.CreateUsingString(request.Title ?? "Boilerplate push"), + Detail = AdsPushText.CreateUsingString(request.Message ?? string.Empty) }; - if (string.IsNullOrEmpty(action) is false) + if (string.IsNullOrEmpty(request.Action) is false) { - payload.Parameters.Add("action", action); + payload.Parameters.Add("action", request.Action); } - if (string.IsNullOrEmpty(pageUrl) is false) + if (string.IsNullOrEmpty(request.PageUrl) is false) { - payload.Parameters.Add("pageUrl", pageUrl); + payload.Parameters.Add("pageUrl", request.PageUrl); } + //#if (signalR == true) + int failedItems = 0; + int succeededItems = 0; + string? signalRConnectionId = null; + if (request.RequesterUserSessionId != null) // Instead of passing SignalRConnectionId directly, we get it from UserSessionId to have latest value at the time of job execution + { + signalRConnectionId = await dbContext.UserSessions + .Where(us => us.Id == request.RequesterUserSessionId) + .Select(us => us.SignalRConnectionId) + .FirstOrDefaultAsync(cancellationToken); + } + //#endif + ConcurrentBag exceptions = []; + ConcurrentBag problematicSubscriptionIds = []; await Parallel.ForEachAsync(subscriptions, parallelOptions: new() { MaxDegreeOfParallelism = 10, - CancellationToken = default - }, async (subscription, _) => + CancellationToken = cancellationToken + }, async (subscription, cancellationToken) => { try { @@ -53,17 +74,50 @@ public async Task RequestPush(int[] pushNotificationSubscriptionIds, : throw new NotImplementedException(); await adsPushSender.BasicSendAsync(target, subscription.PushChannel, payload, default); + + //#if (signalR == true) + Interlocked.Increment(ref succeededItems); // Inside Parallel.ForEachAsync simple ++ wouldn't work + //#endif } catch (Exception exp) { + //#if (signalR == true) + Interlocked.Increment(ref failedItems); + //#endif exceptions.Add(exp); + problematicSubscriptionIds.Add(subscription.Id); + } + //#if (signalR == true) + finally + { + try + { + if (signalRConnectionId != null) + { + _ = hubContext.Clients.Client(signalRConnectionId).Publish(SharedAppMessages.BACKGROUND_JOB_PROGRESS, new BackgroundJobProgressDto() + { + JobId = context.BackgroundJob.Id, + JobTitle = nameof(AppStrings.PushNotificationJob), + TotalItems = pushNotificationSubscriptionIds.Length, + SucceededItems = succeededItems, + FailedItems = failedItems + }, cancellationToken); + } + } + catch { } } + //#endif }); if (exceptions.IsEmpty is false) { serverExceptionHandler.Handle(new AggregateException("Failed to send push notifications", exceptions) - .WithData(new() { { "UserRelatedPush", userRelatedPush } })); + .WithData(new() + { + { "UserRelatedPush", request.UserRelatedPush }, + { "JobId", context.BackgroundJob.Id }, + { "ProblematicSubscriptionIds", problematicSubscriptionIds } + })); } } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs index aaf160ebbf..7a15f9ab91 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs @@ -38,7 +38,7 @@ public virtual async Task SendSms(string messageText, string phoneNumber) var from = appSettings.Sms!.FromPhoneNumber!; - backgroundJobClient.Enqueue(x => x.SendSms(phoneNumber, from, messageText, default)); + backgroundJobClient.Enqueue(x => x.SendSms(phoneNumber, from, messageText)); } [LoggerMessage(Level = LogLevel.Information, Message = "SMS: {message} to {phoneNumber}.")] diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PushNotificationService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PushNotificationService.cs index a4f59dc7a7..05d001a362 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PushNotificationService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PushNotificationService.cs @@ -1,4 +1,5 @@ -using AdsPush.Vapid; +//+:cnd:noEmit +using AdsPush.Vapid; using System.Linq.Expressions; using Boilerplate.Server.Api.Services.Jobs; using Boilerplate.Shared.Dtos.PushNotification; @@ -45,11 +46,7 @@ public async Task Subscribe([Required] PushNotificationSubscriptionDto dto, Canc await dbContext.SaveChangesAsync(cancellationToken); } - public async Task RequestPush(string? title = null, - string? message = null, - string? action = null, - string? pageUrl = null, - bool userRelatedPush = false, + public async Task RequestPush(PushNotificationRequest request, Expression>? customSubscriptionFilter = null, CancellationToken cancellationToken = default) { @@ -63,7 +60,7 @@ public async Task RequestPush(string? title = null, .Where(sub => sub.ExpirationTime > now) .Where(sub => sub.UserSessionId == null || sub.UserSession!.NotificationStatus == UserSessionNotificationStatus.Allowed) .WhereIf(customSubscriptionFilter is not null, customSubscriptionFilter!) - .WhereIf(userRelatedPush is true, sub => (now - sub.RenewedOn) < serverApiSettings.Identity.RefreshTokenExpiration.TotalSeconds); + .WhereIf(request.UserRelatedPush is true, sub => (now - sub.RenewedOn) < serverApiSettings.Identity.RefreshTokenExpiration.TotalSeconds); if (customSubscriptionFilter is null) { @@ -72,6 +69,19 @@ public async Task RequestPush(string? title = null, var pushNotificationSubscriptionIds = await query.Select(pns => pns.Id).ToArrayAsync(cancellationToken); - backgroundJobClient.Enqueue(runner => runner.RequestPush(pushNotificationSubscriptionIds, title, message, action, pageUrl, userRelatedPush, default)); + backgroundJobClient.Enqueue(runner => runner.RequestPush(pushNotificationSubscriptionIds, request)); } } + +public class PushNotificationRequest +{ + public string? Title { get; set; } + public string? Message { get; set; } + public string? Action { get; set; } + public string? PageUrl { get; set; } + + public bool UserRelatedPush { get; set; } + //#if (signalR == true) + public Guid? RequesterUserSessionId { get; set; } + //#endif +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Chatbot.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Chatbot.cs index ea786ee5e3..50d9e78344 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Chatbot.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Chatbot.cs @@ -86,7 +86,7 @@ private async Task HandleException(Exception exp, CancellationToken cancellation return; try { - await Clients.Caller.SendAsync(SharedAppMessages.EXCEPTION_THROWN, problemDetails, cancellationToken); + await Clients.Caller.Publish(SharedAppMessages.EXCEPTION_THROWN, problemDetails, cancellationToken); } catch { } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs index c27dfd5b28..b228c9cfbf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs @@ -14,6 +14,7 @@ //#endif //#if (signalR == true) using Boilerplate.Shared.Dtos.Chatbot; +using Boilerplate.Shared.Dtos.SignalR; //#endif using Boilerplate.Shared.Dtos.Identity; using Boilerplate.Shared.Dtos.Statistics; @@ -64,6 +65,7 @@ namespace Boilerplate.Shared.Dtos; [JsonSerializable(typeof(DiagnosticLogDto[]))] [JsonSerializable(typeof(StartChatRequest))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(BackgroundJobProgressDto))] //#endif public partial class AppJsonContext : JsonSerializerContext { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/SendNotificationToRoleDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/SendNotificationToRoleDto.cs index d938185afe..670e125081 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/SendNotificationToRoleDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/SendNotificationToRoleDto.cs @@ -6,5 +6,8 @@ public partial class SendNotificationToRoleDto public string? Message { get; set; } + /// + /// The page to be opened when the notification is clicked. + /// public string? PageUrl { get; set; } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/SignalR/BackgroundJobProgressDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/SignalR/BackgroundJobProgressDto.cs new file mode 100644 index 0000000000..8162a6c308 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/SignalR/BackgroundJobProgressDto.cs @@ -0,0 +1,10 @@ +namespace Boilerplate.Shared.Dtos.SignalR; + +public class BackgroundJobProgressDto +{ + public required string JobId { get; set; } + public required string JobTitle { get; set; } + public int SucceededItems { get; set; } + public int TotalItems { get; set; } + public int FailedItems { get; set; } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx index 905e337874..9555735bcd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx @@ -266,6 +266,9 @@ این‌جا میتونید پرامپت‌های سیستمی مورد استفاده چت بات رو مشاهده کنید + + + عملیات ارسال پوش نوتیفیکیشن diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx index d2ff606119..d09295aed1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -266,6 +266,9 @@ After reaching {0}, extra sign-ins will have reduced functions. View the system prompts for chatbot here. + + + Push notification job diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedAppMessages.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedAppMessages.cs index 7c29568003..ba28e4b14b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedAppMessages.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedAppMessages.cs @@ -78,4 +78,9 @@ public partial class SharedAppMessages /// This would let the client know that a chat bot successfully processed the user's message. /// public const string MESSAGE_RPOCESS_SUCCESS = nameof(MESSAGE_RPOCESS_SUCCESS); + + /// + /// This would let the client know about the progress of a hangfire background job. + /// + public const string BACKGROUND_JOB_PROGRESS = nameof(BACKGROUND_JOB_PROGRESS); }