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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
30 changes: 30 additions & 0 deletions src/DistroFlow.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ public async Task<IActionResult> Register(
};
}

/// <summary>An invited employee sets their first password using the link from their invite email.</summary>
[AllowAnonymous]
[HttpPost("set-password")]
public async Task<IActionResult> SetPassword(
[FromBody] SetPasswordRequest request,
CancellationToken cancellationToken)
{
var result = await _authService.SetPasswordAsync(request, cancellationToken);

return result.Error switch
{
SetPasswordError.None => Ok(
ApiResponse<object>.SuccessResponse(null, "Password set. You can now log in.")),

SetPasswordError.InvalidLink => BadRequest(
ApiResponse<object>.FailureResponse(
"This link is invalid or has expired. Ask your administrator to resend the invite.")),

SetPasswordError.PasswordRejected => BadRequest(
ApiResponse<object>.FailureResponse(
"Validation failed.",
new Dictionary<string, string[]>
{
[nameof(SetPasswordRequest.NewPassword)] = result.Messages.ToArray()
})),

_ => throw new InvalidOperationException($"Unhandled set-password error: {result.Error}")
};
}

/// <summary>Exchanges email + password for a JWT access token.</summary>
[AllowAnonymous]
[HttpPost("login")]
Expand Down
209 changes: 209 additions & 0 deletions src/DistroFlow.Api/Controllers/EmployeesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
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;

/// <summary>Employee management for the caller's own distributor. Administrators only.</summary>
[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<IActionResult> List(CancellationToken cancellationToken)
{
var employees = await _employeeService.ListAsync(cancellationToken);

return Ok(ApiResponse<IReadOnlyList<EmployeeResponse>>.SuccessResponse(
employees, "Employees retrieved successfully."));
}

[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken cancellationToken)
{
var employee = await _employeeService.GetByIdAsync(id, cancellationToken);

if (employee is null)
{
return NotFound(ApiResponse<EmployeeResponse>.FailureResponse("Employee not found."));
}

return Ok(ApiResponse<EmployeeResponse>.SuccessResponse(
employee, "Employee retrieved successfully."));
}

/// <summary>Adds an employee and emails them a link to set their password.</summary>
[HttpPost]
public async Task<IActionResult> Invite(
[FromBody] InviteEmployeeRequest request,
CancellationToken cancellationToken)
{
var result = await _employeeService.InviteAsync(request, cancellationToken);

return result.Error switch
{
InviteEmployeeError.None => StatusCode(
StatusCodes.Status201Created,
ApiResponse<InviteEmployeeResponse>.SuccessResponse(
result.Response,
result.Response!.InviteEmailSent
? "Employee invited. A set-password email has been sent."
: "Employee added, but the invite email could not be sent. Try resending the invite.")),

InviteEmployeeError.EmailInUse => Conflict(
ApiResponse<object>.FailureResponse(
"This email can't be used right now. If the person worked for another distributor, " +
"ask them to check with their previous employer.")),

InviteEmployeeError.EmployeeCodeInUse => Conflict(
ApiResponse<object>.FailureResponse("Another employee already has this employee code.")),

_ => throw new InvalidOperationException($"Unhandled invite error: {result.Error}")
};
}

/// <summary>Changes an employee's name, phone and employee code.</summary>
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id,
[FromBody] UpdateEmployeeRequest request,
CancellationToken cancellationToken)
{
var result = await _employeeService.UpdateAsync(id, request, cancellationToken);

return result.Error switch
{
UpdateEmployeeError.None => Ok(
ApiResponse<EmployeeResponse>.SuccessResponse(result.Response, "Employee updated successfully.")),

UpdateEmployeeError.NotFound => NotFound(
ApiResponse<object>.FailureResponse("Employee not found.")),

UpdateEmployeeError.NotEditable => Conflict(
ApiResponse<object>.FailureResponse("This employee's personal data was erased and cannot be edited.")),

UpdateEmployeeError.EmployeeCodeInUse => Conflict(
ApiResponse<object>.FailureResponse("Another employee already has this employee code.")),

_ => throw new InvalidOperationException($"Unhandled update error: {result.Error}")
};
}

/// <summary>Replaces an employee's roles. The last active Administrator cannot be demoted.</summary>
[HttpPut("{id:guid}/roles")]
public async Task<IActionResult> ChangeRoles(
Guid id,
[FromBody] ChangeEmployeeRolesRequest request,
CancellationToken cancellationToken)
{
var result = await _employeeService.ChangeRolesAsync(id, request, cancellationToken);

return result.Error switch
{
ChangeEmployeeRolesError.None => Ok(
ApiResponse<EmployeeResponse>.SuccessResponse(result.Response, "Roles updated successfully.")),

ChangeEmployeeRolesError.NotFound => NotFound(
ApiResponse<object>.FailureResponse("Employee not found.")),

ChangeEmployeeRolesError.NotEditable => Conflict(
ApiResponse<object>.FailureResponse("This employee has no login, so roles cannot be changed.")),

ChangeEmployeeRolesError.LastAdministrator => Conflict(
ApiResponse<object>.FailureResponse(
"This is the last active Administrator. Make someone else an Administrator first.")),

_ => throw new InvalidOperationException($"Unhandled change-roles error: {result.Error}")
};
}

/// <summary>The employee left or is suspended: blocks their login. Safe to repeat.</summary>
[HttpPost("{id:guid}/deactivate")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken cancellationToken)
{
var result = await _employeeService.DeactivateAsync(id, cancellationToken);

return result.Error switch
{
EmployeeStatusChangeError.None => Ok(
ApiResponse<EmployeeResponse>.SuccessResponse(result.Response, "Employee deactivated.")),

EmployeeStatusChangeError.NotFound => NotFound(
ApiResponse<object>.FailureResponse("Employee not found.")),

EmployeeStatusChangeError.NotEditable => Conflict(
ApiResponse<object>.FailureResponse("This employee's personal data was erased; their status cannot change.")),

EmployeeStatusChangeError.LastAdministrator => Conflict(
ApiResponse<object>.FailureResponse(
"This is the last active Administrator. Make someone else an Administrator first.")),

_ => throw new InvalidOperationException($"Unhandled deactivate error: {result.Error}")
};
}

/// <summary>A deactivated employee returns and can log in again. Safe to repeat.</summary>
[HttpPost("{id:guid}/reactivate")]
public async Task<IActionResult> Reactivate(Guid id, CancellationToken cancellationToken)
{
var result = await _employeeService.ReactivateAsync(id, cancellationToken);

return result.Error switch
{
EmployeeStatusChangeError.None => Ok(
ApiResponse<EmployeeResponse>.SuccessResponse(result.Response, "Employee reactivated.")),

EmployeeStatusChangeError.NotFound => NotFound(
ApiResponse<object>.FailureResponse("Employee not found.")),

EmployeeStatusChangeError.NotEditable => Conflict(
ApiResponse<object>.FailureResponse("This employee's personal data was erased; their status cannot change.")),

EmployeeStatusChangeError.EmailInUse => Conflict(
ApiResponse<object>.FailureResponse(
"This employee's email is now in use by another account, so they can't be reactivated. " +
"Invite them again with a different email.")),

_ => throw new InvalidOperationException($"Unhandled reactivate error: {result.Error}")
};
}

/// <summary>Sends a new set-password link to an employee who has not accepted their invite yet.</summary>
[HttpPost("{id:guid}/resend-invite")]
public async Task<IActionResult> ResendInvite(Guid id, CancellationToken cancellationToken)
{
var result = await _employeeService.ResendInviteAsync(id, cancellationToken);

return result.Error switch
{
ResendInviteError.None => Ok(
ApiResponse<ResendInviteResponse>.SuccessResponse(
result.Response,
result.Response!.InviteEmailSent
? "Invite email sent."
: "The invite email could not be sent. Please try again later.")),

ResendInviteError.NotFound => NotFound(
ApiResponse<object>.FailureResponse("Employee not found.")),

ResendInviteError.AlreadyAccepted => Conflict(
ApiResponse<object>.FailureResponse("This employee has already set a password.")),

ResendInviteError.NotActive => Conflict(
ApiResponse<object>.FailureResponse("Only active employees can be invited.")),

_ => throw new InvalidOperationException($"Unhandled resend-invite error: {result.Error}")
};
}
}
11 changes: 11 additions & 0 deletions src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ public static IServiceCollection AddApiControllers(this IServiceCollection servi
return services;
}

/// <summary>Frontend base URL, used to build links in emails. Validated at startup.</summary>
public static IServiceCollection AddFrontendLinks(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<FrontendOptions>()
.Bind(configuration.GetSection(FrontendOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();

return services;
}

/// <summary>Who is calling: the current tenant, read from the request's JWT.</summary>
public static IServiceCollection AddCurrentTenant(this IServiceCollection services)
{
Expand Down
1 change: 1 addition & 0 deletions src/DistroFlow.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddJwtAuthentication();
builder.Services.AddCurrentTenant();
builder.Services.AddFrontendLinks(builder.Configuration);

var app = builder.Build();

Expand Down
8 changes: 8 additions & 0 deletions src/DistroFlow.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,13 @@
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=distroflow_dev;Username=distroflow"
},
"Smtp": {
"Host": "localhost",
"Port": 1025,
"UseTls": false
},
"Frontend": {
"BaseUrl": "http://localhost:5173"
}
}
6 changes: 6 additions & 0 deletions src/DistroFlow.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@
"Issuer": "DistroFlow.Api",
"Audience": "DistroFlow.Web",
"AccessTokenMinutes": 30
},
"Smtp": {
"Port": 587,
"UseTls": true,
"FromAddress": "no-reply@distroflow.local",
"FromName": "DistroFlow"
}
}
16 changes: 16 additions & 0 deletions src/DistroFlow.Application/Common/FrontendOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;

namespace DistroFlow.Application.Common;

/// <summary>
/// Where the React app runs, so emails can link to its pages (e.g. set-password).
/// Bound from the "Frontend" configuration section.
/// </summary>
public sealed class FrontendOptions
{
public const string SectionName = "Frontend";

[Required]
[Url]
public string BaseUrl { get; init; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace DistroFlow.Application.Common;

/// <summary>
/// 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.
/// </summary>
public class UniqueConstraintViolationException(string constraintName, Exception innerException)
: Exception($"Unique constraint '{constraintName}' was violated.", innerException)
{
public string ConstraintName { get; } = constraintName;
}

/// <summary>Names of database unique indexes the Application layer reacts to.</summary>
public static class ConstraintNames
{
/// <summary>Employee code is unique within one distributor.</summary>
public const string EmployeeCodePerTenant = "IX_Employees_TenantId_EmployeeCode";
}
19 changes: 19 additions & 0 deletions src/DistroFlow.Application/DTOs/Auth/SetPasswordRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;

namespace DistroFlow.Application.DTOs.Auth;

/// <summary>Sent by the frontend's set-password page, with userId and token taken from the invite link.</summary>
public class SetPasswordRequest
{
[Required]
public Guid? UserId { get; set; }

[Required]
[StringLength(2048)]
public string Token { get; set; } = string.Empty;

/// <summary>Strength rules are enforced by Identity's password policy.</summary>
[Required]
[StringLength(128)]
public string NewPassword { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using DistroFlow.Application.Validators;

namespace DistroFlow.Application.DTOs.Employees;

/// <summary>The employee's complete new set of roles (replaces the current ones).</summary>
public class ChangeEmployeeRolesRequest
{
[ValidEmployeeRoles]
public List<string> Roles { get; set; } = [];
}
Loading
Loading