diff --git a/Directory.Packages.props b/Directory.Packages.props index aa588ae9a..dbfe455bf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,7 @@ true + true @@ -55,10 +56,15 @@ - - + + + + + + - - + + + \ No newline at end of file diff --git a/ProjectManagement-README.md b/ProjectManagement-README.md new file mode 100644 index 000000000..f87bf798d --- /dev/null +++ b/ProjectManagement-README.md @@ -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. diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs index 24f632d66..9908b9271 100644 --- a/src/Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -8,5 +8,15 @@ public interface IApplicationDbContext DbSet TodoItems { get; } + DbSet Projects { get; } + + DbSet ProjectTodoItems { get; } + + DbSet ProjectTodoStatuses { get; } + + DbSet UserNotifications { get; } + + DbSet EmailOutboxMessages { get; } + Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/src/Application/Common/Interfaces/IEmailService.cs b/src/Application/Common/Interfaces/IEmailService.cs new file mode 100644 index 000000000..f71070960 --- /dev/null +++ b/src/Application/Common/Interfaces/IEmailService.cs @@ -0,0 +1,6 @@ +namespace CleanArchitecture.Application.Common.Interfaces; + +public interface IEmailService +{ + Task SendAsync(string toUserId, string subject, string body, CancellationToken cancellationToken); +} diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index b4d74c5b7..b50dccc21 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -1,4 +1,5 @@ using CleanArchitecture.Application.Common.Models; +using CleanArchitecture.Application.Users.Queries.GetAssignableUsers; namespace CleanArchitecture.Application.Common.Interfaces; @@ -6,6 +7,8 @@ public interface IIdentityService { Task GetUserNameAsync(string userId); + Task> GetAssignableUsersAsync(CancellationToken cancellationToken); + Task IsInRoleAsync(string userId, string role); Task AuthorizeAsync(string userId, string policyName); diff --git a/src/Application/Common/Interfaces/IProjectTodoRealtimeNotifier.cs b/src/Application/Common/Interfaces/IProjectTodoRealtimeNotifier.cs new file mode 100644 index 000000000..5726cb4d1 --- /dev/null +++ b/src/Application/Common/Interfaces/IProjectTodoRealtimeNotifier.cs @@ -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); +} diff --git a/src/Application/EmailOutbox/Queries/GetEmailOutboxMessages/GetEmailOutboxMessages.cs b/src/Application/EmailOutbox/Queries/GetEmailOutboxMessages/GetEmailOutboxMessages.cs new file mode 100644 index 000000000..04d525d30 --- /dev/null +++ b/src/Application/EmailOutbox/Queries/GetEmailOutboxMessages/GetEmailOutboxMessages.cs @@ -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>; + +public class GetEmailOutboxMessagesQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + + public GetEmailOutboxMessagesQueryHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task> 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); + } +} diff --git a/src/Application/Notifications/Commands/MarkNotificationRead/MarkNotificationRead.cs b/src/Application/Notifications/Commands/MarkNotificationRead/MarkNotificationRead.cs new file mode 100644 index 000000000..4e3b22a2f --- /dev/null +++ b/src/Application/Notifications/Commands/MarkNotificationRead/MarkNotificationRead.cs @@ -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 +{ + 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); + } +} diff --git a/src/Application/Notifications/Queries/GetNotifications/GetNotifications.cs b/src/Application/Notifications/Queries/GetNotifications/GetNotifications.cs new file mode 100644 index 000000000..e956c1060 --- /dev/null +++ b/src/Application/Notifications/Queries/GetNotifications/GetNotifications.cs @@ -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>; + +public class GetNotificationsQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + private readonly IUser _user; + + public GetNotificationsQueryHandler(IApplicationDbContext context, IUser user) + { + _context = context; + _user = user; + } + + public async Task> 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); + } +} diff --git a/src/Application/ProjectTodoItems/Commands/AssignProjectTodoItem/AssignProjectTodoItem.cs b/src/Application/ProjectTodoItems/Commands/AssignProjectTodoItem/AssignProjectTodoItem.cs new file mode 100644 index 000000000..bd06cc884 --- /dev/null +++ b/src/Application/ProjectTodoItems/Commands/AssignProjectTodoItem/AssignProjectTodoItem.cs @@ -0,0 +1,104 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Notifications.Queries.GetNotifications; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; + +namespace CleanArchitecture.Application.ProjectTodoItems.Commands.AssignProjectTodoItem; + +public record AssignProjectTodoItemCommand : IRequest +{ + public int Id { get; init; } + + public int ProjectId { get; init; } + + public string? AssigneeUserId { get; init; } +} + +public class AssignProjectTodoItemCommandValidator : AbstractValidator +{ + public AssignProjectTodoItemCommandValidator() + { + RuleFor(v => v.Id).GreaterThan(0); + RuleFor(v => v.ProjectId).GreaterThan(0); + } +} + +public class AssignProjectTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IIdentityService _identityService; + private readonly IProjectTodoRealtimeNotifier _notifier; + private readonly IEmailService _emailService; + + public AssignProjectTodoItemCommandHandler(IApplicationDbContext context, IIdentityService identityService, IProjectTodoRealtimeNotifier notifier, IEmailService emailService) + { + _context = context; + _identityService = identityService; + _notifier = notifier; + _emailService = emailService; + } + + public async Task Handle(AssignProjectTodoItemCommand request, CancellationToken cancellationToken) + { + var item = await _context.ProjectTodoItems + .Include(i => i.Status) + .FirstOrDefaultAsync(i => i.Id == request.Id && i.ProjectId == request.ProjectId, cancellationToken); + + Guard.Against.NotFound(request.Id, item); + + var previousAssigneeUserId = item.AssigneeUserId; + var assignmentChanged = item.AssignTo(request.AssigneeUserId); + + if (!assignmentChanged) + { + return; + } + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(previousAssigneeUserId)) + { + await _emailService.SendAsync( + previousAssigneeUserId, + $"Project to-do unassigned: {item.Title}", + $"You are no longer assigned to '{item.Title}'. Open /projects/{item.ProjectId}/todos/{item.Id} to review it.", + cancellationToken); + } + + if (!string.IsNullOrWhiteSpace(request.AssigneeUserId)) + { + await _emailService.SendAsync( + request.AssigneeUserId, + $"Project to-do assigned: {item.Title}", + $"You have been assigned to '{item.Title}'. Open /projects/{item.ProjectId}/todos/{item.Id} to review it.", + cancellationToken); + } + + await _context.SaveChangesAsync(cancellationToken); + + await _notifier.ProjectTodoItemUpdatedAsync(await ProjectTodoItemDtoFactory.CreateAsync(item, _identityService), cancellationToken); + + if (!string.IsNullOrWhiteSpace(request.AssigneeUserId)) + { + var linkUrl = $"/projects/{item.ProjectId}/todos/{item.Id}"; + var notification = await _context.UserNotifications + .AsNoTracking() + .Where(n => n.UserId == request.AssigneeUserId && n.LinkUrl == linkUrl) + .OrderByDescending(n => n.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (notification is not null) + { + await _notifier.UserNotificationCreatedAsync(new NotificationDto + { + Id = notification.Id, + UserId = notification.UserId, + Title = notification.Title, + Message = notification.Message, + LinkUrl = notification.LinkUrl, + IsRead = notification.IsRead, + Created = notification.Created + }, cancellationToken); + } + } + } +} diff --git a/src/Application/ProjectTodoItems/Commands/ChangeProjectTodoItemStatus/ChangeProjectTodoItemStatus.cs b/src/Application/ProjectTodoItems/Commands/ChangeProjectTodoItemStatus/ChangeProjectTodoItemStatus.cs new file mode 100644 index 000000000..a0116e52f --- /dev/null +++ b/src/Application/ProjectTodoItems/Commands/ChangeProjectTodoItemStatus/ChangeProjectTodoItemStatus.cs @@ -0,0 +1,64 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; + +namespace CleanArchitecture.Application.ProjectTodoItems.Commands.ChangeProjectTodoItemStatus; + +public record ChangeProjectTodoItemStatusCommand : IRequest +{ + public int Id { get; init; } + + public int ProjectId { get; init; } + + public int StatusId { get; init; } +} + +public class ChangeProjectTodoItemStatusCommandValidator : AbstractValidator +{ + public ChangeProjectTodoItemStatusCommandValidator() + { + RuleFor(v => v.Id).GreaterThan(0); + RuleFor(v => v.ProjectId).GreaterThan(0); + RuleFor(v => v.StatusId).GreaterThan(0); + } +} + +public class ChangeProjectTodoItemStatusCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IIdentityService _identityService; + private readonly IProjectTodoRealtimeNotifier _notifier; + + public ChangeProjectTodoItemStatusCommandHandler(IApplicationDbContext context, IIdentityService identityService, IProjectTodoRealtimeNotifier notifier) + { + _context = context; + _identityService = identityService; + _notifier = notifier; + } + + public async Task Handle(ChangeProjectTodoItemStatusCommand request, CancellationToken cancellationToken) + { + var item = await _context.ProjectTodoItems + .Include(i => i.Status) + .FirstOrDefaultAsync(i => i.Id == request.Id && i.ProjectId == request.ProjectId, cancellationToken); + + Guard.Against.NotFound(request.Id, item); + + var newStatus = await _context.ProjectTodoStatuses + .FirstOrDefaultAsync(s => s.Id == request.StatusId && s.ProjectId == request.ProjectId, cancellationToken); + + Guard.Against.NotFound(request.StatusId, newStatus); + + var statusChanged = item.ChangeStatus(request.StatusId); + + if (!statusChanged) + { + return; + } + + item.Status = newStatus; + + await _context.SaveChangesAsync(cancellationToken); + + await _notifier.ProjectTodoItemUpdatedAsync(await ProjectTodoItemDtoFactory.CreateAsync(item, _identityService), cancellationToken); + } +} diff --git a/src/Application/ProjectTodoItems/Commands/CreateProjectTodoItem/CreateProjectTodoItem.cs b/src/Application/ProjectTodoItems/Commands/CreateProjectTodoItem/CreateProjectTodoItem.cs new file mode 100644 index 000000000..98c9b9cb0 --- /dev/null +++ b/src/Application/ProjectTodoItems/Commands/CreateProjectTodoItem/CreateProjectTodoItem.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using CleanArchitecture.Application.Common.Exceptions; +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Notifications.Queries.GetNotifications; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.ProjectTodoItems.Commands.CreateProjectTodoItem; + +public record CreateProjectTodoItemCommand : IRequest +{ + public int ProjectId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string? Description { get; init; } + + public string? DueDate { get; init; } + + public string? AssigneeUserId { get; init; } + + public int? StatusId { get; init; } +} + +public class CreateProjectTodoItemCommandValidator : AbstractValidator +{ + public CreateProjectTodoItemCommandValidator() + { + RuleFor(v => v.ProjectId).GreaterThan(0); + RuleFor(v => v.Title).NotEmpty().MaximumLength(200); + RuleFor(v => v.Description).MaximumLength(2000); + RuleFor(v => v.DueDate) + .Must(BeValidDateOnly) + .When(v => !string.IsNullOrWhiteSpace(v.DueDate)) + .WithMessage("Due date must use yyyy-MM-dd format."); + } + + private static bool BeValidDateOnly(string? value) + { + return DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + } +} + +public class CreateProjectTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IUser _user; + private readonly IIdentityService _identityService; + private readonly IProjectTodoRealtimeNotifier _notifier; + private readonly IEmailService _emailService; + + public CreateProjectTodoItemCommandHandler(IApplicationDbContext context, IUser user, IIdentityService identityService, IProjectTodoRealtimeNotifier notifier, IEmailService emailService) + { + _context = context; + _user = user; + _identityService = identityService; + _notifier = notifier; + _emailService = emailService; + } + + public async Task Handle(CreateProjectTodoItemCommand request, CancellationToken cancellationToken) + { + var reporterUserId = _user.Id; + + if (string.IsNullOrWhiteSpace(reporterUserId)) + { + throw new ForbiddenAccessException(); + } + + var projectExists = await _context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken); + Guard.Against.NotFound(request.ProjectId, projectExists ? request.ProjectId : (int?)null); + + var statusId = request.StatusId ?? await _context.ProjectTodoStatuses + .Where(s => s.ProjectId == request.ProjectId && s.IsDefault) + .Select(s => s.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (statusId == 0) + { + statusId = await _context.ProjectTodoStatuses + .Where(s => s.ProjectId == request.ProjectId) + .OrderBy(s => s.SortOrder) + .Select(s => s.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + Guard.Against.NotFound(request.ProjectId, statusId == 0 ? (int?)null : request.ProjectId); + + var statusIsValid = await _context.ProjectTodoStatuses.AnyAsync(s => s.Id == statusId && s.ProjectId == request.ProjectId, cancellationToken); + Guard.Against.NotFound(statusId, statusIsValid ? statusId : (int?)null); + + var item = new ProjectTodoItem + { + ProjectId = request.ProjectId, + Title = request.Title, + Description = request.Description, + DueDate = ParseDateOnly(request.DueDate), + AssigneeUserId = request.AssigneeUserId, + ReporterUserId = reporterUserId, + StatusId = statusId + }; + + _context.ProjectTodoItems.Add(item); + + await _context.SaveChangesAsync(cancellationToken); + + UserNotification? notification = null; + + if (!string.IsNullOrWhiteSpace(request.AssigneeUserId)) + { + notification = new UserNotification + { + UserId = request.AssigneeUserId, + Title = "New project to-do assignment", + Message = $"You have been assigned to '{request.Title}'.", + LinkUrl = $"/projects/{request.ProjectId}/todos/{item.Id}" + }; + + _context.UserNotifications.Add(notification); + + await _emailService.SendAsync( + request.AssigneeUserId, + $"Project to-do assigned: {request.Title}", + $"You have been assigned to '{request.Title}'. Open /projects/{request.ProjectId}/todos/{item.Id} to review it.", + cancellationToken); + + await _context.SaveChangesAsync(cancellationToken); + } + + var savedItem = await _context.ProjectTodoItems + .AsNoTracking() + .Include(i => i.Status) + .FirstAsync(i => i.Id == item.Id, cancellationToken); + + await _notifier.ProjectTodoItemCreatedAsync(await ProjectTodoItemDtoFactory.CreateAsync(savedItem, _identityService), cancellationToken); + + if (notification is not null) + { + await _notifier.UserNotificationCreatedAsync(new NotificationDto + { + Id = notification.Id, + UserId = notification.UserId, + Title = notification.Title, + Message = notification.Message, + LinkUrl = notification.LinkUrl, + IsRead = notification.IsRead, + Created = notification.Created + }, cancellationToken); + } + + return item.Id; + } + + private static DateOnly? ParseDateOnly(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + } +} diff --git a/src/Application/ProjectTodoItems/Commands/DeleteProjectTodoItem/DeleteProjectTodoItem.cs b/src/Application/ProjectTodoItems/Commands/DeleteProjectTodoItem/DeleteProjectTodoItem.cs new file mode 100644 index 000000000..0c4f35135 --- /dev/null +++ b/src/Application/ProjectTodoItems/Commands/DeleteProjectTodoItem/DeleteProjectTodoItem.cs @@ -0,0 +1,31 @@ +using CleanArchitecture.Application.Common.Interfaces; + +namespace CleanArchitecture.Application.ProjectTodoItems.Commands.DeleteProjectTodoItem; + +public record DeleteProjectTodoItemCommand(int ProjectId, int Id) : IRequest; + +public class DeleteProjectTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IProjectTodoRealtimeNotifier _notifier; + + public DeleteProjectTodoItemCommandHandler(IApplicationDbContext context, IProjectTodoRealtimeNotifier notifier) + { + _context = context; + _notifier = notifier; + } + + public async Task Handle(DeleteProjectTodoItemCommand request, CancellationToken cancellationToken) + { + var item = await _context.ProjectTodoItems + .FirstOrDefaultAsync(i => i.Id == request.Id && i.ProjectId == request.ProjectId, cancellationToken); + + Guard.Against.NotFound(request.Id, item); + + _context.ProjectTodoItems.Remove(item); + + await _context.SaveChangesAsync(cancellationToken); + + await _notifier.ProjectTodoItemDeletedAsync(request.ProjectId, request.Id, cancellationToken); + } +} diff --git a/src/Application/ProjectTodoItems/Commands/UpdateProjectTodoItem/UpdateProjectTodoItem.cs b/src/Application/ProjectTodoItems/Commands/UpdateProjectTodoItem/UpdateProjectTodoItem.cs new file mode 100644 index 000000000..b2635696e --- /dev/null +++ b/src/Application/ProjectTodoItems/Commands/UpdateProjectTodoItem/UpdateProjectTodoItem.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; + +namespace CleanArchitecture.Application.ProjectTodoItems.Commands.UpdateProjectTodoItem; + +public record UpdateProjectTodoItemCommand : IRequest +{ + public int Id { get; init; } + + public int ProjectId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string? Description { get; init; } + + public string? DueDate { get; init; } +} + +public class UpdateProjectTodoItemCommandValidator : AbstractValidator +{ + public UpdateProjectTodoItemCommandValidator() + { + RuleFor(v => v.Id).GreaterThan(0); + RuleFor(v => v.ProjectId).GreaterThan(0); + RuleFor(v => v.Title).NotEmpty().MaximumLength(200); + RuleFor(v => v.Description).MaximumLength(2000); + RuleFor(v => v.DueDate) + .Must(BeValidDateOnly) + .When(v => !string.IsNullOrWhiteSpace(v.DueDate)) + .WithMessage("Due date must use yyyy-MM-dd format."); + } + + private static bool BeValidDateOnly(string? value) + { + return DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + } +} + +public class UpdateProjectTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IIdentityService _identityService; + private readonly IProjectTodoRealtimeNotifier _notifier; + + public UpdateProjectTodoItemCommandHandler(IApplicationDbContext context, IIdentityService identityService, IProjectTodoRealtimeNotifier notifier) + { + _context = context; + _identityService = identityService; + _notifier = notifier; + } + + public async Task Handle(UpdateProjectTodoItemCommand request, CancellationToken cancellationToken) + { + var item = await _context.ProjectTodoItems + .Include(i => i.Status) + .FirstOrDefaultAsync(i => i.Id == request.Id && i.ProjectId == request.ProjectId, cancellationToken); + + Guard.Against.NotFound(request.Id, item); + + item.Title = request.Title; + item.Description = request.Description; + item.DueDate = ParseDateOnly(request.DueDate); + + await _context.SaveChangesAsync(cancellationToken); + + await _notifier.ProjectTodoItemUpdatedAsync(await ProjectTodoItemDtoFactory.CreateAsync(item, _identityService), cancellationToken); + } + + private static DateOnly? ParseDateOnly(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + } +} diff --git a/src/Application/ProjectTodoItems/EventHandlers/CreateAssignmentNotification.cs b/src/Application/ProjectTodoItems/EventHandlers/CreateAssignmentNotification.cs new file mode 100644 index 000000000..b9fd95a70 --- /dev/null +++ b/src/Application/ProjectTodoItems/EventHandlers/CreateAssignmentNotification.cs @@ -0,0 +1,35 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Events; + +namespace CleanArchitecture.Application.ProjectTodoItems.EventHandlers; + +public class CreateAssignmentNotification : INotificationHandler +{ + private readonly IApplicationDbContext _context; + + public CreateAssignmentNotification(IApplicationDbContext context) + { + _context = context; + } + + public Task Handle(ProjectTodoItemAssignedEvent notification, CancellationToken cancellationToken) + { + var item = notification.Item; + + if (string.IsNullOrWhiteSpace(item.AssigneeUserId)) + { + return Task.CompletedTask; + } + + _context.UserNotifications.Add(new UserNotification + { + UserId = item.AssigneeUserId, + Title = "Project to-do assigned", + Message = $"You have been assigned to '{item.Title}'.", + LinkUrl = $"/projects/{item.ProjectId}/todos/{item.Id}" + }); + + return Task.CompletedTask; + } +} diff --git a/src/Application/ProjectTodoItems/EventHandlers/QueueReporterEmailOnStatusChanged.cs b/src/Application/ProjectTodoItems/EventHandlers/QueueReporterEmailOnStatusChanged.cs new file mode 100644 index 000000000..8ff7f0a22 --- /dev/null +++ b/src/Application/ProjectTodoItems/EventHandlers/QueueReporterEmailOnStatusChanged.cs @@ -0,0 +1,26 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Domain.Events; + +namespace CleanArchitecture.Application.ProjectTodoItems.EventHandlers; + +public class QueueReporterEmailOnStatusChanged : INotificationHandler +{ + private readonly IEmailService _emailService; + + public QueueReporterEmailOnStatusChanged(IEmailService emailService) + { + _emailService = emailService; + } + + public async Task Handle(ProjectTodoItemStatusChangedEvent notification, CancellationToken cancellationToken) + { + var item = notification.Item; + var statusName = item.Status?.Name ?? "the selected status"; + + await _emailService.SendAsync( + item.ReporterUserId, + $"Project to-do status changed: {item.Title}", + $"The status for '{item.Title}' changed to '{statusName}'. Open /projects/{item.ProjectId}/todos/{item.Id} to review it.", + cancellationToken); + } +} diff --git a/src/Application/ProjectTodoItems/Queries/GetProjectKanbanBoard/GetProjectKanbanBoard.cs b/src/Application/ProjectTodoItems/Queries/GetProjectKanbanBoard/GetProjectKanbanBoard.cs new file mode 100644 index 000000000..699ec9af3 --- /dev/null +++ b/src/Application/ProjectTodoItems/Queries/GetProjectKanbanBoard/GetProjectKanbanBoard.cs @@ -0,0 +1,67 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; + +namespace CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectKanbanBoard; + +public record ProjectKanbanColumnDto +{ + public int StatusId { get; init; } + + public string StatusName { get; init; } = string.Empty; + + public int SortOrder { get; init; } + + public IReadOnlyCollection Items { get; init; } = Array.Empty(); +} + +public record ProjectKanbanBoardVm +{ + public int ProjectId { get; init; } + + public IReadOnlyCollection Columns { get; init; } = Array.Empty(); +} + +public record GetProjectKanbanBoardQuery(int ProjectId) : IRequest; + +public class GetProjectKanbanBoardQueryHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IIdentityService _identityService; + + public GetProjectKanbanBoardQueryHandler(IApplicationDbContext context, IIdentityService identityService) + { + _context = context; + _identityService = identityService; + } + + public async Task Handle(GetProjectKanbanBoardQuery request, CancellationToken cancellationToken) + { + var statuses = await _context.ProjectTodoStatuses + .AsNoTracking() + .Where(s => s.ProjectId == request.ProjectId) + .OrderBy(s => s.SortOrder) + .ToListAsync(cancellationToken); + + var items = await _context.ProjectTodoItems + .AsNoTracking() + .Include(i => i.Status) + .Where(i => i.ProjectId == request.ProjectId) + .OrderBy(i => i.DueDate) + .ThenBy(i => i.Title) + .ToListAsync(cancellationToken); + + var itemDtos = await ProjectTodoItemDtoFactory.CreateAsync(items, _identityService); + + return new ProjectKanbanBoardVm + { + ProjectId = request.ProjectId, + Columns = statuses.Select(status => new ProjectKanbanColumnDto + { + StatusId = status.Id, + StatusName = status.Name, + SortOrder = status.SortOrder, + Items = itemDtos.Where(i => i.StatusId == status.Id).ToList() + }).ToList() + }; + } +} diff --git a/src/Application/ProjectTodoItems/Queries/GetProjectTodoItems/GetProjectTodoItems.cs b/src/Application/ProjectTodoItems/Queries/GetProjectTodoItems/GetProjectTodoItems.cs new file mode 100644 index 000000000..c7a667f1c --- /dev/null +++ b/src/Application/ProjectTodoItems/Queries/GetProjectTodoItems/GetProjectTodoItems.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using CleanArchitecture.Application.Common.Interfaces; + +namespace CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; + +public record ProjectTodoItemDto +{ + public int Id { get; init; } + + public int ProjectId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string? Description { get; init; } + + public string? DueDate { get; init; } + + public string? AssigneeUserId { get; init; } + + public string? AssigneeUserName { get; init; } + + public string ReporterUserId { get; init; } = string.Empty; + + public string? ReporterUserName { get; init; } + + public int StatusId { get; init; } + + public string StatusName { get; init; } = string.Empty; +} + +public record GetProjectTodoItemsQuery(int ProjectId) : IRequest>; + +public class GetProjectTodoItemsQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + private readonly IIdentityService _identityService; + + public GetProjectTodoItemsQueryHandler(IApplicationDbContext context, IIdentityService identityService) + { + _context = context; + _identityService = identityService; + } + + public async Task> Handle(GetProjectTodoItemsQuery request, CancellationToken cancellationToken) + { + var items = await _context.ProjectTodoItems + .AsNoTracking() + .Include(i => i.Status) + .Where(i => i.ProjectId == request.ProjectId) + .OrderBy(i => i.DueDate) + .ThenBy(i => i.Title) + .ToListAsync(cancellationToken); + + return await ProjectTodoItemDtoFactory.CreateAsync(items, _identityService); + } +} + +public static class ProjectTodoItemDtoFactory +{ + public static async Task> CreateAsync(IEnumerable items, IIdentityService identityService) + { + var result = new List(); + + foreach (var item in items) + { + result.Add(await CreateAsync(item, identityService)); + } + + return result; + } + + public static async Task CreateAsync(Domain.Entities.ProjectTodoItem item, IIdentityService identityService) + { + return new ProjectTodoItemDto + { + Id = item.Id, + ProjectId = item.ProjectId, + Title = item.Title, + Description = item.Description, + DueDate = item.DueDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + AssigneeUserId = item.AssigneeUserId, + AssigneeUserName = string.IsNullOrWhiteSpace(item.AssigneeUserId) ? null : await identityService.GetUserNameAsync(item.AssigneeUserId), + ReporterUserId = item.ReporterUserId, + ReporterUserName = await identityService.GetUserNameAsync(item.ReporterUserId), + StatusId = item.StatusId, + StatusName = item.Status.Name + }; + } +} diff --git a/src/Application/ProjectTodoStatuses/Queries/GetProjectTodoStatuses/GetProjectTodoStatuses.cs b/src/Application/ProjectTodoStatuses/Queries/GetProjectTodoStatuses/GetProjectTodoStatuses.cs new file mode 100644 index 000000000..97be7e4a3 --- /dev/null +++ b/src/Application/ProjectTodoStatuses/Queries/GetProjectTodoStatuses/GetProjectTodoStatuses.cs @@ -0,0 +1,48 @@ +using CleanArchitecture.Application.Common.Interfaces; + +namespace CleanArchitecture.Application.ProjectTodoStatuses.Queries.GetProjectTodoStatuses; + +public record ProjectTodoStatusDto +{ + public int Id { get; init; } + + public int ProjectId { get; init; } + + public string Name { get; init; } = string.Empty; + + public int SortOrder { get; init; } + + public bool IsDefault { get; init; } + + public bool IsTerminal { get; init; } +} + +public record GetProjectTodoStatusesQuery(int ProjectId) : IRequest>; + +public class GetProjectTodoStatusesQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + + public GetProjectTodoStatusesQueryHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task> Handle(GetProjectTodoStatusesQuery request, CancellationToken cancellationToken) + { + return await _context.ProjectTodoStatuses + .AsNoTracking() + .Where(s => s.ProjectId == request.ProjectId) + .OrderBy(s => s.SortOrder) + .Select(s => new ProjectTodoStatusDto + { + Id = s.Id, + ProjectId = s.ProjectId, + Name = s.Name, + SortOrder = s.SortOrder, + IsDefault = s.IsDefault, + IsTerminal = s.IsTerminal + }) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Application/Projects/Commands/CreateProject/CreateProject.cs b/src/Application/Projects/Commands/CreateProject/CreateProject.cs new file mode 100644 index 000000000..d7af15554 --- /dev/null +++ b/src/Application/Projects/Commands/CreateProject/CreateProject.cs @@ -0,0 +1,53 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.Projects.Commands.CreateProject; + +public record CreateProjectCommand : IRequest +{ + public string Name { get; init; } = string.Empty; + + public string? Description { get; init; } +} + +public class CreateProjectCommandValidator : AbstractValidator +{ + public CreateProjectCommandValidator() + { + RuleFor(v => v.Name) + .NotEmpty() + .MaximumLength(200); + + RuleFor(v => v.Description) + .MaximumLength(1000); + } +} + +public class CreateProjectCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public CreateProjectCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(CreateProjectCommand request, CancellationToken cancellationToken) + { + var project = new Project + { + Name = request.Name, + Description = request.Description + }; + + project.Statuses.Add(new ProjectTodoStatus { Name = "To Do", SortOrder = 10, IsDefault = true }); + project.Statuses.Add(new ProjectTodoStatus { Name = "In Progress", SortOrder = 20 }); + project.Statuses.Add(new ProjectTodoStatus { Name = "Done", SortOrder = 30, IsTerminal = true }); + + _context.Projects.Add(project); + + await _context.SaveChangesAsync(cancellationToken); + + return project.Id; + } +} diff --git a/src/Application/Projects/Queries/GetProjects/GetProjects.cs b/src/Application/Projects/Queries/GetProjects/GetProjects.cs new file mode 100644 index 000000000..48f965779 --- /dev/null +++ b/src/Application/Projects/Queries/GetProjects/GetProjects.cs @@ -0,0 +1,41 @@ +using CleanArchitecture.Application.Common.Interfaces; + +namespace CleanArchitecture.Application.Projects.Queries.GetProjects; + +public record ProjectDto +{ + public int Id { get; init; } + + public string Name { get; init; } = string.Empty; + + public string? Description { get; init; } + + public int TodoItemCount { get; init; } +} + +public record GetProjectsQuery : IRequest>; + +public class GetProjectsQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + + public GetProjectsQueryHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task> Handle(GetProjectsQuery request, CancellationToken cancellationToken) + { + return await _context.Projects + .AsNoTracking() + .OrderBy(p => p.Name) + .Select(p => new ProjectDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + TodoItemCount = p.TodoItems.Count + }) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Application/Users/Queries/GetAssignableUsers/GetAssignableUsers.cs b/src/Application/Users/Queries/GetAssignableUsers/GetAssignableUsers.cs new file mode 100644 index 000000000..d1939c490 --- /dev/null +++ b/src/Application/Users/Queries/GetAssignableUsers/GetAssignableUsers.cs @@ -0,0 +1,27 @@ +using CleanArchitecture.Application.Common.Interfaces; + +namespace CleanArchitecture.Application.Users.Queries.GetAssignableUsers; + +public record AssignableUserDto +{ + public string Id { get; init; } = string.Empty; + + public string UserName { get; init; } = string.Empty; +} + +public record GetAssignableUsersQuery : IRequest>; + +public class GetAssignableUsersQueryHandler : IRequestHandler> +{ + private readonly IIdentityService _identityService; + + public GetAssignableUsersQueryHandler(IIdentityService identityService) + { + _identityService = identityService; + } + + public async Task> Handle(GetAssignableUsersQuery request, CancellationToken cancellationToken) + { + return await _identityService.GetAssignableUsersAsync(cancellationToken); + } +} diff --git a/src/Domain/Entities/EmailOutboxMessage.cs b/src/Domain/Entities/EmailOutboxMessage.cs new file mode 100644 index 000000000..7e5f4a8ea --- /dev/null +++ b/src/Domain/Entities/EmailOutboxMessage.cs @@ -0,0 +1,14 @@ +namespace CleanArchitecture.Domain.Entities; + +public class EmailOutboxMessage : BaseAuditableEntity +{ + public string To { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; + + public DateTimeOffset? SentAt { get; set; } + + public string Status { get; set; } = "Pending"; +} diff --git a/src/Domain/Entities/Project.cs b/src/Domain/Entities/Project.cs new file mode 100644 index 000000000..9fc26340e --- /dev/null +++ b/src/Domain/Entities/Project.cs @@ -0,0 +1,12 @@ +namespace CleanArchitecture.Domain.Entities; + +public class Project : BaseAuditableEntity +{ + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public IList Statuses { get; private set; } = new List(); + + public IList TodoItems { get; private set; } = new List(); +} diff --git a/src/Domain/Entities/ProjectTodoItem.cs b/src/Domain/Entities/ProjectTodoItem.cs new file mode 100644 index 000000000..8ccc50fae --- /dev/null +++ b/src/Domain/Entities/ProjectTodoItem.cs @@ -0,0 +1,52 @@ +namespace CleanArchitecture.Domain.Entities; + +public class ProjectTodoItem : BaseAuditableEntity +{ + public int ProjectId { get; set; } + + public string Title { get; set; } = string.Empty; + + public string? Description { get; set; } + + public DateOnly? DueDate { get; set; } + + public string? AssigneeUserId { get; set; } + + public string ReporterUserId { get; set; } = string.Empty; + + public int StatusId { get; set; } + + public Project Project { get; set; } = null!; + + public ProjectTodoStatus Status { get; set; } = null!; + + public bool AssignTo(string? assigneeUserId) + { + if (AssigneeUserId == assigneeUserId) + { + return false; + } + + AssigneeUserId = assigneeUserId; + + if (!string.IsNullOrWhiteSpace(assigneeUserId)) + { + AddDomainEvent(new ProjectTodoItemAssignedEvent(this)); + } + + return true; + } + + public bool ChangeStatus(int statusId) + { + if (StatusId == statusId) + { + return false; + } + + StatusId = statusId; + AddDomainEvent(new ProjectTodoItemStatusChangedEvent(this)); + + return true; + } +} diff --git a/src/Domain/Entities/ProjectTodoStatus.cs b/src/Domain/Entities/ProjectTodoStatus.cs new file mode 100644 index 000000000..52898a32d --- /dev/null +++ b/src/Domain/Entities/ProjectTodoStatus.cs @@ -0,0 +1,18 @@ +namespace CleanArchitecture.Domain.Entities; + +public class ProjectTodoStatus : BaseAuditableEntity +{ + public int ProjectId { get; set; } + + public string Name { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + public bool IsDefault { get; set; } + + public bool IsTerminal { get; set; } + + public Project Project { get; set; } = null!; + + public IList TodoItems { get; private set; } = new List(); +} diff --git a/src/Domain/Entities/UserNotification.cs b/src/Domain/Entities/UserNotification.cs new file mode 100644 index 000000000..e28de320e --- /dev/null +++ b/src/Domain/Entities/UserNotification.cs @@ -0,0 +1,14 @@ +namespace CleanArchitecture.Domain.Entities; + +public class UserNotification : BaseAuditableEntity +{ + public string UserId { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public string LinkUrl { get; set; } = string.Empty; + + public bool IsRead { get; set; } +} diff --git a/src/Domain/Events/ProjectTodoItemAssignedEvent.cs b/src/Domain/Events/ProjectTodoItemAssignedEvent.cs new file mode 100644 index 000000000..b5fb05c71 --- /dev/null +++ b/src/Domain/Events/ProjectTodoItemAssignedEvent.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Domain.Events; + +public class ProjectTodoItemAssignedEvent : BaseEvent +{ + public ProjectTodoItemAssignedEvent(ProjectTodoItem item) + { + Item = item; + } + + public ProjectTodoItem Item { get; } +} diff --git a/src/Domain/Events/ProjectTodoItemStatusChangedEvent.cs b/src/Domain/Events/ProjectTodoItemStatusChangedEvent.cs new file mode 100644 index 000000000..fe7829350 --- /dev/null +++ b/src/Domain/Events/ProjectTodoItemStatusChangedEvent.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Domain.Events; + +public class ProjectTodoItemStatusChangedEvent : BaseEvent +{ + public ProjectTodoItemStatusChangedEvent(ProjectTodoItem item) + { + Item = item; + } + + public ProjectTodoItem Item { get; } +} diff --git a/src/Infrastructure/Data/ApplicationDbContext.cs b/src/Infrastructure/Data/ApplicationDbContext.cs index 580b3a633..b6b1cea96 100644 --- a/src/Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Infrastructure/Data/ApplicationDbContext.cs @@ -15,6 +15,16 @@ public ApplicationDbContext(DbContextOptions options) : ba public DbSet TodoItems => Set(); + public DbSet Projects => Set(); + + public DbSet ProjectTodoItems => Set(); + + public DbSet ProjectTodoStatuses => Set(); + + public DbSet UserNotifications => Set(); + + public DbSet EmailOutboxMessages => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs b/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs index 35c2f763d..ea77ed05d 100644 --- a/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs +++ b/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs @@ -4,6 +4,7 @@ using CleanArchitecture.Infrastructure.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -67,7 +68,6 @@ public async Task SeedAsync() public async Task TrySeedAsync() { - // Default roles var administratorRole = new IdentityRole(Roles.Administrator); if (_roleManager.Roles.All(r => r.Name != administratorRole.Name)) @@ -75,7 +75,6 @@ public async Task TrySeedAsync() await _roleManager.CreateAsync(administratorRole); } - // Default users var administrator = new ApplicationUser { UserName = "administrator@localhost", Email = "administrator@localhost" }; if (_userManager.Users.All(u => u.UserName != administrator.UserName)) @@ -83,12 +82,17 @@ public async Task TrySeedAsync() await _userManager.CreateAsync(administrator, "Administrator1!"); if (!string.IsNullOrWhiteSpace(administratorRole.Name)) { - await _userManager.AddToRolesAsync(administrator, new [] { administratorRole.Name }); + await _userManager.AddToRolesAsync(administrator, new[] { administratorRole.Name }); } } - // Default data - // Seed, if necessary + var assignee = new ApplicationUser { UserName = "assignee@localhost", Email = "assignee@localhost" }; + + if (_userManager.Users.All(u => u.UserName != assignee.UserName)) + { + await _userManager.CreateAsync(assignee, "Assignee1!"); + } + if (!_context.TodoLists.Any()) { _context.TodoLists.Add(new TodoList @@ -103,8 +107,37 @@ public async Task TrySeedAsync() new TodoItem { Title = "Reward yourself with a nice, long nap 🏆" }, } }); + } + + if (!await _context.Projects.AnyAsync()) + { + var project = new Project + { + Name = "Demo Project", + Description = "Seed project for the take-home Project Management module." + }; + + var todo = new ProjectTodoStatus { Name = "To Do", SortOrder = 10, IsDefault = true }; + var progress = new ProjectTodoStatus { Name = "In Progress", SortOrder = 20 }; + var done = new ProjectTodoStatus { Name = "Done", SortOrder = 30, IsTerminal = true }; + + project.Statuses.Add(todo); + project.Statuses.Add(progress); + project.Statuses.Add(done); + + project.TodoItems.Add(new ProjectTodoItem + { + Title = "Item: Review project management module", + Description = "Use the list and Kanban views to verify the end-to-end workflow.", + DueDate = DateOnly.FromDateTime(DateTime.UtcNow.Date.AddDays(2)), + AssigneeUserId = assignee.Id, + ReporterUserId = administrator.Id, + Status = todo + }); - await _context.SaveChangesAsync(); + _context.Projects.Add(project); } + + await _context.SaveChangesAsync(); } } diff --git a/src/Infrastructure/Data/Configurations/EmailOutboxMessageConfiguration.cs b/src/Infrastructure/Data/Configurations/EmailOutboxMessageConfiguration.cs new file mode 100644 index 000000000..0e5255d78 --- /dev/null +++ b/src/Infrastructure/Data/Configurations/EmailOutboxMessageConfiguration.cs @@ -0,0 +1,18 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Data.Configurations; + +public class EmailOutboxMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.To).HasMaxLength(320).IsRequired(); + builder.Property(t => t.Subject).HasMaxLength(300).IsRequired(); + builder.Property(t => t.Body).HasMaxLength(4000).IsRequired(); + builder.Property(t => t.Status).HasMaxLength(50).IsRequired(); + + builder.HasIndex(t => new { t.Status, t.Created }); + } +} diff --git a/src/Infrastructure/Data/Configurations/ProjectConfiguration.cs b/src/Infrastructure/Data/Configurations/ProjectConfiguration.cs new file mode 100644 index 000000000..6ae3902f2 --- /dev/null +++ b/src/Infrastructure/Data/Configurations/ProjectConfiguration.cs @@ -0,0 +1,18 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Data.Configurations; + +public class ProjectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Name) + .HasMaxLength(200) + .IsRequired(); + + builder.Property(t => t.Description) + .HasMaxLength(1000); + } +} diff --git a/src/Infrastructure/Data/Configurations/ProjectTodoItemConfiguration.cs b/src/Infrastructure/Data/Configurations/ProjectTodoItemConfiguration.cs new file mode 100644 index 000000000..0528b6a71 --- /dev/null +++ b/src/Infrastructure/Data/Configurations/ProjectTodoItemConfiguration.cs @@ -0,0 +1,41 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Data.Configurations; + +public class ProjectTodoItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + + builder.Property(t => t.Description) + .HasMaxLength(2000); + + builder.Property(t => t.ReporterUserId) + .HasMaxLength(450) + .IsRequired(); + + builder.Property(t => t.AssigneeUserId) + .HasMaxLength(450); + + builder.HasIndex(t => t.ProjectId); + builder.HasIndex(t => new { t.ProjectId, t.StatusId }); + builder.HasIndex(t => t.AssigneeUserId); + builder.HasIndex(t => t.ReporterUserId); + builder.HasIndex(t => t.DueDate); + + builder.HasOne(t => t.Project) + .WithMany(p => p.TodoItems) + .HasForeignKey(t => t.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(t => t.Status) + .WithMany(s => s.TodoItems) + .HasForeignKey(t => t.StatusId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/Infrastructure/Data/Configurations/ProjectTodoStatusConfiguration.cs b/src/Infrastructure/Data/Configurations/ProjectTodoStatusConfiguration.cs new file mode 100644 index 000000000..0c30ab9ce --- /dev/null +++ b/src/Infrastructure/Data/Configurations/ProjectTodoStatusConfiguration.cs @@ -0,0 +1,22 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Data.Configurations; + +public class ProjectTodoStatusConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Name) + .HasMaxLength(100) + .IsRequired(); + + builder.HasIndex(t => new { t.ProjectId, t.SortOrder }); + + builder.HasOne(t => t.Project) + .WithMany(p => p.Statuses) + .HasForeignKey(t => t.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Infrastructure/Data/Configurations/UserNotificationConfiguration.cs b/src/Infrastructure/Data/Configurations/UserNotificationConfiguration.cs new file mode 100644 index 000000000..7a5065b4d --- /dev/null +++ b/src/Infrastructure/Data/Configurations/UserNotificationConfiguration.cs @@ -0,0 +1,18 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Data.Configurations; + +public class UserNotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.UserId).HasMaxLength(450).IsRequired(); + builder.Property(t => t.Title).HasMaxLength(200).IsRequired(); + builder.Property(t => t.Message).HasMaxLength(1000).IsRequired(); + builder.Property(t => t.LinkUrl).HasMaxLength(500).IsRequired(); + + builder.HasIndex(t => new { t.UserId, t.IsRead }); + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index f361e4541..f4b410418 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -1,7 +1,8 @@ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.Infrastructure.Data.Interceptors; -using CleanArchitecture.Infrastructure.Identity; +using CleanArchitecture.Infrastructure.Identity; +using CleanArchitecture.Infrastructure.Email; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -74,6 +75,7 @@ public static void AddInfrastructureServices(this IHostApplicationBuilder builde #endif builder.Services.AddSingleton(TimeProvider.System); - builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddScoped(); } } diff --git a/src/Infrastructure/Email/DevelopmentEmailService.cs b/src/Infrastructure/Email/DevelopmentEmailService.cs new file mode 100644 index 000000000..abcc9b4e2 --- /dev/null +++ b/src/Infrastructure/Email/DevelopmentEmailService.cs @@ -0,0 +1,35 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Infrastructure.Data; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Infrastructure.Email; + +public class DevelopmentEmailService : IEmailService +{ + private readonly ApplicationDbContext _context; + private readonly IIdentityService _identityService; + private readonly ILogger _logger; + + public DevelopmentEmailService(ApplicationDbContext context, IIdentityService identityService, ILogger logger) + { + _context = context; + _identityService = identityService; + _logger = logger; + } + + public async Task SendAsync(string toUserId, string subject, string body, CancellationToken cancellationToken) + { + var to = await _identityService.GetUserNameAsync(toUserId) ?? toUserId; + + _context.EmailOutboxMessages.Add(new EmailOutboxMessage + { + To = to, + Subject = subject, + Body = body, + Status = "Queued" + }); + + _logger.LogInformation("Development email queued. To: {To}; Subject: {Subject}; Body: {Body}", to, subject, body); + } +} diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 8fa38f4a8..1c3eae6d1 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -42,6 +42,18 @@ public IdentityService( return (result.ToApplicationResult(), user.Id); } + public async Task> GetAssignableUsersAsync(CancellationToken cancellationToken) + { + return await _userManager.Users + .OrderBy(u => u.UserName) + .Select(u => new CleanArchitecture.Application.Users.Queries.GetAssignableUsers.AssignableUserDto + { + Id = u.Id, + UserName = u.UserName ?? u.Email ?? u.Id + }) + .ToListAsync(cancellationToken); + } + public async Task IsInRoleAsync(string userId, string role) { var user = await _userManager.FindByIdAsync(userId); diff --git a/src/Web/ClientApp/package-lock.json b/src/Web/ClientApp/package-lock.json index 280decf8c..abb0755ac 100644 --- a/src/Web/ClientApp/package-lock.json +++ b/src/Web/ClientApp/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser-dynamic": "^21.1.5", "@angular/platform-server": "^21.1.5", "@angular/router": "^21.1.5", + "@microsoft/signalr": "^9.0.0", "@picocss/pico": "^2.0.0", "lucide-angular": "^1.0.0", "run-script-os": "^1.1.6", @@ -3974,6 +3975,49 @@ "win32" ] }, + "node_modules/@microsoft/signalr": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz", + "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@microsoft/signalr/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -6817,6 +6861,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8531,7 +8587,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8542,7 +8597,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8877,6 +8931,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -9118,6 +9181,16 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -11829,6 +11902,26 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", @@ -12836,6 +12929,27 @@ "license": "MIT", "optional": true }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/psl/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -12889,6 +13003,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -13050,7 +13170,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -13357,7 +13476,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sass": { @@ -13713,6 +13832,12 @@ "node": ">= 0.8" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14516,6 +14641,45 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-dump": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", @@ -14784,6 +14948,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14959,6 +15133,12 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.105.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", @@ -15348,6 +15528,16 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/src/Web/ClientApp/package.json b/src/Web/ClientApp/package.json index 6aac46dbc..26196d47a 100644 --- a/src/Web/ClientApp/package.json +++ b/src/Web/ClientApp/package.json @@ -25,6 +25,7 @@ "@angular/platform-server": "^21.1.5", "@angular/router": "^21.1.5", "@picocss/pico": "^2.0.0", + "@microsoft/signalr": "^9.0.0", "lucide-angular": "^1.0.0", "run-script-os": "^1.1.6", "rxjs": "~7.8.1", @@ -46,4 +47,4 @@ "nswag": "latest", "typescript": "~5.9.3" } -} +} \ No newline at end of file diff --git a/src/Web/ClientApp/proxy.conf.js b/src/Web/ClientApp/proxy.conf.js index 6fb3f6b11..1d6c371bc 100644 --- a/src/Web/ClientApp/proxy.conf.js +++ b/src/Web/ClientApp/proxy.conf.js @@ -8,6 +8,7 @@ const PROXY_CONFIG = [ { context: [ "/api", + "/hubs", "/openapi", "/scalar", "/weatherforecast", @@ -15,6 +16,7 @@ const PROXY_CONFIG = [ ], target: target, secure: env["NODE_ENV"] !== "development", + ws: true } ]; diff --git a/src/Web/ClientApp/src/app/app.module.ts b/src/Web/ClientApp/src/app/app.module.ts index 8563646ee..f39a27dce 100644 --- a/src/Web/ClientApp/src/app/app.module.ts +++ b/src/Web/ClientApp/src/app/app.module.ts @@ -11,6 +11,10 @@ import { HomeComponent } from './home/home.component'; import { CounterComponent } from './counter/counter.component'; import { WeatherComponent } from './weather/weather.component'; import { TasksComponent } from './todo/todo.component'; +import { ProjectsComponent } from './projects/projects.component'; +import { ProjectDetailComponent } from './projects/project-detail/project-detail.component'; +import { KanbanBoardComponent } from './projects/kanban-board/kanban-board.component'; +import { NotificationsComponent } from './notifications/notifications.component'; import { ThemeToggleComponent } from './theme-toggle/theme-toggle.component'; import { API_BASE_URL } from './web-api-client'; import { AuthorizeInterceptor } from 'src/api-authorization/authorize.interceptor'; @@ -32,6 +36,10 @@ export function getApiBaseUrl(): string { CounterComponent, WeatherComponent, TasksComponent, + ProjectsComponent, + ProjectDetailComponent, + KanbanBoardComponent, + NotificationsComponent, ThemeToggleComponent, LoginComponent, RegisterComponent @@ -46,6 +54,10 @@ export function getApiBaseUrl(): string { { path: 'counter', component: CounterComponent }, { path: 'weather', component: WeatherComponent, canActivate: [AuthGuard] }, { path: 'todo', component: TasksComponent, canActivate: [AuthGuard] }, + { path: 'projects', component: ProjectsComponent, canActivate: [AuthGuard] }, + { path: 'projects/:projectId', component: ProjectDetailComponent, canActivate: [AuthGuard] }, + { path: 'projects/:projectId/todos/:todoItemId', component: ProjectDetailComponent, canActivate: [AuthGuard] }, + { path: 'notifications', component: NotificationsComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginComponent }, { path: 'register', component: RegisterComponent } ]) diff --git a/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html b/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html index 9eaa9fac5..567f9af4a 100644 --- a/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html +++ b/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html @@ -8,6 +8,8 @@
  • Counter
  • Weather
  • Tasks
  • +
  • Projects
  • +
  • Notifications
    • @if (isAuthenticated$ | async) { diff --git a/src/Web/ClientApp/src/app/notifications/notifications.component.html b/src/Web/ClientApp/src/app/notifications/notifications.component.html new file mode 100644 index 000000000..8b8f466e2 --- /dev/null +++ b/src/Web/ClientApp/src/app/notifications/notifications.component.html @@ -0,0 +1,41 @@ +
      +

      Notifications

      +

      In-app assignment notifications and development email outbox messages are shown here for the take-home demo.

      + +
      +
      +

      Assignment notifications

      +
      + @if (notifications.length === 0) { +

      No notifications.

      + } + @for (notification of notifications; track notification.id) { +
      +

      {{ notification.title }}

      +

      {{ notification.message }}

      + Open item + @if (!notification.isRead) { + + } +
      + } +
      + +
      +
      +

      Development email outbox

      + +
      + @if (emailOutboxMessages.length === 0) { +

      No queued emails.

      + } + @for (email of emailOutboxMessages; track email.id) { + + } +
      +
      diff --git a/src/Web/ClientApp/src/app/notifications/notifications.component.scss b/src/Web/ClientApp/src/app/notifications/notifications.component.scss new file mode 100644 index 000000000..00961872c --- /dev/null +++ b/src/Web/ClientApp/src/app/notifications/notifications.component.scss @@ -0,0 +1,7 @@ +article.unread { + border-inline-start: .25rem solid var(--pico-primary); +} + +.email-message { + border-inline-start: .25rem solid var(--pico-muted-border-color); +} diff --git a/src/Web/ClientApp/src/app/notifications/notifications.component.ts b/src/Web/ClientApp/src/app/notifications/notifications.component.ts new file mode 100644 index 000000000..bd99a62e1 --- /dev/null +++ b/src/Web/ClientApp/src/app/notifications/notifications.component.ts @@ -0,0 +1,71 @@ +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { NotificationDto, NotificationsClient } from '../web-api-client'; +import { ProjectTodoRealtimeService } from '../projects/project-todo-realtime.service'; + +interface EmailOutboxMessageDto { + id: number; + to: string; + subject: string; + body: string; + status: string; + sentAt?: string; + created: string; +} + +@Component({ + standalone: false, + selector: 'app-notifications', + templateUrl: './notifications.component.html', + styleUrls: ['./notifications.component.scss'] +}) +export class NotificationsComponent implements OnInit { + notifications: NotificationDto[] = []; + emailOutboxMessages: EmailOutboxMessageDto[] = []; + + constructor( + private readonly notificationsClient: NotificationsClient, + private readonly realtime: ProjectTodoRealtimeService, + private readonly http: HttpClient, + private readonly cdr: ChangeDetectorRef + ) { } + + ngOnInit(): void { + this.load(); + this.loadEmailOutbox(); + this.realtime.notificationCreated$.subscribe(notification => { + this.notifications = [notification, ...this.notifications]; + this.cdr.detectChanges(); + }); + } + + load(): void { + this.notificationsClient.getNotifications().subscribe({ + next: notifications => { + this.notifications = notifications ?? []; + this.cdr.detectChanges(); + }, + error: error => console.error(error) + }); + } + + loadEmailOutbox(): void { + this.http.get('/api/EmailOutboxMessages').subscribe({ + next: messages => { + this.emailOutboxMessages = messages ?? []; + this.cdr.detectChanges(); + }, + error: error => console.error(error) + }); + } + + markRead(notification: NotificationDto): void { + this.notificationsClient.markRead(notification.id).subscribe({ + next: () => { + this.notifications = this.notifications.map(n => n.id === notification.id ? { ...n, isRead: true } as NotificationDto : n); + this.cdr.detectChanges(); + }, + error: error => console.error(error) + }); + } +} diff --git a/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.html b/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.html new file mode 100644 index 000000000..3d729d7d8 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.html @@ -0,0 +1,38 @@ +
      + @for (status of statuses; track status.id) { +
      +
      +

      {{ status.name }}

      + {{ itemsForStatus(status.id).length }} +
      + +
      + @for (item of itemsForStatus(status.id); track item.id) { +
      +
      {{ item.title }}
      + @if (item.description) { +

      {{ item.description }}

      + } + + @if (item.assigneeUserName) { + Assigned to {{ item.assigneeUserName }} + } @else { + Unassigned + } + @if (item.dueDate) { + Due {{ formatDate(item.dueDate) }} + } + +
      + +
      +
      + } + + @if (itemsForStatus(status.id).length === 0) { +
      Drop items here
      + } +
      +
      + } +
      diff --git a/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.scss b/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.scss new file mode 100644 index 000000000..a93e9b6b1 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.scss @@ -0,0 +1,140 @@ +.kanban-board { + display: grid; + gap: 1rem; + grid-auto-columns: minmax(18rem, 22rem); + grid-auto-flow: column; + overflow-x: auto; + overscroll-behavior-x: contain; + padding-bottom: .75rem; + width: 100%; +} + +.kanban-column { + background: var(--pico-card-background-color); + border: .125rem solid transparent; + border-radius: var(--pico-border-radius); + box-sizing: border-box; + min-width: 0; + padding: .75rem; +} + +.kanban-column.drag-over { + border-color: var(--pico-primary); +} + +.kanban-column header { + align-items: center; + display: flex; + justify-content: space-between; + gap: .75rem; + margin-bottom: .75rem; +} + +.kanban-column h3 { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kanban-column header span { + border-radius: 999px; + background: var(--pico-muted-border-color); + flex: 0 0 auto; + min-width: 1.75rem; + padding: .1rem .5rem; + text-align: center; +} + +.kanban-column header span.terminal { + background: var(--pico-primary-background); +} + +.kanban-cards { + display: grid; + gap: .75rem; + min-height: 5rem; +} + +.kanban-card { + box-sizing: border-box; + cursor: grab; + margin: 0; + min-width: 0; + padding: .75rem; + width: 100%; +} + +.kanban-card.overdue { + border-inline-start: .25rem solid var(--pico-del-color); +} + +.kanban-card-title, +.kanban-card-description, +.kanban-card-meta { + overflow-wrap: anywhere; + word-break: break-word; +} + +.kanban-card-title { + display: -webkit-box; + font-weight: 700; + line-height: 1.35; + margin-bottom: .35rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.kanban-card-description { + display: -webkit-box; + line-height: 1.4; + margin: 0 0 .75rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + +.kanban-card-meta { + color: var(--pico-muted-color); + display: flex; + flex-wrap: wrap; + gap: .25rem .5rem; + line-height: 1.35; + margin-bottom: .75rem; +} + +.kanban-card-actions { + display: flex; + justify-content: flex-start; +} + +.kanban-card-actions button { + margin: 0; +} + +.empty-column { + border: .0625rem dashed var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); + color: var(--pico-muted-color); + min-height: 4rem; + padding: 1rem; + text-align: center; +} + +@media (min-width: 90rem) { + .kanban-board { + grid-auto-columns: minmax(20rem, 24rem); + } +} + +:host-context(html[data-theme="light"]) .kanban-column header span.terminal { + color: #e5e7eb; +} + +@media (prefers-color-scheme: light) { + :host-context(html:not([data-theme="dark"])) .kanban-column header span.terminal { + color: #e5e7eb; + } +} diff --git a/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.ts b/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.ts new file mode 100644 index 000000000..959bbe8b0 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/kanban-board/kanban-board.component.ts @@ -0,0 +1,67 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ProjectTodoItemDto, ProjectTodoStatusDto } from '../../web-api-client'; + +@Component({ + standalone: false, + selector: 'app-project-kanban-board', + templateUrl: './kanban-board.component.html', + styleUrls: ['./kanban-board.component.scss'] +}) +export class KanbanBoardComponent { + @Input() statuses: ProjectTodoStatusDto[] = []; + @Input() items: ProjectTodoItemDto[] = []; + @Output() statusChanged = new EventEmitter<{ item: ProjectTodoItemDto; statusId: number }>(); + @Output() editRequested = new EventEmitter(); + + draggedItem?: ProjectTodoItemDto; + dragOverStatusId?: number; + + itemsForStatus(statusId: number): ProjectTodoItemDto[] { + return this.items.filter(item => item.statusId === statusId); + } + + onDragStart(item: ProjectTodoItemDto): void { + this.draggedItem = item; + } + + onDragEnd(): void { + this.draggedItem = undefined; + this.dragOverStatusId = undefined; + } + + onDragOver(statusId: number, event: DragEvent): void { + event.preventDefault(); + this.dragOverStatusId = statusId; + } + + onDrop(statusId: number, event: DragEvent): void { + event.preventDefault(); + + if (this.draggedItem && this.draggedItem.statusId !== statusId) { + this.statusChanged.emit({ item: this.draggedItem, statusId }); + } + + this.onDragEnd(); + } + + formatDate(value: string | undefined): string { + return value || ''; + } + + isOverdue(item: ProjectTodoItemDto): boolean { + if (!item.dueDate || item.statusName === 'Done') { + return false; + } + + return item.dueDate < this.getTodayDateOnlyValue(); + } + + private getTodayDateOnlyValue(): string { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } +} diff --git a/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.html b/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.html new file mode 100644 index 000000000..9d6a97d77 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.html @@ -0,0 +1,141 @@ +
      + + + @if (error) { +

      {{ error }}

      + } + +
      +

      {{ editingItem ? 'Edit item' : 'Create item' }}

      + @if (editingItem) { +
      + + + +
      +
      + + +
      + } @else { +
      + + + +
      +
      + +
      + } +
      + + @if (loading) { +

      Loading project items...

      + } @else if (activeView === 'list') { +
      +

      List view

      +
      + + + + + + + + + + + + + + @for (item of items; track trackById($index, item)) { + + + + + + + + + + } + +
      TitleDescriptionDueAssigneeReporterStatus
      {{ item.title }}{{ item.description }}{{ formatDate(item.dueDate) }} + + {{ item.reporterUserName || item.reporterUserId }} + + + + +
      +
      +
      + } @else { +
      +

      Kanban board

      + +
      + } +
      diff --git a/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.scss b/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.scss new file mode 100644 index 000000000..421ea66f2 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.scss @@ -0,0 +1,172 @@ +.project-detail-page { + padding-block: 1rem; +} + +.page-header { + align-items: flex-start; + display: grid; + gap: 1.25rem; + grid-template-columns: minmax(14rem, 18rem) minmax(22rem, 1fr); + margin-bottom: 1rem; +} + +.page-header h1 { + margin-bottom: .5rem; +} + +.page-header p { + margin-bottom: 0; + overflow-wrap: anywhere; +} + +.view-switch { + display: grid; + grid-template-columns: repeat(2, minmax(8rem, 1fr)); + gap: .5rem; + width: 100%; +} + +.item-editor-card, +.list-card, +.kanban-card-shell { + overflow: hidden; +} + +.item-editor-card { + padding: 1.25rem; +} + +.item-form { + display: grid; + gap: 1rem; +} + +.create-item-form, +.edit-item-form { + grid-template-columns: minmax(0, 1fr); +} + +.title-field input { + font-size: 1rem; +} + +.description-field textarea { + min-height: 9rem; + resize: vertical; +} + +.metadata-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(12rem, 1fr)); +} + +.create-actions, +.edit-actions { + border-top: 1px solid var(--pico-muted-border-color); + margin-top: 1.25rem; + padding-top: 1rem; +} + +.edit-metadata-grid { + align-items: start; + grid-template-columns: minmax(0, 16rem) minmax(0, 24rem); + justify-content: start; +} + +.edit-metadata-grid > label, +.edit-metadata-grid .read-only-field { + display: flex; + flex-direction: column; + gap: .35rem; + margin: 0; + min-width: 0; +} + +.edit-metadata-grid input, +.edit-metadata-grid .read-only-value { + box-sizing: border-box; + height: 3rem; + line-height: 1.25; + margin: 0; + min-height: 3rem; + width: 100%; +} + +.edit-metadata-grid .read-only-value { + align-items: center; +} + +.actions-row { + display: flex; + gap: .5rem; +} + +.read-only-field { + display: flex; + flex-direction: column; + gap: .35rem; + min-width: 0; +} + +.read-only-label { + color: var(--pico-color); +} + +.read-only-value { + align-items: center; + background: var(--pico-form-element-background-color); + border: var(--pico-border-width) solid var(--pico-form-element-border-color); + border-radius: var(--pico-border-radius); + color: var(--pico-muted-color); + display: flex; + min-width: 0; + overflow: hidden; + padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal); + text-overflow: ellipsis; + white-space: nowrap; +} + +.table-wrapper { + overflow-x: auto; +} + +.actions-cell { + white-space: nowrap; +} + +.highlighted { + outline: .125rem solid var(--pico-primary); + outline-offset: -.125rem; +} + +.error { + color: var(--pico-del-color); +} + +:host-context(html[data-theme="light"]) .project-detail-page button:not(.secondary):not(.outline) { + color: #e5e7eb; +} + +@media (prefers-color-scheme: light) { + :host-context(html:not([data-theme="dark"])) .project-detail-page button:not(.secondary):not(.outline) { + color: #e5e7eb; + } +} + +@media (max-width: 64rem) { + .page-header { + grid-template-columns: 1fr; + } +} + +@media (max-width: 48rem) { + .metadata-grid, + .edit-metadata-grid { + grid-template-columns: 1fr; + } + + .view-switch { + grid-template-columns: 1fr; + } +} diff --git a/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.ts b/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.ts new file mode 100644 index 000000000..9e29f4dcc --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/project-detail/project-detail.component.ts @@ -0,0 +1,257 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { + AssignableUserDto, + AssignableUsersClient, + AssignProjectTodoItemCommand, + ChangeProjectTodoItemStatusCommand, + CreateProjectTodoItemCommand, + ProjectDto, + ProjectTodoItemDto, + ProjectTodoItemsClient, + ProjectTodoStatusDto, + ProjectTodoStatusesClient, + ProjectsClient, + UpdateProjectTodoItemCommand +} from '../../web-api-client'; +import { ProjectTodoRealtimeService } from '../project-todo-realtime.service'; + +@Component({ + standalone: false, + selector: 'app-project-detail', + templateUrl: './project-detail.component.html', + styleUrls: ['./project-detail.component.scss'] +}) +export class ProjectDetailComponent implements OnInit, OnDestroy { + projectId = 0; + highlightedItemId?: number; + project?: ProjectDto; + statuses: ProjectTodoStatusDto[] = []; + users: AssignableUserDto[] = []; + items: ProjectTodoItemDto[] = []; + activeView: 'list' | 'board' = 'list'; + loading = true; + error = ''; + newItem = { title: '', description: '', dueDate: '', assigneeUserId: '', statusId: undefined as number | undefined }; + editingItem?: ProjectTodoItemDto; + editingItemDueDate = ''; + private readonly subscriptions = new Subscription(); + + constructor( + private readonly route: ActivatedRoute, + private readonly projectsClient: ProjectsClient, + private readonly todoItemsClient: ProjectTodoItemsClient, + private readonly statusesClient: ProjectTodoStatusesClient, + private readonly usersClient: AssignableUsersClient, + private readonly realtime: ProjectTodoRealtimeService, + private readonly cdr: ChangeDetectorRef + ) { } + + async ngOnInit(): Promise { + this.projectId = Number(this.route.snapshot.paramMap.get('projectId') ?? this.route.snapshot.paramMap.get('id')); + const itemId = Number(this.route.snapshot.paramMap.get('todoItemId')); + this.highlightedItemId = Number.isFinite(itemId) && itemId > 0 ? itemId : undefined; + + this.subscriptions.add(this.realtime.itemCreated$.subscribe(item => this.upsertRealtimeItem(item))); + this.subscriptions.add(this.realtime.itemUpdated$.subscribe(item => this.upsertRealtimeItem(item))); + this.subscriptions.add(this.realtime.itemDeleted$.subscribe(event => { + if (event.projectId === this.projectId) { + this.items = this.items.filter(i => i.id !== event.itemId); + this.cdr.detectChanges(); + } + })); + + this.loadProjects(); + this.loadUsers(); + this.loadStatuses(); + this.loadItems(); + void this.startRealtime(); + } + + ngOnDestroy(): void { + if (this.projectId > 0) { + this.realtime.leave(this.projectId); + } + + this.subscriptions.unsubscribe(); + } + + loadProjects(): void { + this.projectsClient.getProjects().subscribe({ + next: projects => { + this.project = (projects ?? []).find(project => project.id === this.projectId); + this.cdr.detectChanges(); + }, + error: () => this.error = 'Unable to load project.' + }); + } + + loadUsers(): void { + this.usersClient.getAssignableUsers().subscribe({ + next: users => this.users = users ?? [], + error: () => this.error = 'Unable to load assignable users.' + }); + } + + loadStatuses(): void { + this.statusesClient.getProjectTodoStatuses(this.projectId).subscribe({ + next: statuses => { + this.statuses = statuses ?? []; + const defaultStatus = this.statuses.find(s => s.isDefault) ?? this.statuses[0]; + this.newItem.statusId = defaultStatus?.id; + this.cdr.detectChanges(); + }, + error: () => this.error = 'Unable to load project statuses.' + }); + } + + loadItems(): void { + this.loading = true; + this.todoItemsClient.getProjectTodoItems(this.projectId).subscribe({ + next: items => { + this.items = items ?? []; + this.loading = false; + this.openHighlightedItem(); + this.cdr.detectChanges(); + }, + error: () => { + this.error = 'Unable to load project to-do items.'; + this.loading = false; + } + }); + } + + createItem(): void { + const title = this.newItem.title.trim(); + + if (!title) { + return; + } + + const command = { + projectId: this.projectId, + title, + description: this.newItem.description || undefined, + dueDate: this.newItem.dueDate || undefined, + assigneeUserId: this.newItem.assigneeUserId || undefined, + statusId: this.newItem.statusId + } as unknown as CreateProjectTodoItemCommand; + + this.todoItemsClient.createProjectTodoItem(this.projectId, command).subscribe({ + next: () => { + const defaultStatus = this.statuses.find(s => s.isDefault) ?? this.statuses[0]; + this.newItem = { title: '', description: '', dueDate: '', assigneeUserId: '', statusId: defaultStatus?.id }; + }, + error: () => this.error = 'Unable to create item.' + }); + } + + editItem(item: ProjectTodoItemDto): void { + this.editingItem = { ...item } as ProjectTodoItemDto; + this.editingItemDueDate = this.getDateOnlyValue(item.dueDate); + } + + cancelEdit(): void { + this.editingItem = undefined; + this.editingItemDueDate = ''; + } + + updateItem(): void { + if (!this.editingItem) { + return; + } + + const command = { + id: this.editingItem.id, + projectId: this.projectId, + title: this.editingItem.title, + description: this.editingItem.description, + dueDate: this.editingItemDueDate || undefined + } as unknown as UpdateProjectTodoItemCommand; + + this.todoItemsClient.updateProjectTodoItem(this.projectId, this.editingItem.id, command).subscribe({ + next: () => this.cancelEdit(), + error: () => this.error = 'Unable to update item.' + }); + } + + assignItem(item: ProjectTodoItemDto, assigneeUserId: string): void { + const command = new AssignProjectTodoItemCommand({ + id: item.id, + projectId: this.projectId, + assigneeUserId: assigneeUserId || undefined + }); + + this.todoItemsClient.assignProjectTodoItem(this.projectId, item.id, command).subscribe({ + error: () => this.error = 'Unable to assign item.' + }); + } + + changeStatus(item: ProjectTodoItemDto, statusId: number): void { + const command = new ChangeProjectTodoItemStatusCommand({ + id: item.id, + projectId: this.projectId, + statusId + }); + + this.todoItemsClient.changeProjectTodoItemStatus(this.projectId, item.id, command).subscribe({ + error: () => this.error = 'Unable to change status.' + }); + } + + deleteItem(item: ProjectTodoItemDto): void { + this.todoItemsClient.deleteProjectTodoItem(this.projectId, item.id).subscribe({ + error: () => this.error = 'Unable to delete item.' + }); + } + + trackById(_: number, item: ProjectTodoItemDto): number { + return item.id; + } + + private async startRealtime(): Promise { + try { + await this.realtime.start(this.projectId); + } catch (error) { + console.error('Unable to start project real-time updates.', error); + } + } + + formatDate(value: string | undefined): string { + return this.getDateOnlyValue(value); + } + + private getDateOnlyValue(value: string | undefined): string { + if (!value) { + return ''; + } + + return value; + } + + private upsertRealtimeItem(item: ProjectTodoItemDto): void { + if (item.projectId !== this.projectId) { + return; + } + + const existingIndex = this.items.findIndex(existing => existing.id === item.id); + this.items = existingIndex >= 0 + ? this.items.map(existing => existing.id === item.id ? item : existing) + : [...this.items, item]; + + this.cdr.detectChanges(); + } + + private openHighlightedItem(): void { + if (!this.highlightedItemId) { + return; + } + + const item = this.items.find(candidate => candidate.id === this.highlightedItemId); + + if (item) { + this.editItem(item); + } + } +} diff --git a/src/Web/ClientApp/src/app/projects/project-todo-realtime.service.ts b/src/Web/ClientApp/src/app/projects/project-todo-realtime.service.ts new file mode 100644 index 000000000..91b990765 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/project-todo-realtime.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import * as signalR from '@microsoft/signalr'; +import { Subject } from 'rxjs'; +import { ProjectTodoItemDto } from '../web-api-client'; + +export interface ProjectTodoDeletedEvent { + projectId: number; + itemId: number; +} + +@Injectable({ providedIn: 'root' }) +export class ProjectTodoRealtimeService { + private connection?: signalR.HubConnection; + + readonly itemCreated$ = new Subject(); + readonly itemUpdated$ = new Subject(); + readonly itemDeleted$ = new Subject(); + readonly notificationCreated$ = new Subject(); + + async start(projectId: number): Promise { + if (!this.connection) { + this.connection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/project-todos') + .withAutomaticReconnect() + .build(); + + this.connection.on('ProjectTodoItemCreated', item => this.itemCreated$.next(item)); + this.connection.on('ProjectTodoItemUpdated', item => this.itemUpdated$.next(item)); + this.connection.on('ProjectTodoItemDeleted', event => this.itemDeleted$.next(event)); + this.connection.on('UserNotificationCreated', notification => this.notificationCreated$.next(notification)); + + try { + await this.connection.start(); + } catch (error) { + this.connection = undefined; + throw error; + } + } + + if (this.connection.state === signalR.HubConnectionState.Connected) { + await this.connection.invoke('JoinProject', projectId); + } + } + + async leave(projectId: number): Promise { + if (this.connection?.state === signalR.HubConnectionState.Connected) { + await this.connection.invoke('LeaveProject', projectId); + } + } +} diff --git a/src/Web/ClientApp/src/app/projects/projects.component.html b/src/Web/ClientApp/src/app/projects/projects.component.html new file mode 100644 index 000000000..661bc204c --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/projects.component.html @@ -0,0 +1,45 @@ +
      + + + @if (error) { +

      {{ error }}

      + } + +
      +

      New project

      +
      + + +
      + +
      + + @if (loading) { +

      Loading projects...

      + } @else if (projects.length === 0) { +

      No projects yet. Create the first project to start managing to-do items.

      + } @else { +
      + @for (project of projects; track project.id) { +
      +

      {{ project.name }}

      + @if (project.description) { +

      {{ project.description }}

      + } +
      {{ project.todoItemCount }} item{{ project.todoItemCount === 1 ? '' : 's' }}
      +
      + } +
      + } +
      diff --git a/src/Web/ClientApp/src/app/projects/projects.component.scss b/src/Web/ClientApp/src/app/projects/projects.component.scss new file mode 100644 index 000000000..943db7fae --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/projects.component.scss @@ -0,0 +1,58 @@ +.projects-page { + padding-block: 1rem; +} + +.page-header { + margin-bottom: 1rem; +} + +.create-project-card { + margin-bottom: 1rem; +} + +.form-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); +} + +.project-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); +} + +.project-card { + cursor: pointer; +} + +.project-card:focus, +.project-card:hover { + border-color: var(--pico-primary); +} + +.error { + color: var(--pico-del-color); +} + +:host-context(html[data-theme="light"]) .projects-page button:not(.secondary):not(.outline) { + color: #e5e7eb; +} + +@media (prefers-color-scheme: light) { + :host-context(html:not([data-theme="dark"])) .projects-page button:not(.secondary):not(.outline) { + color: #e5e7eb; + } +} + +:host-context(html[data-theme="light"]) .project-card > h2, +:host-context(html[data-theme="light"]) .project-card > p { + color: #e5e7eb; +} + +@media (prefers-color-scheme: light) { + :host-context(html:not([data-theme="dark"])) .project-card > h2, + :host-context(html:not([data-theme="dark"])) .project-card > p { + color: #e5e7eb; + } +} diff --git a/src/Web/ClientApp/src/app/projects/projects.component.ts b/src/Web/ClientApp/src/app/projects/projects.component.ts new file mode 100644 index 000000000..07f04bc45 --- /dev/null +++ b/src/Web/ClientApp/src/app/projects/projects.component.ts @@ -0,0 +1,112 @@ +import { ChangeDetectorRef, Component, NgZone, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { CreateProjectCommand, ProjectDto, ProjectsClient } from '../web-api-client'; + +@Component({ + standalone: false, + selector: 'app-projects', + templateUrl: './projects.component.html', + styleUrls: ['./projects.component.scss'] +}) +export class ProjectsComponent implements OnInit { + projects: ProjectDto[] = []; + loading = true; + saving = false; + error = ''; + newProject = { name: '', description: '' }; + + constructor( + private readonly projectsClient: ProjectsClient, + private readonly router: Router, + private readonly zone: NgZone, + private readonly cdr: ChangeDetectorRef + ) { } + + ngOnInit(): void { + this.loadProjects(); + } + + loadProjects(): void { + this.loading = true; + this.error = ''; + this.cdr.detectChanges(); + + this.projectsClient.getProjects().subscribe({ + next: response => { + this.zone.run(() => { + this.projects = this.normalizeProjects(response); + this.loading = false; + this.cdr.detectChanges(); + }); + }, + error: error => { + this.zone.run(() => { + console.error('Unable to load projects.', error); + this.error = 'Unable to load projects.'; + this.loading = false; + this.cdr.detectChanges(); + }); + } + }); + } + + createProject(): void { + const name = this.newProject.name.trim(); + + if (!name) { + return; + } + + const command = new CreateProjectCommand({ + name, + description: this.newProject.description || undefined + }); + + this.saving = true; + this.error = ''; + this.cdr.detectChanges(); + + this.projectsClient.createProject(command).subscribe({ + next: id => { + this.zone.run(() => { + this.newProject = { name: '', description: '' }; + this.saving = false; + this.cdr.detectChanges(); + void this.router.navigate(['/projects', id]); + }); + }, + error: error => { + this.zone.run(() => { + console.error('Unable to create project.', error); + this.error = 'Unable to create project.'; + this.saving = false; + this.cdr.detectChanges(); + }); + } + }); + } + + openProject(project: ProjectDto): void { + this.router.navigate(['/projects', project.id]); + } + + private normalizeProjects(response: ProjectDto[] | ProjectDto | { items?: ProjectDto[] } | null | undefined): ProjectDto[] { + if (!response) { + return []; + } + + if (Array.isArray(response)) { + return response; + } + + if ('items' in response && Array.isArray(response.items)) { + return response.items; + } + + if (typeof response === 'object' && 'id' in response) { + return [response as ProjectDto]; + } + + return []; + } +} diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs index cbda534d0..334173038 100644 --- a/src/Web/DependencyInjection.cs +++ b/src/Web/DependencyInjection.cs @@ -2,7 +2,9 @@ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.Web.Services; +using CleanArchitecture.Web.Hubs; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; namespace Microsoft.Extensions.DependencyInjection; @@ -34,6 +36,9 @@ public static void AddWebServices(this IHostApplicationBuilder builder) }); builder.Services.AddCors(); + builder.Services.AddSignalR(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); } public static void AddKeyVaultIfConfigured(this IHostApplicationBuilder builder) diff --git a/src/Web/Endpoints/AssignableUsers.cs b/src/Web/Endpoints/AssignableUsers.cs new file mode 100644 index 000000000..29b313d92 --- /dev/null +++ b/src/Web/Endpoints/AssignableUsers.cs @@ -0,0 +1,23 @@ +using CleanArchitecture.Application.Users.Queries.GetAssignableUsers; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class AssignableUsers : IEndpointGroup +{ + public static string RoutePrefix => "/api/Users/Assignable"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetAssignableUsers); + } + + public static async Task>> GetAssignableUsers(ISender sender) + { + var result = await sender.Send(new GetAssignableUsersQuery()); + + return TypedResults.Ok(result); + } +} diff --git a/src/Web/Endpoints/EmailOutboxMessages.cs b/src/Web/Endpoints/EmailOutboxMessages.cs new file mode 100644 index 000000000..5b6e8af10 --- /dev/null +++ b/src/Web/Endpoints/EmailOutboxMessages.cs @@ -0,0 +1,21 @@ +using CleanArchitecture.Application.EmailOutbox.Queries.GetEmailOutboxMessages; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class EmailOutboxMessages : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetEmailOutboxMessages); + } + + public static async Task>> GetEmailOutboxMessages(ISender sender) + { + var result = await sender.Send(new GetEmailOutboxMessagesQuery()); + + return TypedResults.Ok(result); + } +} diff --git a/src/Web/Endpoints/Notifications.cs b/src/Web/Endpoints/Notifications.cs new file mode 100644 index 000000000..30199d68c --- /dev/null +++ b/src/Web/Endpoints/Notifications.cs @@ -0,0 +1,30 @@ +using CleanArchitecture.Application.Notifications.Commands.MarkNotificationRead; +using CleanArchitecture.Application.Notifications.Queries.GetNotifications; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class Notifications : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetNotifications); + groupBuilder.MapPut(MarkRead, "{id}/Read"); + } + + public static async Task>> GetNotifications(ISender sender) + { + var result = await sender.Send(new GetNotificationsQuery()); + + return TypedResults.Ok(result); + } + + public static async Task MarkRead(ISender sender, int id) + { + await sender.Send(new MarkNotificationReadCommand(id)); + + return TypedResults.NoContent(); + } +} diff --git a/src/Web/Endpoints/ProjectTodoItems.cs b/src/Web/Endpoints/ProjectTodoItems.cs new file mode 100644 index 000000000..660d5b582 --- /dev/null +++ b/src/Web/Endpoints/ProjectTodoItems.cs @@ -0,0 +1,97 @@ +using CleanArchitecture.Application.ProjectTodoItems.Commands.AssignProjectTodoItem; +using CleanArchitecture.Application.ProjectTodoItems.Commands.ChangeProjectTodoItemStatus; +using CleanArchitecture.Application.ProjectTodoItems.Commands.CreateProjectTodoItem; +using CleanArchitecture.Application.ProjectTodoItems.Commands.DeleteProjectTodoItem; +using CleanArchitecture.Application.ProjectTodoItems.Commands.UpdateProjectTodoItem; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectKanbanBoard; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class ProjectTodoItems : IEndpointGroup +{ + public static string RoutePrefix => "/api/Projects/{projectId}/TodoItems"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetProjectTodoItems); + groupBuilder.MapGet(GetProjectKanbanBoard, "/Kanban"); + groupBuilder.MapPost(CreateProjectTodoItem); + groupBuilder.MapPut(UpdateProjectTodoItem, "{id}"); + groupBuilder.MapPut(AssignProjectTodoItem, "{id}/Assignee"); + groupBuilder.MapPut(ChangeProjectTodoItemStatus, "{id}/Status"); + groupBuilder.MapDelete(DeleteProjectTodoItem, "{id}"); + } + + public static async Task>> GetProjectTodoItems(ISender sender, int projectId) + { + var result = await sender.Send(new GetProjectTodoItemsQuery(projectId)); + + return TypedResults.Ok(result); + } + + public static async Task> GetProjectKanbanBoard(ISender sender, int projectId) + { + var result = await sender.Send(new GetProjectKanbanBoardQuery(projectId)); + + return TypedResults.Ok(result); + } + + public static async Task, BadRequest>> CreateProjectTodoItem(ISender sender, int projectId, CreateProjectTodoItemCommand command) + { + if (projectId != command.ProjectId) + { + return TypedResults.BadRequest(); + } + + var id = await sender.Send(command); + + return TypedResults.Created($"/api/Projects/{projectId}/TodoItems/{id}", id); + } + + public static async Task> UpdateProjectTodoItem(ISender sender, int projectId, int id, UpdateProjectTodoItemCommand command) + { + if (projectId != command.ProjectId || id != command.Id) + { + return TypedResults.BadRequest(); + } + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + public static async Task> AssignProjectTodoItem(ISender sender, int projectId, int id, AssignProjectTodoItemCommand command) + { + if (projectId != command.ProjectId || id != command.Id) + { + return TypedResults.BadRequest(); + } + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + public static async Task> ChangeProjectTodoItemStatus(ISender sender, int projectId, int id, ChangeProjectTodoItemStatusCommand command) + { + if (projectId != command.ProjectId || id != command.Id) + { + return TypedResults.BadRequest(); + } + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + public static async Task DeleteProjectTodoItem(ISender sender, int projectId, int id) + { + await sender.Send(new DeleteProjectTodoItemCommand(projectId, id)); + + return TypedResults.NoContent(); + } +} diff --git a/src/Web/Endpoints/ProjectTodoStatuses.cs b/src/Web/Endpoints/ProjectTodoStatuses.cs new file mode 100644 index 000000000..078a6d0c4 --- /dev/null +++ b/src/Web/Endpoints/ProjectTodoStatuses.cs @@ -0,0 +1,23 @@ +using CleanArchitecture.Application.ProjectTodoStatuses.Queries.GetProjectTodoStatuses; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class ProjectTodoStatuses : IEndpointGroup +{ + public static string RoutePrefix => "/api/Projects/{projectId}/TodoStatuses"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetProjectTodoStatuses); + } + + public static async Task>> GetProjectTodoStatuses(ISender sender, int projectId) + { + var result = await sender.Send(new GetProjectTodoStatusesQuery(projectId)); + + return TypedResults.Ok(result); + } +} diff --git a/src/Web/Endpoints/Projects.cs b/src/Web/Endpoints/Projects.cs new file mode 100644 index 000000000..8a956f497 --- /dev/null +++ b/src/Web/Endpoints/Projects.cs @@ -0,0 +1,30 @@ +using CleanArchitecture.Application.Projects.Commands.CreateProject; +using CleanArchitecture.Application.Projects.Queries.GetProjects; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class Projects : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetProjects); + groupBuilder.MapPost(CreateProject); + } + + public static async Task>> GetProjects(ISender sender) + { + var result = await sender.Send(new GetProjectsQuery()); + + return TypedResults.Ok(result); + } + + public static async Task> CreateProject(ISender sender, CreateProjectCommand command) + { + var id = await sender.Send(command); + + return TypedResults.Created($"/api/Projects/{id}", id); + } +} diff --git a/src/Web/Hubs/ProjectTodoHub.cs b/src/Web/Hubs/ProjectTodoHub.cs new file mode 100644 index 000000000..266b206cd --- /dev/null +++ b/src/Web/Hubs/ProjectTodoHub.cs @@ -0,0 +1,53 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Notifications.Queries.GetNotifications; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectTodoItems; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace CleanArchitecture.Web.Hubs; + +[Authorize] +public class ProjectTodoHub : Hub +{ + public Task JoinProject(int projectId) + { + return Groups.AddToGroupAsync(Context.ConnectionId, ProjectGroup(projectId)); + } + + public Task LeaveProject(int projectId) + { + return Groups.RemoveFromGroupAsync(Context.ConnectionId, ProjectGroup(projectId)); + } + + public static string ProjectGroup(int projectId) => $"project-{projectId}"; +} + +public class ProjectTodoRealtimeNotifier : IProjectTodoRealtimeNotifier +{ + private readonly IHubContext _hubContext; + + public ProjectTodoRealtimeNotifier(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public Task ProjectTodoItemCreatedAsync(ProjectTodoItemDto item, CancellationToken cancellationToken) + { + return _hubContext.Clients.Group(ProjectTodoHub.ProjectGroup(item.ProjectId)).SendAsync("ProjectTodoItemCreated", item, cancellationToken); + } + + public Task ProjectTodoItemUpdatedAsync(ProjectTodoItemDto item, CancellationToken cancellationToken) + { + return _hubContext.Clients.Group(ProjectTodoHub.ProjectGroup(item.ProjectId)).SendAsync("ProjectTodoItemUpdated", item, cancellationToken); + } + + public Task ProjectTodoItemDeletedAsync(int projectId, int itemId, CancellationToken cancellationToken) + { + return _hubContext.Clients.Group(ProjectTodoHub.ProjectGroup(projectId)).SendAsync("ProjectTodoItemDeleted", new { projectId, itemId }, cancellationToken); + } + + public Task UserNotificationCreatedAsync(NotificationDto notification, CancellationToken cancellationToken) + { + return _hubContext.Clients.User(notification.UserId).SendAsync("UserNotificationCreated", notification, cancellationToken); + } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 889e3141a..bc0ad20ad 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,4 +1,5 @@ -using CleanArchitecture.Infrastructure.Data; +using CleanArchitecture.Infrastructure.Data; +using CleanArchitecture.Web.Hubs; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -41,7 +42,8 @@ app.Map("/", () => Results.Redirect("/scalar")); #endif -app.MapDefaultEndpoints(); +app.MapDefaultEndpoints(); +app.MapHub("/hubs/project-todos"); app.MapEndpoints(typeof(Program).Assembly); #if (!UseApiOnly) diff --git a/src/Web/Services/SignalRUserIdProvider.cs b/src/Web/Services/SignalRUserIdProvider.cs new file mode 100644 index 000000000..a61483988 --- /dev/null +++ b/src/Web/Services/SignalRUserIdProvider.cs @@ -0,0 +1,12 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; + +namespace CleanArchitecture.Web.Services; + +public sealed class SignalRUserIdProvider : IUserIdProvider +{ + public string? GetUserId(HubConnectionContext connection) + { + return connection.User?.FindFirstValue(ClaimTypes.NameIdentifier); + } +} diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index 1e59df0a5..191c78411 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -3,6 +3,7 @@ CleanArchitecture.Web CleanArchitecture.Web + true ClientApp\ ./wwwroot/openapi/ diff --git a/src/app/projects/kanban-board/kanban-board.component.html b/src/app/projects/kanban-board/kanban-board.component.html new file mode 100644 index 000000000..e7e54fd2d --- /dev/null +++ b/src/app/projects/kanban-board/kanban-board.component.html @@ -0,0 +1,36 @@ +
      + @for (status of statuses; track status.id) { +
      +
      +

      {{ status.name }}

      + {{ itemsForStatus(status.id).length }} +
      + +
      + @for (item of itemsForStatus(status.id); track item.id) { +
      +
      {{ item.title }}
      + @if (item.description) { +

      {{ item.description }}

      + } + + @if (item.assigneeUserName) { + Assigned to {{ item.assigneeUserName }} + } @else { + Unassigned + } + @if (item.dueDate) { + · Due {{ formatDate(item.dueDate) }} + } + + +
      + } + + @if (itemsForStatus(status.id).length === 0) { +
      Drop items here
      + } +
      +
      + } +
      diff --git a/src/app/projects/kanban-board/kanban-board.component.ts b/src/app/projects/kanban-board/kanban-board.component.ts new file mode 100644 index 000000000..2851f95b6 --- /dev/null +++ b/src/app/projects/kanban-board/kanban-board.component.ts @@ -0,0 +1,88 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ProjectTodoItemDto, ProjectTodoStatusDto } from '../../web-api-client'; + +@Component({ + standalone: false, + selector: 'app-project-kanban-board', + templateUrl: './kanban-board.component.html', + styleUrls: ['./kanban-board.component.scss'] +}) +export class KanbanBoardComponent { + @Input() statuses: ProjectTodoStatusDto[] = []; + @Input() items: ProjectTodoItemDto[] = []; + @Output() statusChanged = new EventEmitter<{ item: ProjectTodoItemDto; statusId: number }>(); + @Output() editRequested = new EventEmitter(); + + draggedItem?: ProjectTodoItemDto; + dragOverStatusId?: number; + + itemsForStatus(statusId: number): ProjectTodoItemDto[] { + return this.items.filter(item => item.statusId === statusId); + } + + onDragStart(item: ProjectTodoItemDto): void { + this.draggedItem = item; + } + + onDragEnd(): void { + this.draggedItem = undefined; + this.dragOverStatusId = undefined; + } + + onDragOver(statusId: number, event: DragEvent): void { + event.preventDefault(); + this.dragOverStatusId = statusId; + } + + onDrop(statusId: number, event: DragEvent): void { + event.preventDefault(); + + if (this.draggedItem && this.draggedItem.statusId !== statusId) { + this.statusChanged.emit({ item: this.draggedItem, statusId }); + } + + this.onDragEnd(); + } + + formatDate(value: Date | string | undefined): string { + return this.toDateInputValue(value); + } + + isOverdue(item: ProjectTodoItemDto): boolean { + if (!item.dueDate || item.statusName === 'Done') { + return false; + } + + const dueDateValue = this.toDateInputValue(item.dueDate); + + if (!dueDateValue) { + return false; + } + + const dueDate = new Date(`${dueDateValue}T00:00:00.000Z`); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + return dueDate < today; + } + + private toDateInputValue(value: Date | string | undefined): string { + if (!value) { + return ''; + } + + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) + ? '' + : parsed.toISOString().slice(0, 10); + } +} diff --git a/src/app/projects/project-detail/project-detail.component.html b/src/app/projects/project-detail/project-detail.component.html new file mode 100644 index 000000000..37f58f9e6 --- /dev/null +++ b/src/app/projects/project-detail/project-detail.component.html @@ -0,0 +1,119 @@ +
      + + + @if (error) { +

      {{ error }}

      + } + +
      +

      {{ editingItem ? 'Edit item' : 'Create item' }}

      + @if (editingItem) { +
      + + + +
      +
      + + +
      + } @else { +
      + + + + + +
      + + } +
      + + @if (loading) { +

      Loading project items...

      + } @else if (activeView === 'list') { +
      +

      List view

      +
      + + + + + + + + + + + + + + @for (item of items; track trackById($index, item)) { + + + + + + + + + + } + +
      TitleDescriptionDueAssigneeReporterStatus
      {{ item.title }}{{ item.description }}{{ formatDate(item.dueDate) }} + + {{ item.reporterUserName || item.reporterUserId }} + + + + +
      +
      +
      + } @else { +
      +

      Kanban board

      + +
      + } +
      diff --git a/src/app/projects/project-detail/project-detail.component.ts b/src/app/projects/project-detail/project-detail.component.ts new file mode 100644 index 000000000..21f90f301 --- /dev/null +++ b/src/app/projects/project-detail/project-detail.component.ts @@ -0,0 +1,283 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { + AssignableUserDto, + AssignableUsersClient, + AssignProjectTodoItemCommand, + ChangeProjectTodoItemStatusCommand, + CreateProjectTodoItemCommand, + ProjectDto, + ProjectTodoItemDto, + ProjectTodoItemsClient, + ProjectTodoStatusDto, + ProjectTodoStatusesClient, + ProjectsClient, + UpdateProjectTodoItemCommand +} from '../../web-api-client'; +import { ProjectTodoRealtimeService } from '../project-todo-realtime.service'; + +@Component({ + standalone: false, + selector: 'app-project-detail', + templateUrl: './project-detail.component.html', + styleUrls: ['./project-detail.component.scss'] +}) +export class ProjectDetailComponent implements OnInit, OnDestroy { + projectId = 0; + highlightedItemId?: number; + project?: ProjectDto; + statuses: ProjectTodoStatusDto[] = []; + users: AssignableUserDto[] = []; + items: ProjectTodoItemDto[] = []; + activeView: 'list' | 'board' = 'list'; + loading = true; + error = ''; + newItem = { title: '', description: '', dueDate: '', assigneeUserId: '', statusId: undefined as number | undefined }; + editingItem?: ProjectTodoItemDto; + editingItemDueDate = ''; + private readonly subscriptions = new Subscription(); + + constructor( + private readonly route: ActivatedRoute, + private readonly projectsClient: ProjectsClient, + private readonly todoItemsClient: ProjectTodoItemsClient, + private readonly statusesClient: ProjectTodoStatusesClient, + private readonly usersClient: AssignableUsersClient, + private readonly realtime: ProjectTodoRealtimeService, + private readonly cdr: ChangeDetectorRef + ) { } + + async ngOnInit(): Promise { + this.projectId = Number(this.route.snapshot.paramMap.get('projectId') ?? this.route.snapshot.paramMap.get('id')); + const itemId = Number(this.route.snapshot.paramMap.get('todoItemId')); + this.highlightedItemId = Number.isFinite(itemId) && itemId > 0 ? itemId : undefined; + + this.subscriptions.add(this.realtime.itemCreated$.subscribe(item => this.upsertRealtimeItem(item))); + this.subscriptions.add(this.realtime.itemUpdated$.subscribe(item => this.upsertRealtimeItem(item))); + this.subscriptions.add(this.realtime.itemDeleted$.subscribe(event => { + if (event.projectId === this.projectId) { + this.items = this.items.filter(i => i.id !== event.itemId); + this.cdr.detectChanges(); + } + })); + + this.loadProjects(); + this.loadUsers(); + this.loadStatuses(); + this.loadItems(); + void this.startRealtime(); + } + + ngOnDestroy(): void { + if (this.projectId > 0) { + this.realtime.leave(this.projectId); + } + + this.subscriptions.unsubscribe(); + } + + loadProjects(): void { + this.projectsClient.getProjects().subscribe({ + next: projects => { + this.project = (projects ?? []).find(project => project.id === this.projectId); + this.cdr.detectChanges(); + }, + error: () => this.error = 'Unable to load project.' + }); + } + + loadUsers(): void { + this.usersClient.getAssignableUsers().subscribe({ + next: users => this.users = users ?? [], + error: () => this.error = 'Unable to load assignable users.' + }); + } + + loadStatuses(): void { + this.statusesClient.getProjectTodoStatuses(this.projectId).subscribe({ + next: statuses => { + this.statuses = statuses ?? []; + const defaultStatus = this.statuses.find(s => s.isDefault) ?? this.statuses[0]; + this.newItem.statusId = defaultStatus?.id; + this.cdr.detectChanges(); + }, + error: () => this.error = 'Unable to load project statuses.' + }); + } + + loadItems(): void { + this.loading = true; + this.todoItemsClient.getProjectTodoItems(this.projectId).subscribe({ + next: items => { + this.items = items ?? []; + this.loading = false; + this.openHighlightedItem(); + this.cdr.detectChanges(); + }, + error: () => { + this.error = 'Unable to load project to-do items.'; + this.loading = false; + } + }); + } + + createItem(): void { + const title = this.newItem.title.trim(); + + if (!title) { + return; + } + + const command = new CreateProjectTodoItemCommand({ + projectId: this.projectId, + title, + description: this.newItem.description || undefined, + dueDate: this.toDate(this.newItem.dueDate), + assigneeUserId: this.newItem.assigneeUserId || undefined, + statusId: this.newItem.statusId + }); + + this.todoItemsClient.createProjectTodoItem(this.projectId, command).subscribe({ + next: () => { + const defaultStatus = this.statuses.find(s => s.isDefault) ?? this.statuses[0]; + this.newItem = { title: '', description: '', dueDate: '', assigneeUserId: '', statusId: defaultStatus?.id }; + }, + error: () => this.error = 'Unable to create item.' + }); + } + + editItem(item: ProjectTodoItemDto): void { + this.editingItem = { ...item } as ProjectTodoItemDto; + this.editingItemDueDate = this.toDateInputValue(item.dueDate); + } + + cancelEdit(): void { + this.editingItem = undefined; + this.editingItemDueDate = ''; + } + + updateItem(): void { + if (!this.editingItem) { + return; + } + + const command = new UpdateProjectTodoItemCommand({ + id: this.editingItem.id, + projectId: this.projectId, + title: this.editingItem.title, + description: this.editingItem.description, + dueDate: this.toDate(this.editingItemDueDate) + }); + + this.todoItemsClient.updateProjectTodoItem(this.projectId, this.editingItem.id, command).subscribe({ + next: () => this.cancelEdit(), + error: () => this.error = 'Unable to update item.' + }); + } + + assignItem(item: ProjectTodoItemDto, assigneeUserId: string): void { + const command = new AssignProjectTodoItemCommand({ + id: item.id, + projectId: this.projectId, + assigneeUserId: assigneeUserId || undefined + }); + + this.todoItemsClient.assignProjectTodoItem(this.projectId, item.id, command).subscribe({ + error: () => this.error = 'Unable to assign item.' + }); + } + + changeStatus(item: ProjectTodoItemDto, statusId: number): void { + const command = new ChangeProjectTodoItemStatusCommand({ + id: item.id, + projectId: this.projectId, + statusId + }); + + this.todoItemsClient.changeProjectTodoItemStatus(this.projectId, item.id, command).subscribe({ + error: () => this.error = 'Unable to change status.' + }); + } + + deleteItem(item: ProjectTodoItemDto): void { + this.todoItemsClient.deleteProjectTodoItem(this.projectId, item.id).subscribe({ + error: () => this.error = 'Unable to delete item.' + }); + } + + trackById(_: number, item: ProjectTodoItemDto): number { + return item.id; + } + + private async startRealtime(): Promise { + try { + await this.realtime.start(this.projectId); + } catch (error) { + console.error('Unable to start project real-time updates.', error); + } + } + + formatDate(value: Date | string | undefined): string { + return this.toDateInputValue(value); + } + + private toDate(value: Date | string | undefined): Date | undefined { + if (!value) { + return undefined; + } + + if (value instanceof Date) { + return value; + } + + return /^\d{4}-\d{2}-\d{2}$/.test(value) + ? new Date(`${value}T00:00:00.000Z`) + : new Date(value); + } + + private toDateInputValue(value: Date | string | undefined): string { + if (!value) { + return ''; + } + + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) + ? '' + : parsed.toISOString().slice(0, 10); + } + + private upsertRealtimeItem(item: ProjectTodoItemDto): void { + if (item.projectId !== this.projectId) { + return; + } + + const existingIndex = this.items.findIndex(existing => existing.id === item.id); + this.items = existingIndex >= 0 + ? this.items.map(existing => existing.id === item.id ? item : existing) + : [...this.items, item]; + + this.cdr.detectChanges(); + } + + private openHighlightedItem(): void { + if (!this.highlightedItemId) { + return; + } + + const item = this.items.find(candidate => candidate.id === this.highlightedItemId); + + if (item) { + this.editItem(item); + } + } +} diff --git a/tests/Application.FunctionalTests/FunctionalTestSetup.cs b/tests/Application.FunctionalTests/FunctionalTestSetup.cs index 10df583ef..9173bb336 100644 --- a/tests/Application.FunctionalTests/FunctionalTestSetup.cs +++ b/tests/Application.FunctionalTests/FunctionalTestSetup.cs @@ -18,7 +18,7 @@ public async Task OneTimeSetUp() var cancellationToken = cts.Token; var builder = await DistributedApplicationTestingBuilder - .CreateAsync( + .CreateAsync( args: [], configureBuilder: (options, _) => { diff --git a/tests/Application.FunctionalTests/ProjectTodoItems/Commands/AssignProjectTodoItemTests.cs b/tests/Application.FunctionalTests/ProjectTodoItems/Commands/AssignProjectTodoItemTests.cs new file mode 100644 index 000000000..8e370b929 --- /dev/null +++ b/tests/Application.FunctionalTests/ProjectTodoItems/Commands/AssignProjectTodoItemTests.cs @@ -0,0 +1,124 @@ +using CleanArchitecture.Application.Projects.Commands.CreateProject; +using CleanArchitecture.Application.ProjectTodoItems.Commands.AssignProjectTodoItem; +using CleanArchitecture.Application.ProjectTodoItems.Commands.CreateProjectTodoItem; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.FunctionalTests.ProjectTodoItems.Commands; + +public class AssignProjectTodoItemTests : TestBase +{ + [Test] + public async Task ShouldAssignItemAndCreateNotification() + { + await TestApp.RunAsDefaultUserAsync(); + var assigneeUserId = await TestApp.RunAsUserAsync("assignee@test.local", "Testing1234!", []); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand { ProjectId = projectId, Title = "Task" }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = assigneeUserId + }); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.AssigneeUserId.ShouldBe(assigneeUserId); + (await TestApp.CountAsync()).ShouldBe(1); + (await TestApp.CountAsync()).ShouldBe(1); + } + + [Test] + public async Task ShouldNotCreateDuplicateNotificationWhenAssigneeIsUnchanged() + { + await TestApp.RunAsDefaultUserAsync(); + var assigneeUserId = await TestApp.RunAsUserAsync("assignee@test.local", "Testing1234!", []); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand { ProjectId = projectId, Title = "Task" }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = assigneeUserId + }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = assigneeUserId + }); + + (await TestApp.CountAsync()).ShouldBe(1); + (await TestApp.CountAsync()).ShouldBe(1); + } + + [Test] + public async Task ShouldQueueAssignmentAndUnassignmentEmailsWhenAssigneeChanges() + { + await TestApp.RunAsDefaultUserAsync(); + var firstAssigneeUserId = await TestApp.RunAsUserAsync("first.assignee@test.local", "Testing1234!", []); + var secondAssigneeUserId = await TestApp.RunAsUserAsync("second.assignee@test.local", "Testing1234!", []); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand { ProjectId = projectId, Title = "Task" }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = firstAssigneeUserId + }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = secondAssigneeUserId + }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = null + }); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.AssigneeUserId.ShouldBeNull(); + (await TestApp.CountAsync()).ShouldBe(2); + (await TestApp.CountAsync()).ShouldBe(4); + } + + [Test] + public async Task ShouldNotCreateNotificationWhenUnassigned() + { + await TestApp.RunAsDefaultUserAsync(); + var assigneeUserId = await TestApp.RunAsUserAsync("assignee@test.local", "Testing1234!", []); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand + { + ProjectId = projectId, + Title = "Task", + AssigneeUserId = assigneeUserId + }); + + await TestApp.SendAsync(new AssignProjectTodoItemCommand + { + ProjectId = projectId, + Id = itemId, + AssigneeUserId = null + }); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.AssigneeUserId.ShouldBeNull(); + (await TestApp.CountAsync()).ShouldBe(1); + (await TestApp.CountAsync()).ShouldBe(2); + } +} diff --git a/tests/Application.FunctionalTests/ProjectTodoItems/Commands/ChangeProjectTodoItemStatusTests.cs b/tests/Application.FunctionalTests/ProjectTodoItems/Commands/ChangeProjectTodoItemStatusTests.cs new file mode 100644 index 000000000..2120287c1 --- /dev/null +++ b/tests/Application.FunctionalTests/ProjectTodoItems/Commands/ChangeProjectTodoItemStatusTests.cs @@ -0,0 +1,53 @@ +using CleanArchitecture.Application.Projects.Commands.CreateProject; +using CleanArchitecture.Application.ProjectTodoItems.Commands.ChangeProjectTodoItemStatus; +using CleanArchitecture.Application.ProjectTodoItems.Commands.CreateProjectTodoItem; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.FunctionalTests.ProjectTodoItems.Commands; + +public class ChangeProjectTodoItemStatusTests : TestBase +{ + [Test] + public async Task ShouldChangeStatusAndQueueReporterEmail() + { + await TestApp.RunAsDefaultUserAsync(); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand { ProjectId = projectId, Title = "Task" }); + + var statuses = await TestApp.SendAsync(new CleanArchitecture.Application.ProjectTodoStatuses.Queries.GetProjectTodoStatuses.GetProjectTodoStatusesQuery(projectId)); + var targetStatus = statuses.First(s => s.Name == "In Progress"); + + await TestApp.SendAsync(new ChangeProjectTodoItemStatusCommand + { + ProjectId = projectId, + Id = itemId, + StatusId = targetStatus.Id + }); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.StatusId.ShouldBe(targetStatus.Id); + (await TestApp.CountAsync()).ShouldBe(1); + } + + [Test] + public async Task ShouldNotQueueReporterEmailWhenStatusIsUnchanged() + { + await TestApp.RunAsDefaultUserAsync(); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand { ProjectId = projectId, Title = "Task" }); + + var statuses = await TestApp.SendAsync(new CleanArchitecture.Application.ProjectTodoStatuses.Queries.GetProjectTodoStatuses.GetProjectTodoStatusesQuery(projectId)); + var defaultStatus = statuses.First(s => s.IsDefault); + + await TestApp.SendAsync(new ChangeProjectTodoItemStatusCommand + { + ProjectId = projectId, + Id = itemId, + StatusId = defaultStatus.Id + }); + + (await TestApp.CountAsync()).ShouldBe(0); + } +} diff --git a/tests/Application.FunctionalTests/ProjectTodoItems/Commands/CreateProjectTodoItemTests.cs b/tests/Application.FunctionalTests/ProjectTodoItems/Commands/CreateProjectTodoItemTests.cs new file mode 100644 index 000000000..5c148a479 --- /dev/null +++ b/tests/Application.FunctionalTests/ProjectTodoItems/Commands/CreateProjectTodoItemTests.cs @@ -0,0 +1,63 @@ +using CleanArchitecture.Application.Common.Exceptions; +using CleanArchitecture.Application.Projects.Commands.CreateProject; +using CleanArchitecture.Application.ProjectTodoItems.Commands.CreateProjectTodoItem; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.FunctionalTests.ProjectTodoItems.Commands; + +public class CreateProjectTodoItemTests : TestBase +{ + [Test] + public async Task ShouldRequireAuthenticatedReporter() + { + var command = new CreateProjectTodoItemCommand + { + ProjectId = 1, + Title = "Task" + }; + + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldCreateItemWithCurrentUserAsReporter() + { + var userId = await TestApp.RunAsDefaultUserAsync(); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + var dueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(1)); + + var itemId = await TestApp.SendAsync(new CreateProjectTodoItemCommand + { + ProjectId = projectId, + Title = "Prepare review", + Description = "Review the implementation", + DueDate = dueDate.ToString("yyyy-MM-dd") + }); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.ProjectId.ShouldBe(projectId); + item.Title.ShouldBe("Prepare review"); + item.DueDate.ShouldBe(dueDate); + item.ReporterUserId.ShouldBe(userId); + item.StatusId.ShouldBeGreaterThan(0); + } + [Test] + public async Task ShouldQueueAssignmentEmailWhenCreatedWithAssignee() + { + await TestApp.RunAsDefaultUserAsync(); + var assigneeUserId = await TestApp.RunAsUserAsync("assignee.create@test.local", "Testing1234!", []); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + + await TestApp.SendAsync(new CreateProjectTodoItemCommand + { + ProjectId = projectId, + Title = "Prepare review", + AssigneeUserId = assigneeUserId + }); + + (await TestApp.CountAsync()).ShouldBe(1); + (await TestApp.CountAsync()).ShouldBe(1); + } +} diff --git a/tests/Application.FunctionalTests/ProjectTodoItems/Queries/GetProjectKanbanBoardTests.cs b/tests/Application.FunctionalTests/ProjectTodoItems/Queries/GetProjectKanbanBoardTests.cs new file mode 100644 index 000000000..b7b4b00f8 --- /dev/null +++ b/tests/Application.FunctionalTests/ProjectTodoItems/Queries/GetProjectKanbanBoardTests.cs @@ -0,0 +1,22 @@ +using CleanArchitecture.Application.Projects.Commands.CreateProject; +using CleanArchitecture.Application.ProjectTodoItems.Commands.CreateProjectTodoItem; +using CleanArchitecture.Application.ProjectTodoItems.Queries.GetProjectKanbanBoard; + +namespace CleanArchitecture.Application.FunctionalTests.ProjectTodoItems.Queries; + +public class GetProjectKanbanBoardTests : TestBase +{ + [Test] + public async Task ShouldGroupItemsByExtensibleStatuses() + { + await TestApp.RunAsDefaultUserAsync(); + var projectId = await TestApp.SendAsync(new CreateProjectCommand { Name = "Project" }); + await TestApp.SendAsync(new CreateProjectTodoItemCommand { ProjectId = projectId, Title = "Task" }); + + var board = await TestApp.SendAsync(new GetProjectKanbanBoardQuery(projectId)); + + board.ProjectId.ShouldBe(projectId); + board.Columns.Select(c => c.StatusName).ShouldBe(new[] { "To Do", "In Progress", "Done" }); + board.Columns.Sum(c => c.Items.Count).ShouldBe(1); + } +} diff --git a/tests/Domain.UnitTests/ProjectManagement/ProjectTodoItemTests.cs b/tests/Domain.UnitTests/ProjectManagement/ProjectTodoItemTests.cs new file mode 100644 index 000000000..a85515913 --- /dev/null +++ b/tests/Domain.UnitTests/ProjectManagement/ProjectTodoItemTests.cs @@ -0,0 +1,70 @@ +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Events; +using NUnit.Framework; +using Shouldly; +using System.Linq; + +namespace CleanArchitecture.Domain.UnitTests.ProjectManagement; + +public class ProjectTodoItemTests +{ + [Test] + public void AssignToShouldSetAssigneeAndRaiseEvent() + { + var item = new ProjectTodoItem { Title = "Task", ReporterUserId = "reporter", StatusId = 1 }; + + var changed = item.AssignTo("assignee"); + + changed.ShouldBeTrue(); + item.AssigneeUserId.ShouldBe("assignee"); + item.DomainEvents.OfType().ShouldHaveSingleItem(); + } + + [Test] + public void AssignToShouldNotRaiseEventWhenAssigneeIsUnchanged() + { + var item = new ProjectTodoItem { Title = "Task", AssigneeUserId = "assignee", ReporterUserId = "reporter", StatusId = 1 }; + + var changed = item.AssignTo("assignee"); + + changed.ShouldBeFalse(); + item.AssigneeUserId.ShouldBe("assignee"); + item.DomainEvents.ShouldBeEmpty(); + } + + [Test] + public void AssignToShouldNotRaiseAssignmentEventWhenUnassigned() + { + var item = new ProjectTodoItem { Title = "Task", AssigneeUserId = "assignee", ReporterUserId = "reporter", StatusId = 1 }; + + var changed = item.AssignTo(null); + + changed.ShouldBeTrue(); + item.AssigneeUserId.ShouldBeNull(); + item.DomainEvents.ShouldBeEmpty(); + } + + [Test] + public void ChangeStatusShouldSetStatusAndRaiseEvent() + { + var item = new ProjectTodoItem { Title = "Task", ReporterUserId = "reporter", StatusId = 1 }; + + var changed = item.ChangeStatus(2); + + changed.ShouldBeTrue(); + item.StatusId.ShouldBe(2); + item.DomainEvents.OfType().ShouldHaveSingleItem(); + } + + [Test] + public void ChangeStatusShouldNotRaiseEventWhenStatusIsUnchanged() + { + var item = new ProjectTodoItem { Title = "Task", ReporterUserId = "reporter", StatusId = 1 }; + + var changed = item.ChangeStatus(1); + + changed.ShouldBeFalse(); + item.StatusId.ShouldBe(1); + item.DomainEvents.ShouldBeEmpty(); + } +} diff --git a/tests/Web.AcceptanceTests/Features/ProjectManagement.feature b/tests/Web.AcceptanceTests/Features/ProjectManagement.feature new file mode 100644 index 000000000..0fb937259 --- /dev/null +++ b/tests/Web.AcceptanceTests/Features/ProjectManagement.feature @@ -0,0 +1,36 @@ +@ProjectManagement +Feature: Project management + Users can manage project to-do items through the Angular Project Management UI. + +Scenario: Projects page displays the seeded project + Given an authenticated user visits the projects page + Then the projects heading is "Projects" + And the seeded demo project is displayed + +Scenario: User can create and open a project + Given an authenticated user visits the projects page + When the user creates a unique project + Then the new project is displayed in the projects list + When the user opens the new project + Then the project detail page is displayed + +Scenario: User can create, edit, assign, and move a project to-do item + Given an authenticated user opens a new project for project management + When the user creates a project to-do item + Then the item is displayed in the list view + When the user edits the project to-do item description + Then the edited project to-do item description is displayed + When the user assigns the item to the current user + Then the assignment remains selected for the item + When the user changes the item status to "In Progress" + And the user opens the Kanban view + Then the item is displayed in the "In Progress" Kanban column + +Scenario: Assignment notifications and status change email outbox are demonstrable + Given an authenticated user opens a new project for project management + When the user creates a project to-do item + And the user assigns the item to the current user + And the user changes the item status to "In Progress" + And the user opens the notifications page + Then an assignment notification for the item is displayed + And a status change email for the item is displayed diff --git a/tests/Web.AcceptanceTests/Pages/BasePage.cs b/tests/Web.AcceptanceTests/Pages/BasePage.cs index 77ed43e00..be6906d10 100644 --- a/tests/Web.AcceptanceTests/Pages/BasePage.cs +++ b/tests/Web.AcceptanceTests/Pages/BasePage.cs @@ -8,5 +8,10 @@ public abstract class BasePage(IPage page) protected IPage Page { get; } = page; - public Task GotoAsync() => Page.GotoAsync(PagePath); + public Task GotoAsync() + => Page.GotoAsync(PagePath, new PageGotoOptions + { + WaitUntil = WaitUntilState.DOMContentLoaded, + Timeout = 60_000 + }); } diff --git a/tests/Web.AcceptanceTests/Pages/ProjectsPage.cs b/tests/Web.AcceptanceTests/Pages/ProjectsPage.cs new file mode 100644 index 000000000..64a4d3773 --- /dev/null +++ b/tests/Web.AcceptanceTests/Pages/ProjectsPage.cs @@ -0,0 +1,170 @@ +namespace CleanArchitecture.Web.AcceptanceTests.Pages; + +public class ProjectsPage(IPage page) : BasePage(page) +{ + private const string CurrentUserName = "administrator@localhost"; + + public override string PagePath => $"{BaseUrl}/projects"; + + public async Task AssertProjectsHeading(string text) + { + await Assertions.Expect(Page.Locator("h1")).ToHaveTextAsync(text); + await WaitForProjectsLoadAsync(); + } + + public async Task AssertSeededDemoProjectVisible() + { + await WaitForProjectsLoadAsync(); + await Assertions.Expect(ProjectCard("Demo Project")).ToBeVisibleAsync(); + } + + public async Task CreateProjectAsync(string name, string description) + { + await WaitForProjectsLoadAsync(); + await Page.GetByPlaceholder("Project name").FillAsync(name); + await Page.GetByPlaceholder("Optional description").FillAsync(description); + await Page.GetByRole(AriaRole.Button, new() { Name = "Create project" }).ClickAsync(); + await Assertions.Expect(ProjectDetailHeading(name)).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 }); + await WaitForProjectItemsLoadAsync(); + } + + public async Task OpenProjectAsync(string name) + { + if (await ProjectDetailHeading(name).IsVisibleAsync()) + { + await WaitForProjectItemsLoadAsync(); + return; + } + + await GotoAsync(); + await WaitForProjectsLoadAsync(); + await ProjectCard(name).ClickAsync(); + await Assertions.Expect(ProjectDetailHeading(name)).ToBeVisibleAsync(); + await WaitForProjectItemsLoadAsync(); + } + + public async Task AssertProjectVisibleInListAsync(string name) + { + await GotoAsync(); + await WaitForProjectsLoadAsync(); + await Assertions.Expect(ProjectCard(name)).ToBeVisibleAsync(); + } + + public Task AssertProjectDetailVisibleAsync(string name) + => Assertions.Expect(ProjectDetailHeading(name)).ToBeVisibleAsync(); + + public async Task CreateItemAsync(string title, string description, string dueDate) + { + await WaitForProjectItemsLoadAsync(); + await Page.GetByPlaceholder("Task title").FillAsync(title); + await Page.GetByPlaceholder("Describe the task, requirements, acceptance notes, or implementation details").FillAsync(description); + await Page.Locator(".item-editor-card input[type='date']").FillAsync(dueDate); + await Page.GetByRole(AriaRole.Button, new() { Name = "Create item" }).ClickAsync(); + await AssertItemVisibleInListAsync(title); + } + + public Task AssertItemVisibleInListAsync(string title) + => Assertions.Expect(ItemRow(title)).ToBeVisibleAsync(); + + public async Task EditItemDescriptionAsync(string title, string description) + { + await ItemRow(title).GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Edit item" })).ToBeVisibleAsync(); + await Page.Locator(".item-editor-card textarea").FillAsync(description); + await Page.GetByRole(AriaRole.Button, new() { Name = "Save item" }).ClickAsync(); + await Assertions.Expect(ItemRow(title).GetByText(description)).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 }); + } + + public Task AssertItemDescriptionVisibleAsync(string title, string description) + => Assertions.Expect(ItemRow(title).GetByText(description)).ToBeVisibleAsync(); + + public async Task AssignItemToCurrentUserAsync(string title) + { + var assigneeSelect = AssigneeSelect(title); + await assigneeSelect.SelectOptionAsync(new[] { new SelectOptionValue { Label = CurrentUserName } }); + await Assertions.Expect(assigneeSelect.Locator("option:checked")).ToHaveTextAsync(CurrentUserName, new LocatorAssertionsToHaveTextOptions { Timeout = 15_000 }); + } + + public Task AssertItemAssignedToCurrentUserAsync(string title) + => Assertions.Expect(AssigneeSelect(title).Locator("option:checked")).ToHaveTextAsync(CurrentUserName); + + public async Task ChangeItemStatusAsync(string title, string statusName) + { + var statusSelect = StatusSelect(title); + await statusSelect.SelectOptionAsync(new[] { new SelectOptionValue { Label = statusName } }); + await Assertions.Expect(statusSelect.Locator("option:checked")).ToHaveTextAsync(statusName, new LocatorAssertionsToHaveTextOptions { Timeout = 15_000 }); + } + + public async Task OpenKanbanViewAsync() + { + await Page.GetByRole(AriaRole.Button, new() { Name = "Kanban" }).ClickAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Kanban board" })).ToBeVisibleAsync(); + } + + public Task AssertItemVisibleInKanbanColumnAsync(string title, string statusName) + => Assertions.Expect(Page.Locator($"xpath=//section[contains(@class,'kanban-column')][.//h3[normalize-space()={XPathLiteral(statusName)}]]//*[contains(@class,'kanban-card-title') and normalize-space()={XPathLiteral(title)}]")).ToBeVisibleAsync(); + + public async Task OpenNotificationsAsync() + { + await Page.GotoAsync($"{BaseUrl}/notifications", new PageGotoOptions + { + WaitUntil = WaitUntilState.DOMContentLoaded, + Timeout = 60_000 + }); + } + + public async Task AssertAssignmentNotificationVisibleAsync(string title) + { + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Notifications", Exact = true })).ToBeVisibleAsync(); + await Assertions.Expect(AssignmentNotificationsSection()).ToBeVisibleAsync(); + await Assertions.Expect(AssignmentNotificationsSection().Locator($"xpath=.//p[normalize-space()={XPathLiteral($"You have been assigned to '{title}'.")}]")).ToBeVisibleAsync(); + } + + public async Task AssertStatusChangeEmailVisibleAsync(string title, string statusName) + { + await Assertions.Expect(DevelopmentEmailOutboxSection()).ToBeVisibleAsync(); + await Assertions.Expect(DevelopmentEmailOutboxSection().GetByText($"The status for '{title}' changed to '{statusName}'.", new() { Exact = false })).ToBeVisibleAsync(); + } + + private ILocator ProjectCard(string name) + => Page.Locator("article.project-card", new() { HasTextString = name }); + + private ILocator AssignmentNotificationsSection() + => Page.Locator("xpath=//article[./header/h2[normalize-space()='Assignment notifications']]"); + + private ILocator DevelopmentEmailOutboxSection() + => Page.Locator("xpath=//article[./header/h2[normalize-space()='Development email outbox']]"); + + private ILocator ProjectDetailHeading(string name) + => Page.GetByRole(AriaRole.Heading, new() { Name = name }); + + private ILocator ItemRow(string title) + => Page.Locator("tbody tr", new() { HasTextString = title }); + + private ILocator AssigneeSelect(string title) + => ItemRow(title).Locator("td.select-cell select").Nth(0); + + private ILocator StatusSelect(string title) + => ItemRow(title).Locator("td.select-cell select").Nth(1); + + private Task WaitForProjectsLoadAsync() + => Assertions.Expect(Page.GetByText("Loading projects...")).ToBeHiddenAsync(new LocatorAssertionsToBeHiddenOptions { Timeout = 15_000 }); + + private Task WaitForProjectItemsLoadAsync() + => Assertions.Expect(Page.GetByText("Loading project items...")).ToBeHiddenAsync(new LocatorAssertionsToBeHiddenOptions { Timeout = 15_000 }); + + private static string XPathLiteral(string value) + { + if (!value.Contains('\'')) + { + return $"'{value}'"; + } + + if (!value.Contains('"')) + { + return $"\"{value}\""; + } + + return "concat('" + value.Replace("'", "', \"'\", '") + "')"; + } +} diff --git a/tests/Web.AcceptanceTests/PlaywrightSetup.cs b/tests/Web.AcceptanceTests/PlaywrightSetup.cs index 99b0bc602..7e9191573 100644 --- a/tests/Web.AcceptanceTests/PlaywrightSetup.cs +++ b/tests/Web.AcceptanceTests/PlaywrightSetup.cs @@ -1,4 +1,6 @@ +using System.ComponentModel; using System.Diagnostics; +using System.Text; namespace CleanArchitecture.Web.AcceptanceTests; @@ -16,18 +18,137 @@ public async Task OneTimeSetUp() Assertions.SetDefaultExpectTimeout(10_000); _playwright = await Playwright.CreateAsync(); + Browser = await LaunchChromiumAsync(); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (Browser is not null) + { + await Browser.CloseAsync(); + } + + _playwright?.Dispose(); + } + + private static async Task LaunchChromiumAsync() + { + try + { + return await _playwright!.Chromium.LaunchAsync(CreateLaunchOptions()); + } + catch (PlaywrightException ex) when (IsMissingBrowserExecutable(ex)) + { + InstallPlaywrightBrowsers(); + return await _playwright!.Chromium.LaunchAsync(CreateLaunchOptions()); + } + } - Browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + private static BrowserTypeLaunchOptions CreateLaunchOptions() + { + return new BrowserTypeLaunchOptions { Headless = IsHeadless, SlowMo = IsHeadless ? 0 : 500 - }); + }; } - [OneTimeTearDown] - public async Task OneTimeTearDown() + private static bool IsMissingBrowserExecutable(PlaywrightException exception) { - await Browser.CloseAsync(); - _playwright?.Dispose(); + return exception.Message.Contains("Executable doesn't exist", StringComparison.OrdinalIgnoreCase) + || exception.Message.Contains("playwright.ps1 install", StringComparison.OrdinalIgnoreCase) + || exception.Message.Contains("Please run the following command to download new browsers", StringComparison.OrdinalIgnoreCase); + } + + private static void InstallPlaywrightBrowsers() + { + var scriptPath = FindPlaywrightScript(); + + if (scriptPath is null) + { + Assert.Fail($"Playwright browser executable was not found, and the Playwright install script could not be located from '{AppContext.BaseDirectory}'. Build the Web.AcceptanceTests project and run the generated playwright.ps1 install command before running acceptance tests."); + return; + } + + var installResult = TryRunPowerShellInstall(scriptPath); + + if (installResult.ExitCode != 0) + { + Assert.Fail($"Playwright browser installation failed with exit code {installResult.ExitCode}.{Environment.NewLine}{installResult.Output}"); + } } + + private static string? FindPlaywrightScript() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + var scriptPath = Path.Combine(directory.FullName, "playwright.ps1"); + + if (File.Exists(scriptPath)) + { + return scriptPath; + } + + directory = directory.Parent; + } + + return null; + } + + private static ProcessResult TryRunPowerShellInstall(string scriptPath) + { + var errors = new StringBuilder(); + + foreach (var shell in new[] { "pwsh", "powershell" }) + { + try + { + return RunPowerShellInstall(shell, scriptPath); + } + catch (Win32Exception ex) + { + errors.AppendLine($"Unable to start '{shell}': {ex.Message}"); + } + } + + return new ProcessResult(1, errors.ToString()); + } + + private static ProcessResult RunPowerShellInstall(string shell, string scriptPath) + { + using var process = new Process(); + + process.StartInfo = new ProcessStartInfo + { + FileName = shell, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + process.StartInfo.ArgumentList.Add("-NoProfile"); + process.StartInfo.ArgumentList.Add("-ExecutionPolicy"); + process.StartInfo.ArgumentList.Add("Bypass"); + process.StartInfo.ArgumentList.Add("-File"); + process.StartInfo.ArgumentList.Add(scriptPath); + process.StartInfo.ArgumentList.Add("install"); + process.StartInfo.ArgumentList.Add("chromium"); + + process.Start(); + + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + return new ProcessResult( + process.ExitCode, + string.Join(Environment.NewLine, new[] { standardOutput, standardError }.Where(s => !string.IsNullOrWhiteSpace(s)))); + } + + private sealed record ProcessResult(int ExitCode, string Output); } diff --git a/tests/Web.AcceptanceTests/StepDefinitions/ProjectManagementStepDefinitions.cs b/tests/Web.AcceptanceTests/StepDefinitions/ProjectManagementStepDefinitions.cs new file mode 100644 index 000000000..38a61295b --- /dev/null +++ b/tests/Web.AcceptanceTests/StepDefinitions/ProjectManagementStepDefinitions.cs @@ -0,0 +1,129 @@ +namespace CleanArchitecture.Web.AcceptanceTests.StepDefinitions; + +[Binding] +public sealed class ProjectManagementStepDefinitions(ProjectsPage projectsPage) +{ + private string _projectName = string.Empty; + private string _projectDescription = string.Empty; + private string _itemTitle = string.Empty; + private string _itemDescription = string.Empty; + private string _editedItemDescription = string.Empty; + + [BeforeFeature("ProjectManagement")] + public static async Task BeforeProjectManagementFeature(IObjectContainer container) + { + var context = await PlaywrightSetup.Browser.NewContextAsync(); + var page = await context.NewPageAsync(); + + var loginPage = new LoginPage(page); + await loginPage.GotoAsync(); + await loginPage.SetEmail("administrator@localhost"); + await loginPage.SetPassword("Administrator1!"); + await loginPage.ClickLogin(); + await Assertions.Expect(page.Locator("a:has-text('Log out')")).ToBeVisibleAsync(); + + container.RegisterInstanceAs(context); + container.RegisterInstanceAs(new ProjectsPage(page)); + } + + [AfterFeature("ProjectManagement")] + public static async Task AfterProjectManagementFeature(IObjectContainer container) + { + var context = container.Resolve(); + await context.DisposeAsync(); + } + + [Given("an authenticated user visits the projects page")] + public Task GivenAnAuthenticatedUserVisitsTheProjectsPage() => projectsPage.GotoAsync(); + + [Given("an authenticated user opens a new project for project management")] + public async Task GivenAnAuthenticatedUserOpensANewProjectForProjectManagement() + { + await projectsPage.GotoAsync(); + GenerateProjectData(); + await projectsPage.CreateProjectAsync(_projectName, _projectDescription); + await projectsPage.OpenProjectAsync(_projectName); + } + + [Then("the projects heading is {string}")] + public Task ThenTheProjectsHeadingIs(string heading) => projectsPage.AssertProjectsHeading(heading); + + [Then("the seeded demo project is displayed")] + public Task ThenTheSeededDemoProjectIsDisplayed() => projectsPage.AssertSeededDemoProjectVisible(); + + [When("the user creates a unique project")] + public async Task WhenTheUserCreatesAUniqueProject() + { + GenerateProjectData(); + await projectsPage.CreateProjectAsync(_projectName, _projectDescription); + } + + [Then("the new project is displayed in the projects list")] + public Task ThenTheNewProjectIsDisplayedInTheProjectsList() => projectsPage.AssertProjectVisibleInListAsync(_projectName); + + [When("the user opens the new project")] + public Task WhenTheUserOpensTheNewProject() => projectsPage.OpenProjectAsync(_projectName); + + [Then("the project detail page is displayed")] + public Task ThenTheProjectDetailPageIsDisplayed() => projectsPage.AssertProjectDetailVisibleAsync(_projectName); + + [When("the user creates a project to-do item")] + public async Task WhenTheUserCreatesAProjectToDoItem() + { + GenerateItemData(); + await projectsPage.CreateItemAsync(_itemTitle, _itemDescription, DateOnly.FromDateTime(DateTime.UtcNow.Date.AddDays(3)).ToString("yyyy-MM-dd")); + } + + [Then("the item is displayed in the list view")] + public Task ThenTheItemIsDisplayedInTheListView() => projectsPage.AssertItemVisibleInListAsync(_itemTitle); + + [When("the user edits the project to-do item description")] + public async Task WhenTheUserEditsTheProjectToDoItemDescription() + { + _editedItemDescription = $"Updated acceptance description {UniqueSuffix()}"; + await projectsPage.EditItemDescriptionAsync(_itemTitle, _editedItemDescription); + } + + [Then("the edited project to-do item description is displayed")] + public Task ThenTheEditedProjectToDoItemDescriptionIsDisplayed() => projectsPage.AssertItemDescriptionVisibleAsync(_itemTitle, _editedItemDescription); + + [When("the user assigns the item to the current user")] + public Task WhenTheUserAssignsTheItemToTheCurrentUser() => projectsPage.AssignItemToCurrentUserAsync(_itemTitle); + + [Then("the assignment remains selected for the item")] + public Task ThenTheAssignmentRemainsSelectedForTheItem() => projectsPage.AssertItemAssignedToCurrentUserAsync(_itemTitle); + + [When("the user changes the item status to {string}")] + public Task WhenTheUserChangesTheItemStatusTo(string statusName) => projectsPage.ChangeItemStatusAsync(_itemTitle, statusName); + + [When("the user opens the Kanban view")] + public Task WhenTheUserOpensTheKanbanView() => projectsPage.OpenKanbanViewAsync(); + + [Then("the item is displayed in the {string} Kanban column")] + public Task ThenTheItemIsDisplayedInTheKanbanColumn(string statusName) => projectsPage.AssertItemVisibleInKanbanColumnAsync(_itemTitle, statusName); + + [When("the user opens the notifications page")] + public Task WhenTheUserOpensTheNotificationsPage() => projectsPage.OpenNotificationsAsync(); + + [Then("an assignment notification for the item is displayed")] + public Task ThenAnAssignmentNotificationForTheItemIsDisplayed() => projectsPage.AssertAssignmentNotificationVisibleAsync(_itemTitle); + + [Then("a status change email for the item is displayed")] + public Task ThenAStatusChangeEmailForTheItemIsDisplayed() => projectsPage.AssertStatusChangeEmailVisibleAsync(_itemTitle, "In Progress"); + + private void GenerateProjectData() + { + var suffix = UniqueSuffix(); + _projectName = $"Acceptance Project {suffix}"; + _projectDescription = $"Project created by Web.AcceptanceTests {suffix}"; + } + + private void GenerateItemData() + { + var suffix = UniqueSuffix(); + _itemTitle = $"Acceptance item {suffix}"; + _itemDescription = $"Initial acceptance description {suffix}"; + } + + private static string UniqueSuffix() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); +}