Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Comment thread
pmachapman marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class ProjectNotificationService {
}

async subscribeToProject(projectId: string): Promise<void> {
await this.connection.send('subscribeToProject', projectId).catch(err => {
await this.connection.invoke('subscribeToProject', projectId).catch(err => {
// This error is thrown when a user navigates away quickly after starting the sync
if (err.message === "Cannot send data if the connection is not in the 'Connected' State.") {
return;
Comment thread
pmachapman marked this conversation as resolved.
Comment thread
pmachapman marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class DraftNotificationService {
}

async subscribeToProject(projectId: string): Promise<void> {
await this.connection.send('subscribeToProject', projectId).catch(err => {
await this.connection.invoke('subscribeToProject', projectId).catch(err => {
// This error is thrown when a user navigates away quickly after starting the sync
if (err.message === "Cannot send data if the connection is not in the 'Connected' State.") {
return;
Comment thread
pmachapman marked this conversation as resolved.
Expand Down
42 changes: 7 additions & 35 deletions src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,10 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SIL.XForge.Scripture.Models;
using SIL.XForge.Realtime;

namespace SIL.XForge.Scripture.Services;

[Authorize]
public class DraftNotificationHub : Hub<IDraftNotifier>, IDraftNotifier
{
/// <summary>
/// Notifies subscribers to a project of draft application progress.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <param name="draftApplyState">The state of the draft being applied.</param>
/// <returns>The asynchronous task.</returns>
/// <remarks>
/// This differs from the implementation in <see cref="NotificationHub"/> in that this version
/// does have stateful reconnection, and so there is a guarantee that it is received by clients.
///
/// This is a blocking operation if the stateful reconnection buffer is full, so it should only
/// be subscribed to by the user performing the draft import. Using <see cref="NotificationHub"/>
/// is sufficient for all other users to subscribe to, although they will not receive all draft
/// progress notifications, only the final success message.
/// </remarks>
public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) =>
await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState);

/// <summary>
/// Subscribe to notifications for a project.
///
/// This is called from the frontend via <c>project-notification.service.ts</c>.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <returns>The asynchronous task.</returns>
public async Task SubscribeToProject(string projectId) =>
await Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}
/// <summary>
/// The SignalR notification hub for apply draft notifications.
/// </summary>
/// <param name="realtimeService">The realtime service.</param>
public class DraftNotificationHub(IRealtimeService realtimeService)
: NotificationHubBase<IDraftNotifier>(realtimeService) { }
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
1 change: 0 additions & 1 deletion src/SIL.XForge.Scripture/Services/IDraftNotifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,4 @@ namespace SIL.XForge.Scripture.Services;
public interface IDraftNotifier
{
Task NotifyDraftApplyProgress(string sfProjectId, DraftApplyState draftApplyState);
Task SubscribeToProject(string projectId);
}
1 change: 0 additions & 1 deletion src/SIL.XForge.Scripture/Services/INotifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@ public interface INotifier
Task NotifyBuildProgress(string sfProjectId, ServalBuildState buildState);
Task NotifyDraftApplyProgress(string sfProjectId, DraftApplyState draftApplyState);
Task NotifySyncProgress(string sfProjectId, ProgressState progressState);
Task SubscribeToProject(string projectId);
}
60 changes: 6 additions & 54 deletions src/SIL.XForge.Scripture/Services/NotificationHub.cs
Original file line number Diff line number Diff line change
@@ -1,57 +1,9 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SIL.XForge.Scripture.Models;
using SIL.XForge.Realtime;

namespace SIL.XForge.Scripture.Services;

[Authorize]
public class NotificationHub : Hub<INotifier>, INotifier
{
/// <summary>
/// Notifies subscribers to a project of draft build progress.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <param name="buildState">The build state from Serval.</param>
/// <returns>The asynchronous task.</returns>
/// <remarks>
/// This will currently be emitted on the TranslationBuildStarted and TranslationBuildFinished webhooks,
/// and when the draft pre-translations have been retrieved.
/// </remarks>
public async Task NotifyBuildProgress(string projectId, ServalBuildState buildState) =>
await Clients.Group(projectId).NotifyBuildProgress(projectId, buildState);

/// <summary>
/// Notifies subscribers to a project of draft application progress.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <param name="draftApplyState">The state of the draft being applied.</param>
/// <returns>The asynchronous task.</returns>
/// <remarks>
/// This differs from the implementation in <see cref="DraftNotificationHub"/> in that this version
/// does not have stateful reconnection, and so there is no guarantee that the message is received.
/// </remarks>
public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) =>
await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState);

/// <summary>
/// Notifies subscribers to a project of sync progress.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <param name="progressState">
/// The progress state, including a string value (Paratext only - not used in SF), or percentage value.
/// </param>
/// <returns>The asynchronous task.</returns>
public async Task NotifySyncProgress(string projectId, ProgressState progressState) =>
await Clients.Group(projectId).NotifySyncProgress(projectId, progressState);

/// <summary>
/// Subscribe to notifications for a project.
///
/// This is called from the frontend via <c>project-notification.service.ts</c>.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <returns>The asynchronous task.</returns>
public async Task SubscribeToProject(string projectId) =>
await Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}
/// <summary>
/// The SignalR notification hub for sync and draft notifications.
/// </summary>
/// <param name="realtimeService">The realtime service.</param>
public class NotificationHub(IRealtimeService realtimeService) : NotificationHubBase<INotifier>(realtimeService) { }
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
63 changes: 63 additions & 0 deletions src/SIL.XForge.Scripture/Services/NotificationHubBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SIL.XForge.Realtime;
using SIL.XForge.Scripture.Models;
using SIL.XForge.Services;
using SIL.XForge.Utils;

namespace SIL.XForge.Scripture.Services;

/// <summary>
/// Base class for SignalR notification hubs, providing shared project subscription and
/// permission checking to ensure only authorized users with Paratext roles receive notifications.
/// </summary>
[Authorize]
public abstract class NotificationHubBase<T>(IRealtimeService realtimeService) : Hub<T>
where T : class
{
/// <summary>
/// Subscribe to notifications for a project.
///
/// This is called from the frontend via <c>project-notification.service.ts</c>.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <returns>The asynchronous task.</returns>
public async Task SubscribeToProject(string projectId)
{
await EnsurePermissionAsync(projectId);
await Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/// <summary>
/// Ensures that the user has permission to access the project for SignalR notifications.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <exception cref="DataNotFoundException">The project does not exist.</exception>
/// <exception cref="ForbiddenException">
/// The user does not have permission to access the project.
/// </exception>
protected async Task EnsurePermissionAsync(string projectId)
{
// Load the project from the realtime service
Attempt<SFProject> attempt = await realtimeService.TryGetSnapshotAsync<SFProject>(projectId);
if (!attempt.TryResult(out SFProject project))
{
throw new DataNotFoundException("The project does not exist.");
}

// Retrieve the user identifier
string? userId = Context.GetHttpContext()?.User.FindFirst(XFClaimTypes.UserId)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
throw new UnauthorizedAccessException();
}

// Ensure the user is on the project, and has a Paratext role
if (!project.UserRoles.TryGetValue(userId, out string? role) || !SFProjectRole.IsParatextRole(role))
{
throw new ForbiddenException();
}
Comment thread
pmachapman marked this conversation as resolved.
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Loading