This commit is contained in:
Chris Chen
2026-06-20 15:13:23 -07:00
parent b6c50a38aa
commit f55807fa7d
32 changed files with 866 additions and 18 deletions
@@ -56,4 +56,40 @@ public class OfferingSessionsController : ControllerBase
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
[HttpPost("{id:int}/proof")]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
public async Task<IActionResult> UploadProof(int id, IFormFile file)
{
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveProofAsync(id, stream, file.FileName);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpGet("{id:int}/proof")]
public async Task<IActionResult> GetProof(int id)
{
try
{
var result = await _svc.OpenProofAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}/proof")]
public async Task<IActionResult> DeleteProof(int id)
{
try { await _svc.DeleteProofAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -10,5 +10,6 @@ public class OfferingSessionDto
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public bool HasProof { get; set; }
public List<OfferingGivingLineDto> Givings { get; set; } = [];
}
@@ -10,4 +10,5 @@ public class OfferingSessionListItemDto
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public int LineCount { get; set; }
public bool HasProof { get; set; }
}
+1
View File
@@ -119,6 +119,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
entity.Property(e => e.ReconciledBy).HasMaxLength(450);
entity.Property(e => e.ProofPdfPath).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.SessionDate).IsUnique();
@@ -12,6 +12,7 @@ public class OfferingSession : AuditableEntity
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public string? ProofPdfPath { get; set; } // merged paper-proof PDF (relative storage path)
public DateTimeOffset? SubmittedAt { get; set; }
public string? SubmittedBy { get; set; }
public DateTimeOffset? ReconciledAt { get; set; }
@@ -911,6 +911,10 @@ namespace ROLAC.API.Migrations
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("ProofPdfPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset?>("ReconciledAt")
.HasColumnType("timestamp with time zone");
+15 -1
View File
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -143,6 +144,7 @@ builder.Services
opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter());
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddHealthChecks();
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" });
@@ -171,6 +173,12 @@ builder.Services.AddSwaggerGen(opt =>
// ---------------------------------------------------------------------------
var app = builder.Build();
// Behind a TLS-terminating reverse proxy (nginx), honour the original scheme/client IP.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// Apply migrations + seed on startup
using (var scope = app.Services.CreateScope())
{
@@ -185,10 +193,16 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// TLS is terminated by nginx in production; only redirect in local dev.
if (app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseCors("Angular");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -12,4 +12,8 @@ public interface IOfferingSessionService
Task<int> CreateAsync(CreateOfferingSessionRequest request);
Task ReopenAsync(int id);
Task ReplaceAsync(int id, CreateOfferingSessionRequest request);
Task SaveProofAsync(int id, Stream content, string fileName);
Task<(Stream stream, string contentType)?> OpenProofAsync(int id);
Task DeleteProofAsync(int id);
}
@@ -5,6 +5,7 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services;
@@ -12,11 +13,13 @@ public class OfferingSessionService : IOfferingSessionService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage;
public OfferingSessionService(AppDbContext db, IHttpContextAccessor http)
public OfferingSessionService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage)
{
_db = db;
_http = http;
_db = db;
_http = http;
_storage = storage;
}
private string CurrentUserId =>
@@ -48,6 +51,7 @@ public class OfferingSessionService : IOfferingSessionService
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
SystemTotal = s.SystemTotal, Difference = s.Difference,
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
HasProof = s.ProofPdfPath != null,
}).ToList();
return new PagedResult<OfferingSessionListItemDto>
@@ -81,6 +85,7 @@ public class OfferingSessionService : IOfferingSessionService
Id = s.Id, SessionDate = s.SessionDate, Status = s.Status,
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes,
HasProof = s.ProofPdfPath != null,
Givings = lines.Select(l => new OfferingGivingLineDto
{
Id = l.Id, MemberId = l.MemberId,
@@ -158,6 +163,41 @@ public class OfferingSessionService : IOfferingSessionService
await _db.SaveChangesAsync();
}
// ── Paper-proof PDF (one merged file per session) ────────────────────────
public async Task SaveProofAsync(int id, Stream content, string fileName)
{
var s = await _db.OfferingSessions.FindAsync(id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
var path = $"finance/offering-proofs/{s.SessionDate.Year}/{s.SessionDate.Month}/{s.Id}-proof.pdf";
if (s.ProofPdfPath != null && s.ProofPdfPath != path)
await _storage.DeleteAsync(s.ProofPdfPath);
var saved = await _storage.SaveAsync(content, path);
s.ProofPdfPath = saved;
await _db.SaveChangesAsync();
}
public async Task<(Stream stream, string contentType)?> OpenProofAsync(int id)
{
var s = await _db.OfferingSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
if (s.ProofPdfPath is null) return null;
var stream = await _storage.OpenReadAsync(s.ProofPdfPath);
if (stream is null) return null;
return (stream, "application/pdf");
}
public async Task DeleteProofAsync(int id)
{
var s = await _db.OfferingSessions.FindAsync(id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
if (s.ProofPdfPath is null) return;
await _storage.DeleteAsync(s.ProofPdfPath);
s.ProofPdfPath = null;
await _db.SaveChangesAsync();
}
private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new()
{
MemberId = line.IsAnonymous ? null : line.MemberId,