From 36b5f4dc9eee18e70b6bfe7ef0c904de26195221 Mon Sep 17 00:00:00 2001 From: Navaneeth T Date: Mon, 9 Nov 2026 21:25:07 +0530 Subject: [PATCH 01/18] feat: add employee listing and details --- .../Controllers/EmployeesController.cs | 45 +++++++ .../DTOs/Employees/EmployeeResponse.cs | 23 ++++ .../DependencyInjection.cs | 1 + .../Interfaces/Identity/IIdentityService.cs | 8 ++ .../Interfaces/Identity/LoginInfo.cs | 4 + .../Repositories/IEmployeeRepository.cs | 9 ++ .../Interfaces/Services/IEmployeeService.cs | 13 ++ .../Services/EmployeeService.cs | 80 ++++++++++++ .../Identity/IdentityService.cs | 32 +++++ .../Repositories/EmployeeRepository.cs | 18 +++ .../Controllers/EmployeesControllerTests.cs | 123 ++++++++++++++++++ 11 files changed, 356 insertions(+) create mode 100644 src/DistroFlow.Api/Controllers/EmployeesController.cs create mode 100644 src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs create mode 100644 src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs create mode 100644 src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs create mode 100644 src/DistroFlow.Application/Services/EmployeeService.cs create mode 100644 tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs diff --git a/src/DistroFlow.Api/Controllers/EmployeesController.cs b/src/DistroFlow.Api/Controllers/EmployeesController.cs new file mode 100644 index 0000000..e0aa93f --- /dev/null +++ b/src/DistroFlow.Api/Controllers/EmployeesController.cs @@ -0,0 +1,45 @@ +using DistroFlow.Application.DTOs.Common; +using DistroFlow.Application.DTOs.Employees; +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Domain.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DistroFlow.Api.Controllers; + +/// Employee management for the caller's own distributor. Administrators only. +[ApiController] +[Authorize(Roles = EmployeeRoles.Administrator)] +[Route("api/v1/employees")] +public class EmployeesController : ControllerBase +{ + private readonly IEmployeeService _employeeService; + + public EmployeesController(IEmployeeService employeeService) + { + _employeeService = employeeService; + } + + [HttpGet] + public async Task List(CancellationToken cancellationToken) + { + var employees = await _employeeService.ListAsync(cancellationToken); + + return Ok(ApiResponse>.SuccessResponse( + employees, "Employees retrieved successfully.")); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id, CancellationToken cancellationToken) + { + var employee = await _employeeService.GetByIdAsync(id, cancellationToken); + + if (employee is null) + { + return NotFound(ApiResponse.FailureResponse("Employee not found.")); + } + + return Ok(ApiResponse.SuccessResponse( + employee, "Employee retrieved successfully.")); + } +} diff --git a/src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs b/src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs new file mode 100644 index 0000000..112346e --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs @@ -0,0 +1,23 @@ +namespace DistroFlow.Application.DTOs.Employees; + +public class EmployeeResponse +{ + public Guid Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + /// Login email. Null once the employee is anonymised (login deleted). + public string? Email { get; set; } + + public string? Phone { get; set; } + + public string? EmployeeCode { get; set; } + + public string Status { get; set; } = string.Empty; + + public IReadOnlyList Roles { get; set; } = []; + + public DateTime CreatedAtUtc { get; set; } + + public DateTime? UpdatedAtUtc { get; set; } +} diff --git a/src/DistroFlow.Application/DependencyInjection.cs b/src/DistroFlow.Application/DependencyInjection.cs index 7cae76e..2482b08 100644 --- a/src/DistroFlow.Application/DependencyInjection.cs +++ b/src/DistroFlow.Application/DependencyInjection.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services { services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs index e1ef274..2f4a76f 100644 --- a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs +++ b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs @@ -25,4 +25,12 @@ Task CheckCredentialsAsync( string email, string password, CancellationToken cancellationToken = default); + + /// + /// Email and roles for many logins in a fixed number of queries (not one per employee). + /// Callers must pass ids of employees already loaded through the tenant filter. + /// + Task> GetLoginInfoAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default); } diff --git a/src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs b/src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs new file mode 100644 index 0000000..818fef4 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs @@ -0,0 +1,4 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +/// The parts of an employee that live on their login account. +public sealed record LoginInfo(string? Email, IReadOnlyList Roles); diff --git a/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs b/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs index 4c905dd..7769851 100644 --- a/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs +++ b/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs @@ -6,6 +6,15 @@ public interface IEmployeeRepository { void Add(Employee employee); + /// Employees of the current tenant (tenant filter applies), ordered by name. + Task> ListAsync( + CancellationToken cancellationToken = default); + + /// An employee of the current tenant, or null (also null for another tenant's id). + Task GetByIdReadOnlyAsync( + Guid id, + CancellationToken cancellationToken = default); + /// /// Login only: finds the employee for an authenticated login across all tenants /// (no tenant is known before login). Do not use for tenant-facing queries. diff --git a/src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs b/src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs new file mode 100644 index 0000000..a50d148 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs @@ -0,0 +1,13 @@ +using DistroFlow.Application.DTOs.Employees; + +namespace DistroFlow.Application.Interfaces.Services; + +public interface IEmployeeService +{ + Task> ListAsync( + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default); +} diff --git a/src/DistroFlow.Application/Services/EmployeeService.cs b/src/DistroFlow.Application/Services/EmployeeService.cs new file mode 100644 index 0000000..3b78874 --- /dev/null +++ b/src/DistroFlow.Application/Services/EmployeeService.cs @@ -0,0 +1,80 @@ +using DistroFlow.Application.DTOs.Employees; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Repositories; +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Domain.Entities; + +namespace DistroFlow.Application.Services; + +public class EmployeeService : IEmployeeService +{ + private readonly IEmployeeRepository _employeeRepository; + private readonly IIdentityService _identityService; + + public EmployeeService(IEmployeeRepository employeeRepository, IIdentityService identityService) + { + _employeeRepository = employeeRepository; + _identityService = identityService; + } + + public async Task> ListAsync( + CancellationToken cancellationToken = default) + { + // Tenant filter applies: only the caller's distributor's employees come back. + var employees = await _employeeRepository.ListAsync(cancellationToken); + + var logins = await LoadLoginsAsync(employees, cancellationToken); + + return employees.Select(e => MapToResponse(e, logins)).ToList(); + } + + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + // Another distributor's id returns null too, so the API answers 404, never 403: + // it must not even reveal that the id exists. + var employee = await _employeeRepository.GetByIdReadOnlyAsync(id, cancellationToken); + if (employee is null) + return null; + + var logins = await LoadLoginsAsync([employee], cancellationToken); + + return MapToResponse(employee, logins); + } + + private Task> LoadLoginsAsync( + IReadOnlyCollection employees, + CancellationToken cancellationToken) + { + var userIds = employees + .Where(e => e.UserId is not null) + .Select(e => e.UserId!.Value) + .ToList(); + + return _identityService.GetLoginInfoAsync(userIds, cancellationToken); + } + + private static EmployeeResponse MapToResponse( + Employee employee, + IReadOnlyDictionary logins) + { + // Anonymised employees have no login, so no email or roles. + LoginInfo? login = null; + if (employee.UserId is { } userId) + logins.TryGetValue(userId, out login); + + return new EmployeeResponse + { + Id = employee.Id, + FullName = employee.FullName, + Email = login?.Email, + Phone = employee.Phone, + EmployeeCode = employee.EmployeeCode, + Status = employee.Status.ToString(), + Roles = login?.Roles ?? [], + CreatedAtUtc = employee.CreatedAtUtc, + UpdatedAtUtc = employee.UpdatedAtUtc + }; + } +} diff --git a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs index 47397ec..edea523 100644 --- a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs +++ b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs @@ -124,6 +124,38 @@ public async Task CheckCredentialsAsync( return CredentialCheckResult.Success(user.Id, user.TenantId, roles.ToList()); } + public async Task> GetLoginInfoAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default) + { + if (userIds.Count == 0) + { + return new Dictionary(); + } + + // Two queries in total, however many employees: emails, then all their role names. + var emails = await _dbContext.Users + .AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .Select(u => new { u.Id, u.Email }) + .ToListAsync(cancellationToken); + + var roles = await ( + from userRole in _dbContext.UserRoles + join role in _dbContext.Roles on userRole.RoleId equals role.Id + where userIds.Contains(userRole.UserId) + select new { userRole.UserId, role.Name }) + .ToListAsync(cancellationToken); + + var rolesByUser = roles + .GroupBy(r => r.UserId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(r => r.Name!).Order().ToList()); + + return emails.ToDictionary( + u => u.Id, + u => new LoginInfo(u.Email, rolesByUser.GetValueOrDefault(u.Id, []))); + } + private static bool IsActiveEmailViolation(DbUpdateException ex) => ex.InnerException is PostgresException { diff --git a/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs b/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs index b86c499..a940e3d 100644 --- a/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs +++ b/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs @@ -19,6 +19,24 @@ public void Add(Employee employee) _dbContext.Employees.Add(employee); } + public async Task> ListAsync( + CancellationToken cancellationToken = default) + { + return await _dbContext.Employees + .AsNoTracking() + .OrderBy(e => e.FullName) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdReadOnlyAsync( + Guid id, + CancellationToken cancellationToken = default) + { + return await _dbContext.Employees + .AsNoTracking() + .SingleOrDefaultAsync(e => e.Id == id, cancellationToken); + } + public async Task GetForLoginByUserIdAsync( Guid userId, CancellationToken cancellationToken = default) diff --git a/tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs new file mode 100644 index 0000000..776e4a2 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +public class EmployeesControllerTests : IClassFixture +{ + private const string EmployeesUrl = "/api/v1/employees"; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public EmployeesControllerTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task List_returns_only_own_employees_with_email_and_roles() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var anita = await AddEmployeeAsync(a.TenantId, "Anita Rao", EmployeeRoles.SalesStaff); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).GetAsync(EmployeesUrl); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var list = (await response.Content.ReadFromJsonAsync()).GetProperty("data") + .EnumerateArray().ToList(); + + // Ordered by name: "Anita Rao" before "Ravi Sharma" (the owner). + Assert.Equal([anita.EmployeeId, a.EmployeeId], list.Select(e => e.GetProperty("id").GetGuid())); + Assert.DoesNotContain(list, e => e.GetProperty("id").GetGuid() == b.EmployeeId); + + var owner = list[1]; + Assert.Equal(a.Email, owner.GetProperty("email").GetString()); + Assert.Equal([EmployeeRoles.Administrator], RolesOf(owner)); + Assert.Equal("Active", owner.GetProperty("status").GetString()); + + Assert.Equal(anita.Email, list[0].GetProperty("email").GetString()); + Assert.Equal([EmployeeRoles.SalesStaff], RolesOf(list[0])); + } + + [Fact] + public async Task Get_by_id_returns_employee_details() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var anita = await AddEmployeeAsync(a.TenantId, "Anita Rao", EmployeeRoles.DispatchStaff); + + var response = await AsAdmin(a).GetAsync($"{EmployeesUrl}/{anita.EmployeeId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Anita Rao", data.GetProperty("fullName").GetString()); + Assert.Equal(anita.Email, data.GetProperty("email").GetString()); + Assert.Equal([EmployeeRoles.DispatchStaff], RolesOf(data)); + } + + [Fact] + public async Task Get_another_distributors_employee_returns_404_not_403() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).GetAsync($"{EmployeesUrl}/{b.EmployeeId}"); + + // 404, not 403: A must not even learn that B's employee id exists. + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Non_administrator_cannot_list_employees() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var salesToken = TestAuth.IssueToken(_factory, a.TenantId, EmployeeRoles.SalesStaff); + + var response = await TestAuth.CreateClientWithToken(_factory, salesToken).GetAsync(EmployeesUrl); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task List_without_token_returns_401() + { + var response = await _anonymous.GetAsync(EmployeesUrl); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private HttpClient AsAdmin(RegisteredDistributor distributor) => + TestAuth.CreateClientWithToken(_factory, distributor.AccessToken); + + private static string[] RolesOf(JsonElement employee) => + employee.GetProperty("roles").EnumerateArray().Select(r => r.GetString()!).ToArray(); + + /// + /// Test setup until the invite endpoint exists: creates a login and an employee directly. + /// + private async Task<(Guid EmployeeId, string Email)> AddEmployeeAsync(Guid tenantId, string fullName, string role) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var identity = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var email = $"staff-{Guid.NewGuid():N}@example.com"; + var login = await identity.CreateUserAsync(tenantId, email, TestAuth.Password, role); + Assert.True(login.Succeeded); + + var employee = new Employee { TenantId = tenantId, UserId = login.UserId, FullName = fullName }; + db.Employees.Add(employee); + await db.SaveChangesAsync(); + + return (employee.Id, email); + } +} From 452b72cbab23505bf481a1ea516dabee5f7a07b5 Mon Sep 17 00:00:00 2001 From: Navaneeth T Date: Mon, 9 Nov 2026 21:40:31 +0530 Subject: [PATCH 02/18] feat: add email sending with Mailpit for local development --- compose.yaml | 10 +++ .../appsettings.Development.json | 5 ++ src/DistroFlow.Api/appsettings.json | 6 ++ .../Interfaces/Email/IEmailSender.cs | 18 +++++ .../DependencyInjection.cs | 13 ++++ .../DistroFlow.Infrastructure.csproj | 1 + .../Email/SmtpEmailSender.cs | 56 ++++++++++++++ .../Email/SmtpOptions.cs | 33 ++++++++ .../Email/SmtpEmailSenderTests.cs | 76 +++++++++++++++++++ 9 files changed, 218 insertions(+) create mode 100644 src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs create mode 100644 src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs create mode 100644 src/DistroFlow.Infrastructure/Email/SmtpOptions.cs create mode 100644 tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs diff --git a/compose.yaml b/compose.yaml index 3eb9804..f38d0c1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,5 +13,15 @@ services: volumes: - distroflow_pgdata:/var/lib/postgresql + # Local email catcher: the API sends to localhost:1025; read the emails at http://localhost:8025. + # It never delivers to real inboxes, so it is for development only. + mailpit: + image: axllent/mailpit:v1.33.1 + container_name: distroflow-mailpit + restart: unless-stopped + ports: + - "127.0.0.1:1025:1025" # SMTP + - "127.0.0.1:8025:8025" # web UI + volumes: distroflow_pgdata: diff --git a/src/DistroFlow.Api/appsettings.Development.json b/src/DistroFlow.Api/appsettings.Development.json index 843b77b..bf44e20 100644 --- a/src/DistroFlow.Api/appsettings.Development.json +++ b/src/DistroFlow.Api/appsettings.Development.json @@ -7,5 +7,10 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5433;Database=distroflow_dev;Username=distroflow" + }, + "Smtp": { + "Host": "localhost", + "Port": 1025, + "UseTls": false } } diff --git a/src/DistroFlow.Api/appsettings.json b/src/DistroFlow.Api/appsettings.json index f856aa3..db00565 100644 --- a/src/DistroFlow.Api/appsettings.json +++ b/src/DistroFlow.Api/appsettings.json @@ -10,5 +10,11 @@ "Issuer": "DistroFlow.Api", "Audience": "DistroFlow.Web", "AccessTokenMinutes": 30 + }, + "Smtp": { + "Port": 587, + "UseTls": true, + "FromAddress": "no-reply@distroflow.local", + "FromName": "DistroFlow" } } diff --git a/src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs b/src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs new file mode 100644 index 0000000..b34d644 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs @@ -0,0 +1,18 @@ +namespace DistroFlow.Application.Interfaces.Email; + +/// +/// Sends an email. The Application layer only says *what* to send; how (SMTP, which +/// provider) is an Infrastructure and configuration decision. +/// +public interface IEmailSender +{ + /// Throws if the message could not be handed to the mail server. + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); +} + +/// Plain-text version, for mail clients that do not show HTML. +public sealed record EmailMessage( + string To, + string Subject, + string HtmlBody, + string TextBody); diff --git a/src/DistroFlow.Infrastructure/DependencyInjection.cs b/src/DistroFlow.Infrastructure/DependencyInjection.cs index c2dad46..ec3caf4 100644 --- a/src/DistroFlow.Infrastructure/DependencyInjection.cs +++ b/src/DistroFlow.Infrastructure/DependencyInjection.cs @@ -1,4 +1,6 @@ +using DistroFlow.Application.Interfaces.Email; using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Infrastructure.Email; using DistroFlow.Application.Interfaces.Persistence; using DistroFlow.Application.Interfaces.Repositories; using DistroFlow.Infrastructure.Identity; @@ -23,10 +25,21 @@ public static IServiceCollection AddInfrastructure( services.AddPersistence(configuration); services.AddIdentityServices(); services.AddTokenIssuing(configuration); + services.AddEmail(configuration); return services; } + private static void AddEmail(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(SmtpOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddScoped(); + } + private static void AddPersistence(this IServiceCollection services, IConfiguration configuration) { var connectionString = configuration.GetConnectionString("DefaultConnection") diff --git a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj index 9587d82..b317ce3 100644 --- a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj +++ b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs b/src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs new file mode 100644 index 0000000..2ad6651 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs @@ -0,0 +1,56 @@ +using DistroFlow.Application.Interfaces.Email; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace DistroFlow.Infrastructure.Email; + +/// Sends email over SMTP with MailKit (works with Mailpit locally and any SMTP provider). +public class SmtpEmailSender : IEmailSender +{ + private readonly SmtpOptions _options; + + public SmtpEmailSender(IOptions options) + { + _options = options.Value; + } + + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + var mime = new MimeMessage(); + mime.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress)); + mime.To.Add(MailboxAddress.Parse(message.To)); + mime.Subject = message.Subject; + mime.Body = new BodyBuilder + { + HtmlBody = message.HtmlBody, + TextBody = message.TextBody + }.ToMessageBody(); + + // A new connection per email keeps this simple; fine for low volume (invites). + using var client = new SmtpClient(); + + await client.ConnectAsync(_options.Host, _options.Port, SocketOptions(), cancellationToken); + + if (!string.IsNullOrEmpty(_options.Username)) + { + await client.AuthenticateAsync(_options.Username, _options.Password ?? string.Empty, cancellationToken); + } + + await client.SendAsync(mime, cancellationToken); + await client.DisconnectAsync(quit: true, cancellationToken); + } + + /// + /// TLS is required when enabled (never silently downgraded to plain text, which would + /// expose the SMTP password). Port 465 uses TLS from the start; others upgrade via STARTTLS. + /// + private SecureSocketOptions SocketOptions() + { + if (!_options.UseTls) + return SecureSocketOptions.None; + + return _options.Port == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls; + } +} diff --git a/src/DistroFlow.Infrastructure/Email/SmtpOptions.cs b/src/DistroFlow.Infrastructure/Email/SmtpOptions.cs new file mode 100644 index 0000000..3209662 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Email/SmtpOptions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Infrastructure.Email; + +/// +/// Bound from the "Smtp" configuration section. +/// Development: Mailpit on localhost:1025 (no TLS, no login), view mail at http://localhost:8025. +/// Production: any SMTP provider; set Username/Password via user-secrets or env vars (Smtp__Password). +/// +public sealed class SmtpOptions +{ + public const string SectionName = "Smtp"; + + [Required] + public string Host { get; init; } = string.Empty; + + [Range(1, 65535)] + public int Port { get; init; } = 587; + + /// Use STARTTLS/TLS. Off only for a local catcher such as Mailpit. + public bool UseTls { get; init; } = true; + + public string? Username { get; init; } + + public string? Password { get; init; } + + [Required] + [EmailAddress] + public string FromAddress { get; init; } = string.Empty; + + [Required] + public string FromName { get; init; } = string.Empty; +} diff --git a/tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs b/tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs new file mode 100644 index 0000000..35a1037 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs @@ -0,0 +1,76 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Email; +using DistroFlow.Infrastructure.Email; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Microsoft.Extensions.Options; + +namespace DistroFlow.IntegrationTests.Email; + +/// +/// Sends real SMTP mail to a Mailpit container and reads it back through Mailpit's API. +/// +public class SmtpEmailSenderTests : IAsyncLifetime +{ + private const int SmtpPort = 1025; + private const int ApiPort = 8025; + + private readonly IContainer _mailpit = new ContainerBuilder("axllent/mailpit:v1.33.1") + .WithPortBinding(SmtpPort, assignRandomHostPort: true) + .WithPortBinding(ApiPort, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(ApiPort).ForPath("/api/v1/info"))) + .Build(); + + public Task InitializeAsync() => _mailpit.StartAsync(); + + public Task DisposeAsync() => _mailpit.DisposeAsync().AsTask(); + + [Fact] + public async Task Sends_html_and_text_email_to_the_smtp_server() + { + var sender = CreateSender(useTls: false); + + await sender.SendAsync(new EmailMessage( + To: "anita@example.com", + Subject: "You're invited to DistroFlow", + HtmlBody: "

Click here

", + TextBody: "Open https://example.com/set")); + + using var api = new HttpClient { BaseAddress = new Uri($"http://{_mailpit.Hostname}:{_mailpit.GetMappedPublicPort(ApiPort)}") }; + + var list = await api.GetFromJsonAsync("/api/v1/messages"); + Assert.Equal(1, list.GetProperty("total").GetInt32()); + var summary = list.GetProperty("messages")[0]; + Assert.Equal("You're invited to DistroFlow", summary.GetProperty("Subject").GetString()); + Assert.Equal("anita@example.com", summary.GetProperty("To")[0].GetProperty("Address").GetString()); + Assert.Equal("no-reply@distroflow.test", summary.GetProperty("From").GetProperty("Address").GetString()); + Assert.Equal("DistroFlow Tests", summary.GetProperty("From").GetProperty("Name").GetString()); + + var message = await api.GetFromJsonAsync($"/api/v1/message/{summary.GetProperty("ID").GetString()}"); + Assert.Contains("https://example.com/set", message.GetProperty("HTML").GetString()); + Assert.Contains("https://example.com/set", message.GetProperty("Text").GetString()); + } + + [Fact] + public async Task With_tls_enabled_it_refuses_a_server_without_tls_instead_of_sending_in_plain_text() + { + // Mailpit offers no STARTTLS. With UseTls = true, sending must fail rather than + // silently fall back to an unencrypted connection. + var sender = CreateSender(useTls: true); + + await Assert.ThrowsAnyAsync(() => sender.SendAsync( + new EmailMessage("anita@example.com", "Subject", "

Body

", "Body"))); + } + + private SmtpEmailSender CreateSender(bool useTls) => + new(Options.Create(new SmtpOptions + { + Host = _mailpit.Hostname, + Port = _mailpit.GetMappedPublicPort(SmtpPort), + UseTls = useTls, + FromAddress = "no-reply@distroflow.test", + FromName = "DistroFlow Tests" + })); +} From 1e250c0d4b5c0066ea0f090ad997b2520a75a624 Mon Sep 17 00:00:00 2001 From: Navaneeth T Date: Mon, 9 Nov 2026 21:55:36 +0530 Subject: [PATCH 03/18] feat: translate unique constraint violations in unit of work --- .../UniqueConstraintViolationException.cs | 19 +++++++ .../Persistence/UnitOfWork.cs | 17 +++++- .../Persistence/UnitOfWorkTests.cs | 54 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs create mode 100644 tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs diff --git a/src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs b/src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs new file mode 100644 index 0000000..a146a57 --- /dev/null +++ b/src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs @@ -0,0 +1,19 @@ +namespace DistroFlow.Application.Common; + +/// +/// The database rejected a save because a unique index would be violated. +/// Thrown by the persistence layer so the Application layer can react to specific rules +/// (e.g. duplicate employee code) without depending on EF Core or PostgreSQL types. +/// +public class UniqueConstraintViolationException(string constraintName, Exception innerException) + : Exception($"Unique constraint '{constraintName}' was violated.", innerException) +{ + public string ConstraintName { get; } = constraintName; +} + +/// Names of database unique indexes the Application layer reacts to. +public static class ConstraintNames +{ + /// Employee code is unique within one distributor. + public const string EmployeeCodePerTenant = "IX_Employees_TenantId_EmployeeCode"; +} diff --git a/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs b/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs index ac3f25c..71608d2 100644 --- a/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs +++ b/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs @@ -1,5 +1,8 @@ +using DistroFlow.Application.Common; using DistroFlow.Application.Interfaces.Persistence; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; namespace DistroFlow.Infrastructure.Persistence; @@ -14,7 +17,19 @@ public UnitOfWork(AppDbContext dbContext) public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await _dbContext.SaveChangesAsync(cancellationToken); + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: { } constraintName + }) + { + // Translate to a provider-neutral exception; the original stays as InnerException. + throw new UniqueConstraintViolationException(constraintName, ex); + } } public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) diff --git a/tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs b/tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs new file mode 100644 index 0000000..18199e7 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs @@ -0,0 +1,54 @@ +using DistroFlow.Application.Common; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Persistence; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Persistence; + +public class UnitOfWorkTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + + public UnitOfWorkTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Unique_index_violation_is_translated_with_the_constraint_name() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var identity = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var tenant = new Tenant { Name = $"Distributor {Guid.NewGuid():N}" }; + db.Tenants.Add(tenant); + await unitOfWork.SaveChangesAsync(); + + // Two employees of the same distributor with the same employee code. + foreach (var name in new[] { "First", "Second" }) + { + var login = await identity.CreateUserAsync( + tenant.Id, $"uow-{Guid.NewGuid():N}@example.com", "Str0ng!Passw0rd", EmployeeRoles.SalesStaff); + db.Employees.Add(new Employee + { + TenantId = tenant.Id, + UserId = login.UserId, + FullName = name, + EmployeeCode = "EMP-1" + }); + + if (name == "First") + await unitOfWork.SaveChangesAsync(); + } + + var ex = await Assert.ThrowsAsync(() => unitOfWork.SaveChangesAsync()); + + Assert.Equal(ConstraintNames.EmployeeCodePerTenant, ex.ConstraintName); + } +} From 99ea75380ef08f34c4cabd4d4eb9c271bbbeaab4 Mon Sep 17 00:00:00 2001 From: Navaneeth T Date: Mon, 9 Nov 2026 21:56:17 +0530 Subject: [PATCH 04/18] feat: support invited logins and set-password tokens --- .../Interfaces/Identity/IIdentityService.cs | 17 ++++++++ .../DependencyInjection.cs | 8 +++- .../DistroFlow.Infrastructure.csproj | 7 +++- .../Identity/IdentityService.cs | 40 +++++++++++++++--- .../Identity/IdentityServiceTests.cs | 42 +++++++++++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs index 2f4a76f..d4b908d 100644 --- a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs +++ b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs @@ -17,6 +17,23 @@ Task CreateUserAsync( string role, CancellationToken cancellationToken = default); + /// + /// Creates a login with no password (an invited employee sets it via an emailed link) + /// and assigns the roles. Saves immediately; wrap in a transaction with the employee row. + /// + Task CreateInvitedUserAsync( + Guid tenantId, + string email, + IReadOnlyCollection roles, + CancellationToken cancellationToken = default); + + /// + /// A signed, single-use, expiring token that lets the user set their password. + /// + Task GeneratePasswordSetupTokenAsync( + Guid userId, + CancellationToken cancellationToken = default); + /// /// Checks email + password against the single active account with that email. /// Counts failed attempts and enforces lockout. diff --git a/src/DistroFlow.Infrastructure/DependencyInjection.cs b/src/DistroFlow.Infrastructure/DependencyInjection.cs index ec3caf4..2a88b49 100644 --- a/src/DistroFlow.Infrastructure/DependencyInjection.cs +++ b/src/DistroFlow.Infrastructure/DependencyInjection.cs @@ -72,7 +72,13 @@ private static void AddIdentityServices(this IServiceCollection services) options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); }) .AddRoles>() - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores() + // Signed, single-use tokens for invite / set-password links. + .AddDefaultTokenProviders(); + + // Invite links stay valid for 3 days. + services.Configure(options => + options.TokenLifespan = TimeSpan.FromDays(3)); services.AddScoped(); } diff --git a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj index b317ce3..5d7ea6a 100644 --- a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj +++ b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj @@ -5,6 +5,12 @@ + + + + + @@ -13,7 +19,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - diff --git a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs index edea523..7f30004 100644 --- a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs +++ b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs @@ -25,12 +25,39 @@ public IdentityService( _logger = logger; } - public async Task CreateUserAsync( + public Task CreateUserAsync( Guid tenantId, string email, string password, string role, + CancellationToken cancellationToken = default) => + CreateAsync(tenantId, email, password, [role]); + + public Task CreateInvitedUserAsync( + Guid tenantId, + string email, + IReadOnlyCollection roles, + CancellationToken cancellationToken = default) => + CreateAsync(tenantId, email, password: null, roles); + + public async Task GeneratePasswordSetupTokenAsync( + Guid userId, CancellationToken cancellationToken = default) + { + var user = await _userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidOperationException($"User {userId} not found."); + + // Identity's password-reset token: signed with Data Protection, tied to the user's + // security stamp (so it stops working once the password is set) and time-limited. + return await _userManager.GeneratePasswordResetTokenAsync(user); + } + + /// Null for an invited user, who sets it later via the emailed link. + private async Task CreateAsync( + Guid tenantId, + string email, + string? password, + IReadOnlyCollection roles) { var user = new ApplicationUser { @@ -45,7 +72,9 @@ public async Task CreateUserAsync( IdentityResult created; try { - created = await _userManager.CreateAsync(user, password); + created = password is null + ? await _userManager.CreateAsync(user) + : await _userManager.CreateAsync(user, password); } catch (DbUpdateException ex) when (IsActiveEmailViolation(ex)) { @@ -72,12 +101,13 @@ public async Task CreateUserAsync( created.Errors.Select(e => e.Description).ToList()); } - var assigned = await _userManager.AddToRoleAsync(user, role); + var assigned = await _userManager.AddToRolesAsync(user, roles); if (!assigned.Succeeded) { - // Roles are seeded by migration; a missing role is a bug, not user error. + // Roles are validated at the API boundary and seeded by migration; + // failing here is a bug, not user error. throw new InvalidOperationException( - $"Could not assign role '{role}': {string.Join("; ", assigned.Errors.Select(e => e.Description))}"); + $"Could not assign roles '{string.Join(", ", roles)}': {string.Join("; ", assigned.Errors.Select(e => e.Description))}"); } return CreateUserResult.Success(user.Id); diff --git a/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs b/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs index e31f684..b81d147 100644 --- a/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs +++ b/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs @@ -91,6 +91,48 @@ public async Task CreateUser_returns_ValidationFailed_for_weak_password_and_save Assert.Equal(0, await db.Users.CountAsync(u => u.TenantId == tenant.Id)); } + [Fact] + public async Task CreateInvitedUser_creates_login_without_password_and_with_all_roles() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var result = await service.CreateInvitedUserAsync( + tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff, EmployeeRoles.Approver]); + + Assert.True(result.Succeeded); + var user = await userManager.FindByIdAsync(result.UserId!.Value.ToString()); + Assert.NotNull(user); + Assert.False(await userManager.HasPasswordAsync(user)); + Assert.Equal( + [EmployeeRoles.Approver, EmployeeRoles.SalesStaff], + (await userManager.GetRolesAsync(user)).Order()); + } + + [Fact] + public async Task Password_setup_token_is_accepted_once_and_then_stops_working() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var created = await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff]); + var token = await service.GeneratePasswordSetupTokenAsync(created.UserId!.Value); + var user = (await userManager.FindByIdAsync(created.UserId.Value.ToString()))!; + + var first = await userManager.ResetPasswordAsync(user, token, ValidPassword); + var reused = await userManager.ResetPasswordAsync(user, token, "An0ther!Passw0rd"); + + Assert.True(first.Succeeded); + Assert.False(reused.Succeeded); // single use: setting the password invalidates the token + Assert.True(await userManager.CheckPasswordAsync(user, ValidPassword)); + } + private static string UniqueEmail() => $"user-{Guid.NewGuid():N}@example.com"; private async Task CreateTenantAsync() From e871472ef9ba855b3cc63309576408d22a8446bf Mon Sep 17 00:00:00 2001 From: Navaneeth T Date: Mon, 9 Nov 2026 21:56:39 +0530 Subject: [PATCH 05/18] feat: add invitation email template --- .../Emails/InvitationEmail.cs | 37 +++++++++++++++++++ .../Emails/InvitationEmailTests.cs | 32 ++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/DistroFlow.Application/Emails/InvitationEmail.cs create mode 100644 tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs diff --git a/src/DistroFlow.Application/Emails/InvitationEmail.cs b/src/DistroFlow.Application/Emails/InvitationEmail.cs new file mode 100644 index 0000000..b57855e --- /dev/null +++ b/src/DistroFlow.Application/Emails/InvitationEmail.cs @@ -0,0 +1,37 @@ +using System.Net; +using DistroFlow.Application.Interfaces.Email; + +namespace DistroFlow.Application.Emails; + +/// The email an invited employee receives, with the link to set their password. +public static class InvitationEmail +{ + public static EmailMessage Create(string to, string employeeName, string distributorName, string setPasswordLink) + { + // Names come from user input: encode them so they can never become HTML/script in the email. + var name = WebUtility.HtmlEncode(employeeName); + var distributor = WebUtility.HtmlEncode(distributorName); + var link = WebUtility.HtmlEncode(setPasswordLink); + + var subject = $"You're invited to {distributorName} on DistroFlow"; + + var html = $""" +

Hi {name},

+

You've been added to {distributor} on DistroFlow.

+

Set your password to get started. This link can be used once and expires in 3 days.

+

If you weren't expecting this, you can ignore this email.

+ """; + + var text = $""" + Hi {employeeName}, + + You've been added to {distributorName} on DistroFlow. + Set your password to get started (the link can be used once and expires in 3 days): + {setPasswordLink} + + If you weren't expecting this, you can ignore this email. + """; + + return new EmailMessage(to, subject, html, text); + } +} diff --git a/tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs b/tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs new file mode 100644 index 0000000..a6fa014 --- /dev/null +++ b/tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs @@ -0,0 +1,32 @@ +using DistroFlow.Application.Emails; + +namespace DistroFlow.UnitTests.Emails; + +public class InvitationEmailTests +{ + private const string Link = "https://app.example.com/set-password?userId=42&token=abc%2B1"; + + [Fact] + public void Contains_recipient_distributor_name_and_link() + { + var email = InvitationEmail.Create("anita@example.com", "Anita Rao", "Sharma Traders", Link); + + Assert.Equal("anita@example.com", email.To); + Assert.Contains("Sharma Traders", email.Subject); + Assert.Contains("Anita Rao", email.HtmlBody); + Assert.Contains(Link, email.TextBody); + Assert.Contains("https://app.example.com/set-password?userId=42&token=abc%2B1", email.HtmlBody); + } + + [Fact] + public void User_supplied_names_are_html_encoded() + { + var email = InvitationEmail.Create( + "anita@example.com", "", "Evil Traders", Link); + + Assert.DoesNotContain("