e1f99158aa
Live verification revealed the JWT carries the user id in the 'sub' claim (NameClaimType=sub, MapInboundClaims=false), so ClaimTypes.NameIdentifier is null at runtime. This caused ExpensesController.GetMine/GetById to throw NullReferenceException (500) on the '!.Value', and made the services fall back to 'system' — silently defeating the self-ownership guard. Resolve via NameIdentifier (unit tests) then 'sub' (real tokens). Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
5.5 KiB
C#
137 lines
5.5 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using ROLAC.API.DTOs.Expense;
|
|
using ROLAC.API.Services;
|
|
|
|
namespace ROLAC.API.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/expenses")]
|
|
[Authorize]
|
|
public class ExpensesController : ControllerBase
|
|
{
|
|
private readonly IExpenseService _svc;
|
|
public ExpensesController(IExpenseService svc) => _svc = svc;
|
|
|
|
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
|
|
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
|
|
|
|
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
|
private string CurrentUserId() =>
|
|
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "";
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetPaged(
|
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null,
|
|
[FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null,
|
|
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
|
{
|
|
if (!CanViewAll()) return Forbid();
|
|
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to));
|
|
}
|
|
|
|
[HttpGet("mine")]
|
|
public async Task<IActionResult> GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
|
{
|
|
return Ok(await _svc.GetMineAsync(CurrentUserId(), status, page, pageSize));
|
|
}
|
|
|
|
[HttpGet("{id:int}")]
|
|
public async Task<IActionResult> GetById(int id)
|
|
{
|
|
var dto = await _svc.GetByIdAsync(id);
|
|
if (dto is null) return NotFound();
|
|
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
|
return Ok(dto);
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
|
{
|
|
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpPut("{id:int}")]
|
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
|
{
|
|
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); }
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpDelete("{id:int}")]
|
|
public async Task<IActionResult> Delete(int id)
|
|
{
|
|
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpPost("{id:int}/submit")]
|
|
public async Task<IActionResult> Submit(int id)
|
|
{
|
|
try { await _svc.SubmitAsync(id); return NoContent(); }
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpPost("{id:int}/approve")]
|
|
[Authorize(Roles = "finance,super_admin")]
|
|
public async Task<IActionResult> Approve(int id)
|
|
{
|
|
try { await _svc.ApproveAsync(id); return NoContent(); }
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpPost("{id:int}/reject")]
|
|
[Authorize(Roles = "finance,super_admin")]
|
|
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
|
{
|
|
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpPost("{id:int}/pay")]
|
|
[Authorize(Roles = "finance,super_admin")]
|
|
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
|
{
|
|
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpPost("{id:int}/receipt")]
|
|
[RequestSizeLimit(10_485_760)]
|
|
public async Task<IActionResult> UploadReceipt(int id, IFormFile file)
|
|
{
|
|
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
|
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
|
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
|
|
try
|
|
{
|
|
await using var stream = file.OpenReadStream();
|
|
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
|
|
return NoContent();
|
|
}
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
|
}
|
|
|
|
[HttpGet("{id:int}/receipt")]
|
|
public async Task<IActionResult> GetReceipt(int id)
|
|
{
|
|
try
|
|
{
|
|
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
|
if (result is null) return NotFound();
|
|
return File(result.Value.stream, result.Value.contentType);
|
|
}
|
|
catch (KeyNotFoundException) { return NotFound(); }
|
|
catch (InvalidOperationException) { return Forbid(); }
|
|
}
|
|
}
|