wip
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/appsettings.Development.json
|
||||
**/appsettings.*.local.json
|
||||
ROLAC.API.Tests/
|
||||
.git
|
||||
*.user
|
||||
@@ -0,0 +1,25 @@
|
||||
# ---- build ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ROLAC.API/ROLAC.API.csproj ROLAC.API/
|
||||
RUN dotnet restore ROLAC.API/ROLAC.API.csproj
|
||||
COPY ROLAC.API/ ROLAC.API/
|
||||
RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# ---- runtime ----
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_HTTP_PORTS=8080
|
||||
# curl: used by the HEALTHCHECK (not present in the base image)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/publish .
|
||||
# storage dir created + owned for the non-root app user
|
||||
RUN mkdir -p /app/App_Data/storage && chown -R app:app /app/App_Data
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||||
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||
ENTRYPOINT ["dotnet", "ROLAC.API.dll"]
|
||||
@@ -7,12 +7,24 @@ using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class OfferingSessionServiceTests
|
||||
{
|
||||
// Proof storage is not exercised by these tests; a no-op keeps the service constructible.
|
||||
private sealed class NoOpFileStorage : IFileStorage
|
||||
{
|
||||
public Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default)
|
||||
=> Task.FromResult(relativePath);
|
||||
public Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream?>(null);
|
||||
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||
@@ -55,7 +67,7 @@ public class OfferingSessionServiceTests
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
@@ -72,7 +84,7 @@ public class OfferingSessionServiceTests
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
@@ -86,7 +98,7 @@ public class OfferingSessionServiceTests
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
@@ -98,7 +110,7 @@ public class OfferingSessionServiceTests
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
@@ -110,7 +122,7 @@ public class OfferingSessionServiceTests
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await svc.ReopenAsync(id);
|
||||
@@ -135,7 +147,7 @@ public class OfferingSessionServiceTests
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
var req = new CreateOfferingSessionRequest
|
||||
{
|
||||
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user