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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions flossk-ms/FlosskMS.API/Controllers/PurchaseRequestsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Security.Claims;
using FlosskMS.Business.DTOs;
using FlosskMS.Business.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace FlosskMS.API.Controllers;

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class PurchaseRequestsController(IPurchaseRequestService purchaseRequestService) : ControllerBase
{
private readonly IPurchaseRequestService _purchaseRequestService = purchaseRequestService;

private string UserId => User.FindFirstValue(ClaimTypes.NameIdentifier)!;
private string UserName => $"{User.FindFirstValue("firstName")} {User.FindFirstValue("lastName")}".Trim();

// Any authenticated user can submit a request
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreatePurchaseRequestDto request)
=> await _purchaseRequestService.CreateAsync(request, UserId, UserName);

// Any authenticated user can view their own requests
[HttpGet("mine")]
public async Task<IActionResult> GetMine([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
=> await _purchaseRequestService.GetMineAsync(UserId, page, pageSize);

// Board (Admin/Leader) — approval queue
[Authorize(Roles = "Admin,Leader")]
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
=> await _purchaseRequestService.GetAllAsync(status, page, pageSize);

[Authorize(Roles = "Admin,Leader")]
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
=> await _purchaseRequestService.GetByIdAsync(id);

[Authorize(Roles = "Admin,Leader")]
[HttpPost("approve/{id:guid}")]
public async Task<IActionResult> Approve(Guid id)
=> await _purchaseRequestService.ApproveAsync(id, UserId, UserName);

[Authorize(Roles = "Admin,Leader")]
[HttpPost("reject/{id:guid}")]
public async Task<IActionResult> Reject(Guid id, [FromBody] RejectPurchaseRequestDto request)
=> await _purchaseRequestService.RejectAsync(id, request, UserId, UserName);
}
6 changes: 6 additions & 0 deletions flossk-ms/FlosskMS.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using FlosskMS.Business.DomainEvents.Memberships;
using FlosskMS.Business.DomainEvents.Inventory;
using FlosskMS.Business.DomainEvents.Projects;
using FlosskMS.Business.DomainEvents.Purchasing;
using FlosskMS.Business.Services;
using FlosskMS.Business.Services.FactoryPattern;
using FlosskMS.Data;
Expand Down Expand Up @@ -203,6 +204,11 @@
builder.Services.AddScoped<IDomainEventHandler<MembershipRequestApprovedEvent>, MembershipRequestApprovedNotificationHandler>();
builder.Services.AddScoped<IDomainEventHandler<MembershipRequestRejectedEvent>, MembershipRequestRejectedNotificationHandler>();

builder.Services.AddScoped<IPurchaseRequestService, PurchaseRequestService>();
builder.Services.AddScoped<IDomainEventHandler<PurchaseRequestSubmittedEvent>, PurchaseRequestSubmittedNotificationHandler>();
builder.Services.AddScoped<IDomainEventHandler<PurchaseRequestApprovedEvent>, PurchaseRequestApprovedNotificationHandler>();
builder.Services.AddScoped<IDomainEventHandler<PurchaseRequestRejectedEvent>, PurchaseRequestRejectedNotificationHandler>();

builder.Services.Configure<FileUploadSettings>(builder.Configuration.GetSection("FileUploadSettings"));
builder.Services.Configure<ClamAvSettings>(builder.Configuration.GetSection("ClamAvSettings"));
builder.Services.Configure<VapidSettings>(builder.Configuration.GetSection("VapidSettings"));
Expand Down
61 changes: 61 additions & 0 deletions flossk-ms/FlosskMS.Business/DTOs/PurchaseRequestDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;

namespace FlosskMS.Business.DTOs;

public class CreatePurchaseRequestDto
{
[Required]
[MaxLength(200)]
public string ItemName { get; set; } = string.Empty;

[Required]
[MaxLength(2000)]
public string Reason { get; set; } = string.Empty;

[MaxLength(2000)]
[Url]
public string? Link { get; set; }

[Range(0, 9999999)]
public decimal Price { get; set; }

[Range(1, 100000)]
public int Quantity { get; set; } = 1;

[Required]
public DateTime NeededByDate { get; set; }
}

public class RejectPurchaseRequestDto
{
[MaxLength(2000)]
public string? RejectionReason { get; set; }
}

public class PurchaseRequestDto
{
public Guid Id { get; set; }
public string ItemName { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
public string? Link { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public decimal Total { get; set; }
public DateTime NeededByDate { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string? CreatedByFirstName { get; set; }
public string? CreatedByLastName { get; set; }
public DateTime? ReviewedAt { get; set; }
public string? ReviewedByFirstName { get; set; }
public string? ReviewedByLastName { get; set; }
public string? RejectionReason { get; set; }
}

public class PurchaseRequestListDto
{
public List<PurchaseRequestDto> Requests { get; set; } = [];
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace FlosskMS.Business.DomainEvents.Purchasing;

public sealed record PurchaseRequestSubmittedEvent(
string ItemName,
string SubmitterName,
string SubmitterUserId
) : IDomainEvent;

public sealed record PurchaseRequestApprovedEvent(
string ItemName,
string SubmitterUserId,
string ReviewerName
) : IDomainEvent;

public sealed record PurchaseRequestRejectedEvent(
string ItemName,
string SubmitterUserId,
string ReviewerName,
string? RejectionReason
) : IDomainEvent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using FlosskMS.Business.Services;
using FlosskMS.Data.Entities;
using Microsoft.AspNetCore.Identity;

namespace FlosskMS.Business.DomainEvents.Purchasing;

public sealed class PurchaseRequestSubmittedNotificationHandler(
INotificationService notificationService,
UserManager<ApplicationUser> userManager)
: IDomainEventHandler<PurchaseRequestSubmittedEvent>
{
private readonly INotificationService _notificationService = notificationService;
private readonly UserManager<ApplicationUser> _userManager = userManager;

public async Task HandleAsync(PurchaseRequestSubmittedEvent domainEvent, CancellationToken ct = default)
{
var admins = await _userManager.GetUsersInRoleAsync("Admin");
var leaders = await _userManager.GetUsersInRoleAsync("Leader");
var boardIds = admins.Concat(leaders)
.Select(u => u.Id)
.Distinct();

await _notificationService.SendToManyAsync(
boardIds,
NotificationType.PurchaseRequestSubmitted,
"New purchase request",
$"{domainEvent.SubmitterName} requested to buy \"{domainEvent.ItemName}\".");
}
}

public sealed class PurchaseRequestApprovedNotificationHandler(
INotificationService notificationService)
: IDomainEventHandler<PurchaseRequestApprovedEvent>
{
private readonly INotificationService _notificationService = notificationService;

public async Task HandleAsync(PurchaseRequestApprovedEvent domainEvent, CancellationToken ct = default)
{
await _notificationService.SendAsync(
domainEvent.SubmitterUserId,
NotificationType.PurchaseRequestApproved,
"Purchase request approved",
$"{domainEvent.ReviewerName} approved your request to buy \"{domainEvent.ItemName}\".");
}
}

public sealed class PurchaseRequestRejectedNotificationHandler(
INotificationService notificationService)
: IDomainEventHandler<PurchaseRequestRejectedEvent>
{
private readonly INotificationService _notificationService = notificationService;

public async Task HandleAsync(PurchaseRequestRejectedEvent domainEvent, CancellationToken ct = default)
{
var reason = string.IsNullOrWhiteSpace(domainEvent.RejectionReason)
? ""
: $" Reason: {domainEvent.RejectionReason}";

await _notificationService.SendAsync(
domainEvent.SubmitterUserId,
NotificationType.PurchaseRequestRejected,
"Purchase request rejected",
$"{domainEvent.ReviewerName} rejected your request to buy \"{domainEvent.ItemName}\".{reason}");
}
}
14 changes: 14 additions & 0 deletions flossk-ms/FlosskMS.Business/Services/IPurchaseRequestService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using FlosskMS.Business.DTOs;
using Microsoft.AspNetCore.Mvc;

namespace FlosskMS.Business.Services;

public interface IPurchaseRequestService
{
Task<IActionResult> CreateAsync(CreatePurchaseRequestDto request, string userId, string userName);
Task<IActionResult> GetMineAsync(string userId, int page = 1, int pageSize = 20);
Task<IActionResult> GetAllAsync(string? status = null, int page = 1, int pageSize = 20);
Task<IActionResult> GetByIdAsync(Guid id);
Task<IActionResult> ApproveAsync(Guid id, string reviewerUserId, string reviewerName);
Task<IActionResult> RejectAsync(Guid id, RejectPurchaseRequestDto request, string reviewerUserId, string reviewerName);
}
Loading