Skip to content
Open
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
14 changes: 10 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Ardalis.GuardClauses" Version="5.0.0" />
Expand Down Expand Up @@ -55,10 +56,15 @@
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.2.2" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.4.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Api.ProviderBuilderExtensions" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Propagators" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
</ItemGroup>
</Project>
71 changes: 71 additions & 0 deletions ProjectManagement-README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Project Management Module Implementation

This package extends the CleanArchitecture template with an end-to-end Project Management module for the Senior Developer Take Home Evaluation. So, it is kind of a mini-project within the template, demonstrating a full-stack feature implementation with domain-driven design, real-time updates, and user notifications. The main purpose is to showcase the candidate's ability to design and implement a complex feature while adhering to Clean Architecture principles, writing maintainable code, and delivering a polished user experience.

## Scope

The implementation adds project-scoped to-do items with:

- project list and project detail screens in Angular;
- list and Kanban board views;
- drag-and-drop Kanban movement;
- title, description, due date, assignee, reporter, and status fields;
- backend-assigned reporter from the authenticated user;
- project-scoped extensible statuses seeded for every new project;
- in-app assignment notifications with item hyperlinks;
- development email outbox for reporter status-change emails;
- SignalR real-time item and notification updates;
- tests for domain guards, command behavior, notifications, email outbox, and Kanban grouping.

## Design decisions

### Project-scoped statuses

Statuses are modeled as `ProjectTodoStatus` records associated with a project rather than as an enum. Every project is created with `To Do`, `In Progress`, and `Done`, but the model supports additional statuses per project without code changes.

### Reporter

The reporter is assigned server-side from `IUser.Id` inside `CreateProjectTodoItemCommandHandler`. The Angular client never supplies the reporter value as a trusted field.

### Assignment notifications

Assignment notifications are persisted as `UserNotification` rows and pushed to the assignee through SignalR. Notification links use `/projects/{projectId}/todos/{todoItemId}` and the Angular route opens the project detail screen with that item highlighted/opened for editing.

### Email notification on status change

Status-change email uses a generic `IEmailService`. The development implementation writes an `EmailOutboxMessage` and logs the email payload, so the behavior is demonstrable through the UI/API/database and application logs without SMTP setup.

### Real-time updates

The backend uses SignalR at `/hubs/project-todos`. Project connections join project-specific groups, and personal notifications use `Clients.User(userId)` with an explicit `SignalRUserIdProvider` based on `ClaimTypes.NameIdentifier`.

### Clean Architecture boundaries

State-changing command handlers perform validation, persistence, and post-save real-time broadcasts. Domain methods guard no-op transitions and raise events for assignment/status changes. Domain event handlers create assignment notifications and queue reporter email messages in the same unit of work without nested `SaveChangesAsync` calls.

### Angular decomposition

The UI is split into:

- `ProjectsComponent` for the project list and project creation;
- `ProjectDetailComponent` for list view, item forms, assignment/status updates, and SignalR synchronization;
- `KanbanBoardComponent` for Kanban rendering and drag-and-drop;
- `NotificationsComponent` for in-app notifications and development email outbox visibility.

## Demo steps

1. Start the solution.
2. Sign in as a user.
3. Open **Projects**.
4. Create a project.
5. Open the project and create a to-do item with title, description, due date, assignee, and status.
6. Open the same project in another browser tab.
7. Create another item and observe it appearing in the second tab without refresh.
8. Switch to Kanban view.
9. Drag an item between statuses and observe the second tab update live.
10. Assign an item to another user and open **Notifications** as that user to see the in-app notification with a link.
11. Change an item's status and open **Notifications** as the reporter to see the development email outbox message. The same payload is logged by `DevelopmentEmailService`.

## Known setup note

Formal EF migration files are not included in this package because the execution environment used to prepare it does not provide the .NET SDK. The implementation includes the EF model, configurations, DbSet registrations, and seed logic required for schema creation in the template's development flow.
10 changes: 10 additions & 0 deletions src/Application/Common/Interfaces/IApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,15 @@ public interface IApplicationDbContext

DbSet<TodoItem> TodoItems { get; }

DbSet<Project> Projects { get; }

DbSet<ProjectTodoItem> ProjectTodoItems { get; }

DbSet<ProjectTodoStatus> ProjectTodoStatuses { get; }

DbSet<UserNotification> UserNotifications { get; }

DbSet<EmailOutboxMessage> EmailOutboxMessages { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
6 changes: 6 additions & 0 deletions src/Application/Common/Interfaces/IEmailService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CleanArchitecture.Application.Common.Interfaces;

public interface IEmailService
{
Task SendAsync(string toUserId, string subject, string body, CancellationToken cancellationToken);
}
3 changes: 3 additions & 0 deletions src/Application/Common/Interfaces/IIdentityService.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using CleanArchitecture.Application.Common.Models;
using CleanArchitecture.Application.Users.Queries.GetAssignableUsers;

namespace CleanArchitecture.Application.Common.Interfaces;

public interface IIdentityService
{
Task<string?> GetUserNameAsync(string userId);

Task<IReadOnlyCollection<AssignableUserDto>> GetAssignableUsersAsync(CancellationToken cancellationToken);

Task<bool> IsInRoleAsync(string userId, string role);

Task<bool> AuthorizeAsync(string userId, string policyName);
Expand Down
15 changes: 15 additions & 0 deletions src/Application/Common/Interfaces/IProjectTodoRealtimeNotifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using CleanArchitecture.Application.Notifications.Queries.GetNotifications;
using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems;

namespace CleanArchitecture.Application.Common.Interfaces;

public interface IProjectTodoRealtimeNotifier
{
Task ProjectTodoItemCreatedAsync(ProjectTodoItemDto item, CancellationToken cancellationToken);

Task ProjectTodoItemUpdatedAsync(ProjectTodoItemDto item, CancellationToken cancellationToken);

Task ProjectTodoItemDeletedAsync(int projectId, int itemId, CancellationToken cancellationToken);

Task UserNotificationCreatedAsync(NotificationDto notification, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using CleanArchitecture.Application.Common.Interfaces;

namespace CleanArchitecture.Application.EmailOutbox.Queries.GetEmailOutboxMessages;

public record EmailOutboxMessageDto
{
public int Id { get; init; }

public string To { get; init; } = string.Empty;

public string Subject { get; init; } = string.Empty;

public string Body { get; init; } = string.Empty;

public string Status { get; init; } = string.Empty;

public DateTimeOffset? SentAt { get; init; }

public DateTimeOffset Created { get; init; }
}

public record GetEmailOutboxMessagesQuery : IRequest<IReadOnlyCollection<EmailOutboxMessageDto>>;

public class GetEmailOutboxMessagesQueryHandler : IRequestHandler<GetEmailOutboxMessagesQuery, IReadOnlyCollection<EmailOutboxMessageDto>>
{
private readonly IApplicationDbContext _context;

public GetEmailOutboxMessagesQueryHandler(IApplicationDbContext context)
{
_context = context;
}

public async Task<IReadOnlyCollection<EmailOutboxMessageDto>> Handle(GetEmailOutboxMessagesQuery request, CancellationToken cancellationToken)
{
return await _context.EmailOutboxMessages
.AsNoTracking()
.OrderByDescending(m => m.Id)
.Take(25)
.Select(m => new EmailOutboxMessageDto
{
Id = m.Id,
To = m.To,
Subject = m.Subject,
Body = m.Body,
Status = m.Status,
SentAt = m.SentAt,
Created = m.Created
})
.ToListAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using CleanArchitecture.Application.Common.Exceptions;
using CleanArchitecture.Application.Common.Interfaces;

namespace CleanArchitecture.Application.Notifications.Commands.MarkNotificationRead;

public record MarkNotificationReadCommand(int Id) : IRequest;

public class MarkNotificationReadCommandHandler : IRequestHandler<MarkNotificationReadCommand>
{
private readonly IApplicationDbContext _context;
private readonly IUser _user;

public MarkNotificationReadCommandHandler(IApplicationDbContext context, IUser user)
{
_context = context;
_user = user;
}

public async Task Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_user.Id))
{
throw new ForbiddenAccessException();
}

var notification = await _context.UserNotifications
.FirstOrDefaultAsync(n => n.Id == request.Id && n.UserId == _user.Id, cancellationToken);

Guard.Against.NotFound(request.Id, notification);

notification.IsRead = true;

await _context.SaveChangesAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using CleanArchitecture.Application.Common.Exceptions;
using CleanArchitecture.Application.Common.Interfaces;

namespace CleanArchitecture.Application.Notifications.Queries.GetNotifications;

public record NotificationDto
{
public int Id { get; init; }

public string UserId { get; init; } = string.Empty;

public string Title { get; init; } = string.Empty;

public string Message { get; init; } = string.Empty;

public string LinkUrl { get; init; } = string.Empty;

public bool IsRead { get; init; }

public DateTimeOffset Created { get; init; }
}

public record GetNotificationsQuery : IRequest<IReadOnlyCollection<NotificationDto>>;

public class GetNotificationsQueryHandler : IRequestHandler<GetNotificationsQuery, IReadOnlyCollection<NotificationDto>>
{
private readonly IApplicationDbContext _context;
private readonly IUser _user;

public GetNotificationsQueryHandler(IApplicationDbContext context, IUser user)
{
_context = context;
_user = user;
}

public async Task<IReadOnlyCollection<NotificationDto>> Handle(GetNotificationsQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_user.Id))
{
throw new ForbiddenAccessException();
}

return await _context.UserNotifications
.AsNoTracking()
.Where(n => n.UserId == _user.Id)
.OrderByDescending(n => n.Id)
.Take(25)
.Select(n => new NotificationDto
{
Id = n.Id,
UserId = n.UserId,
Title = n.Title,
Message = n.Message,
LinkUrl = n.LinkUrl,
IsRead = n.IsRead,
Created = n.Created
})
.ToListAsync(cancellationToken);
}
}
Loading