Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
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
21 changes: 21 additions & 0 deletions backend/src/Loopless.Api/Endpoints/NotificationEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using Loopless.Application.DTOs;
using Loopless.Application.Features.Notifications.GetNotifications;
using Loopless.Application.Features.Notifications.MarkAllRead;
using Loopless.Application.Features.Notifications.MarkRead;
using Loopless.Infrastructure.Hubs;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;

namespace Loopless.Api.Endpoints;

Expand Down Expand Up @@ -31,6 +34,24 @@ public static IEndpointRouteBuilder MapNotificationEndpoints(this IEndpointRoute
.Produces(StatusCodes.Status401Unauthorized)
.ProducesValidationProblem();

group.MapPut("/{id:guid}/read", async (
[FromRoute] Guid id,
ISender sender,
IHubContext<NotificationsHub> hub,
CancellationToken ct) =>
{
var userId = await sender.Send(new MarkNotificationReadCommand(id), ct);
// Persistence is done; the broadcast only syncs other open tabs/devices.
await hub.Clients.Group(userId.ToString())
.SendAsync("NotificationRead", new { notificationId = id }, ct);
return Results.NoContent();
})
.WithName("MarkNotificationRead")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

group.MapPost("/mark-all-read", async (
ISender sender,
CancellationToken ct) =>
Expand Down
4 changes: 3 additions & 1 deletion backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild
await sender.Send(new TriggerCommitSyncCommand(projectId), ct);
return Results.Accepted($"/api/v1/projects/{projectId}/commits");
})
.RequireAuthorization(KeycloakAuthExtensions.EnterprisePolicy)
// Membership (owner OR accepted member) is enforced in the handler, so the
// freelancer who actually pushes the commits can refresh them too.
.RequireAuthorization()
.WithName("TriggerCommitSync")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status401Unauthorized)
Expand Down
2 changes: 1 addition & 1 deletion backend/src/Loopless.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ await context.HttpContext.Response.WriteAsync(
recurringJobs.AddOrUpdate<GitHubSyncJob>(
GitHubSyncJob.RecurringJobId,
job => job.SyncAllAsync(CancellationToken.None),
"0 */6 * * *");
"*/30 * * * *");

recurringJobs.AddOrUpdate<UnreadMessageEmailJob>(
UnreadMessageEmailJob.RecurringJobId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using MediatR;

namespace Loopless.Application.Features.Notifications.MarkRead;

/// <summary>Marks a single notification as read. Returns the owning user's internal id
/// so the API layer can broadcast the read event to that user's hub group.</summary>
public sealed record MarkNotificationReadCommand(Guid NotificationId) : IRequest<Guid>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Notifications.MarkRead;

public sealed class MarkNotificationReadCommandHandler(
IAppDbContext db,
ICurrentUser currentUser) : IRequestHandler<MarkNotificationReadCommand, Guid>
{
public async Task<Guid> Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken)
{
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");

var userId = await db.Users
.Where(u => u.KeycloakId == keycloakId)
.Select(u => u.Id)
.FirstOrDefaultAsync(cancellationToken);

if (userId == Guid.Empty)
throw new NotFoundException("User not found.");

var notification = await db.Notifications
.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken)
?? throw new NotFoundException("Notification not found.");

// Same response as a missing notification so other users' ids can't be probed.
if (notification.UserId != userId)
throw new NotFoundException("Notification not found.");

if (!notification.IsRead)
{
notification.IsRead = true;
notification.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}

return userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FluentValidation;

namespace Loopless.Application.Features.Notifications.MarkRead;

public sealed class MarkNotificationReadCommandValidator : AbstractValidator<MarkNotificationReadCommand>
{
public MarkNotificationReadCommandValidator()
{
RuleFor(x => x.NotificationId).NotEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ public async Task<Unit> Handle(TriggerCommitSyncCommand request, CancellationTok
.FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken)
?? throw new NotFoundException("User not found.");

if (user.Role != UserRole.Enterprise)
throw new UnauthorizedException("Only the project owner can trigger commit sync.");

var project = await db.Projects
.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken)
?? throw new NotFoundException("Project not found.");

if (project.OwnerId != user.Id)
throw new UnauthorizedException("Only the project owner can trigger commit sync.");
// Owner or any accepted member can refresh commits — members (freelancers)
// are the ones pushing to the repo and waiting for their work to show up.
var isOwner = project.OwnerId == user.Id;
var isMember = !isOwner && await db.ProjectInvitations.AnyAsync(
i => i.ProjectId == project.Id
&& i.FreelancerId == user.Id
&& i.Status == InvitationStatus.Accepted,
cancellationToken);

if (!isOwner && !isMember)
throw new UnauthorizedException("Only the project owner or members can trigger commit sync.");

if (string.IsNullOrWhiteSpace(project.GitHubRepoUrl))
throw new ConflictException("Project has no GitHub repository linked.");
Expand Down
19 changes: 19 additions & 0 deletions backend/src/Loopless.Infrastructure/GitHub/GitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ public async Task<IReadOnlyList<GitHubCommitPayload>> FetchCommitsAsync(
return [];
}

// GitHub answers 404 (not 403) for private repos the token can't read — and for
// ALL private repos when no token is configured. Surface that instead of letting
// EnsureSuccessStatusCode throw a generic HttpRequestException.
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogWarning(
"GitHub returned 404 for {Repo} - repo doesn't exist, or it is private and the configured GitHub:Token is missing or lacks access - skipping sync",
repoUrl);
return [];
}

if (response.StatusCode == HttpStatusCode.Unauthorized)
{
_logger.LogWarning(
"GitHub returned 401 for {Repo} - the configured GitHub:Token is invalid or expired - skipping sync",
repoUrl);
return [];
}

response.EnsureSuccessStatusCode();

var items = await response.Content.ReadFromJsonAsync<List<GitHubCommitResponse>>(cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,19 @@ public async Task SendAsync(Guid userId, string title, string body, Cancellation
db.Notifications.Add(notification);
await db.SaveChangesAsync(cancellationToken);

// Shape must match NotificationDto on the client: without type/isRead/createdAt
// the bell can't group by day or count the new item correctly until a refetch.
await hub.Clients.Group(userId.ToString())
.SendAsync("NewNotification", new { notification.Id, notification.Title, notification.Body, notification.ActionUrl }, cancellationToken);
.SendAsync("NewNotification", new
{
notification.Id,
Type = "NewNotification",
notification.Title,
notification.Body,
IsRead = false,
notification.CreatedAt,
notification.ActionUrl,
}, cancellationToken);

var user = await db.Users
.Where(u => u.Id == userId)
Expand Down
79 changes: 79 additions & 0 deletions backend/tests/Loopless.IntegrationTests/NotificationsFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,84 @@ public async Task MarkAllRead_FlipsUnreadFlags()
remainingUnread.Should().Be(0);
}

[Fact]
public async Task MarkRead_PersistsSingleNotification()
{
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var user = new User
{
Email = "mark-one-read@test.local",
Name = "Mark One Read",
Role = UserRole.Freelancer,
KeycloakId = "keycloak-mark-one-read",
};
db.Users.Add(user);

var notification = new Notification
{
UserId = user.Id,
Title = "Single unread",
Body = "Body",
IsRead = false,
};
db.Notifications.Add(notification);
await db.SaveChangesAsync();

var client = factory.CreateAuthenticatedClient("keycloak-mark-one-read", "freelancer");
var response = await client.PutAsync($"/api/v1/notifications/{notification.Id}/read", content: null);

response.StatusCode.Should().Be(HttpStatusCode.NoContent);

var stillUnread = await db.Notifications
.Where(n => n.Id == notification.Id && !n.IsRead)
.CountAsync();
stillUnread.Should().Be(0);
}

[Fact]
public async Task MarkRead_OtherUsersNotification_Returns404()
{
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var owner = new User
{
Email = "notif-owner@test.local",
Name = "Notif Owner",
Role = UserRole.Enterprise,
KeycloakId = "keycloak-notif-owner",
};
var intruder = new User
{
Email = "notif-intruder@test.local",
Name = "Notif Intruder",
Role = UserRole.Freelancer,
KeycloakId = "keycloak-notif-intruder",
};
db.Users.AddRange(owner, intruder);

var notification = new Notification
{
UserId = owner.Id,
Title = "Owner only",
Body = "Body",
IsRead = false,
};
db.Notifications.Add(notification);
await db.SaveChangesAsync();

var client = factory.CreateAuthenticatedClient("keycloak-notif-intruder", "freelancer");
var response = await client.PutAsync($"/api/v1/notifications/{notification.Id}/read", content: null);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);

var stillUnread = await db.Notifications
.Where(n => n.Id == notification.Id && !n.IsRead)
.CountAsync();
stillUnread.Should().Be(1);
}

private sealed record MarkAllReadResponse(int Updated);
}
23 changes: 20 additions & 3 deletions frontend/client/app/(protected)/messages/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export default function MessagesPage() {

const selectedId = explicitSelectedId ?? conversations[0]?.id ?? null;

// Ref mirror so the stable SignalR receive callback can see the current
// selection without re-subscribing on every conversation switch.
const selectedIdRef = useRef<string | null>(selectedId);
useEffect(() => {
selectedIdRef.current = selectedId;
}, [selectedId]);

const selectedConversation = conversations.find((c) => c.id === selectedId) ?? null;

const historyQuery = useMessageHistory(selectedId);
Expand Down Expand Up @@ -118,9 +125,19 @@ export default function MessagesPage() {
? (prev[event.conversationId] ?? [])
: [...(prev[event.conversationId] ?? []), newMsg],
}));
queryClient.invalidateQueries({
queryKey: ["messaging", "conversations"],
});
// Messages that land in the conversation the user is currently viewing are
// read the moment they arrive. Without this they stay unread on the server
// (mark-read only ran on conversation open), so the nav badge kept showing
// a count for a chat the user already had on screen.
if (event.conversationId === selectedIdRef.current) {
markConversationRead(event.conversationId)
.catch(() => {})
.finally(() => {
queryClient.invalidateQueries({ queryKey: ["messaging", "conversations"] });
});
} else {
queryClient.invalidateQueries({ queryKey: ["messaging", "conversations"] });
}
},
[currentUserId, queryClient],
);
Expand Down
Loading
Loading