wip
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
name: ci-cd-nas
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Runs in a normal container on the runner — only needs the .NET SDK.
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with: { dotnet-version: '8.0.x' }
|
||||||
|
- run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
|
||||||
|
|
||||||
|
# Runs on the NAS runner (label `nas`) which has the host Docker socket mounted
|
||||||
|
# and /volume1/docker/rolac bind-mounted at the same path. Builds, pushes to the
|
||||||
|
# local Gitea registry, then (re)starts the stack.
|
||||||
|
deploy:
|
||||||
|
needs: test
|
||||||
|
runs-on: nas
|
||||||
|
env:
|
||||||
|
REGISTRY: git.golife.love/chrischen
|
||||||
|
DEPLOY_DIR: /volume1/docker/rolac
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Registry login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
run: |
|
||||||
|
docker build -t "$REGISTRY/rolac-api:latest" -t "$REGISTRY/rolac-api:${{ github.sha }}" ./API
|
||||||
|
docker build -t "$REGISTRY/rolac-app:latest" -t "$REGISTRY/rolac-app:${{ github.sha }}" ./APP
|
||||||
|
|
||||||
|
- name: Push images
|
||||||
|
run: |
|
||||||
|
docker push --all-tags "$REGISTRY/rolac-api"
|
||||||
|
docker push --all-tags "$REGISTRY/rolac-app"
|
||||||
|
|
||||||
|
- name: Sync compose + nginx to deploy dir
|
||||||
|
run: |
|
||||||
|
mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage"
|
||||||
|
cp deploy/nas/docker-compose.yml "$DEPLOY_DIR/docker-compose.yml"
|
||||||
|
cp deploy/nas/nginx/conf.d/rolac.conf "$DEPLOY_DIR/nginx/conf.d/rolac.conf"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
docker compose up -d
|
||||||
|
sleep 5
|
||||||
|
curl -fsS http://localhost:8080/api/health
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: ci-cd
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [azure-deploy]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with: { dotnet-version: '8.0.x' }
|
||||||
|
- run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
|
||||||
|
|
||||||
|
build-push:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.golife.love
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./API
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.golife.love/chrischen/rolac-api:latest
|
||||||
|
git.golife.love/chrischen/rolac-api:${{ github.sha }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./APP
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.golife.love/chrischen/rolac-app:latest
|
||||||
|
git.golife.love/chrischen/rolac-app:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build-push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.VM_HOST }}
|
||||||
|
username: ${{ secrets.VM_USER }}
|
||||||
|
key: ${{ secrets.VM_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/rolac/deploy
|
||||||
|
export TAG=${{ github.sha }}
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
curl -fsS https://app.rolac.org/api/health
|
||||||
@@ -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.DTOs.Giving;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ROLAC.API.Tests.Services;
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
public class OfferingSessionServiceTests
|
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")
|
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
|
||||||
{
|
{
|
||||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
@@ -55,7 +67,7 @@ public class OfferingSessionServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var catId = await SeedCategoryAsync(db);
|
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)));
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
@@ -72,7 +84,7 @@ public class OfferingSessionServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var catId = await SeedCategoryAsync(db);
|
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)));
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
@@ -86,7 +98,7 @@ public class OfferingSessionServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var catId = await SeedCategoryAsync(db);
|
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 svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
@@ -98,7 +110,7 @@ public class OfferingSessionServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var catId = await SeedCategoryAsync(db);
|
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)));
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
@@ -110,7 +122,7 @@ public class OfferingSessionServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var catId = await SeedCategoryAsync(db);
|
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)));
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
await svc.ReopenAsync(id);
|
await svc.ReopenAsync(id);
|
||||||
@@ -135,7 +147,7 @@ public class OfferingSessionServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var catId = await SeedCategoryAsync(db);
|
var catId = await SeedCategoryAsync(db);
|
||||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
var req = new CreateOfferingSessionRequest
|
var req = new CreateOfferingSessionRequest
|
||||||
{
|
{
|
||||||
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
|
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
|
||||||
|
|||||||
@@ -56,4 +56,40 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
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 SystemTotal { get; set; }
|
||||||
public decimal Difference { get; set; }
|
public decimal Difference { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public bool HasProof { get; set; }
|
||||||
public List<OfferingGivingLineDto> Givings { get; set; } = [];
|
public List<OfferingGivingLineDto> Givings { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ public class OfferingSessionListItemDto
|
|||||||
public decimal SystemTotal { get; set; }
|
public decimal SystemTotal { get; set; }
|
||||||
public decimal Difference { get; set; }
|
public decimal Difference { get; set; }
|
||||||
public int LineCount { 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.Difference).HasColumnType("decimal(18,2)");
|
||||||
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
|
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.ReconciledBy).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.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasIndex(e => e.SessionDate).IsUnique();
|
entity.HasIndex(e => e.SessionDate).IsUnique();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class OfferingSession : AuditableEntity
|
|||||||
public decimal SystemTotal { get; set; }
|
public decimal SystemTotal { get; set; }
|
||||||
public decimal Difference { get; set; }
|
public decimal Difference { get; set; }
|
||||||
public string? Notes { 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 DateTimeOffset? SubmittedAt { get; set; }
|
||||||
public string? SubmittedBy { get; set; }
|
public string? SubmittedBy { get; set; }
|
||||||
public DateTimeOffset? ReconciledAt { get; set; }
|
public DateTimeOffset? ReconciledAt { get; set; }
|
||||||
|
|||||||
@@ -911,6 +911,10 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProofPdfPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ReconciledAt")
|
b.Property<DateTimeOffset?>("ReconciledAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -143,6 +144,7 @@ builder.Services
|
|||||||
opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter());
|
opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter());
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddSwaggerGen(opt =>
|
builder.Services.AddSwaggerGen(opt =>
|
||||||
{
|
{
|
||||||
opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" });
|
opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" });
|
||||||
@@ -171,6 +173,12 @@ builder.Services.AddSwaggerGen(opt =>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
var app = builder.Build();
|
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
|
// Apply migrations + seed on startup
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
@@ -185,10 +193,16 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLS is terminated by nginx in production; only redirect in local dev.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseCors("Angular");
|
app.UseCors("Angular");
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -12,4 +12,8 @@ public interface IOfferingSessionService
|
|||||||
Task<int> CreateAsync(CreateOfferingSessionRequest request);
|
Task<int> CreateAsync(CreateOfferingSessionRequest request);
|
||||||
Task ReopenAsync(int id);
|
Task ReopenAsync(int id);
|
||||||
Task ReplaceAsync(int id, CreateOfferingSessionRequest request);
|
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.Giving;
|
||||||
using ROLAC.API.DTOs.Shared;
|
using ROLAC.API.DTOs.Shared;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -12,11 +13,13 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
|
private readonly IFileStorage _storage;
|
||||||
|
|
||||||
public OfferingSessionService(AppDbContext db, IHttpContextAccessor http)
|
public OfferingSessionService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_http = http;
|
_http = http;
|
||||||
|
_storage = storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string CurrentUserId =>
|
private string CurrentUserId =>
|
||||||
@@ -48,6 +51,7 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
|
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
|
||||||
SystemTotal = s.SystemTotal, Difference = s.Difference,
|
SystemTotal = s.SystemTotal, Difference = s.Difference,
|
||||||
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
|
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
|
||||||
|
HasProof = s.ProofPdfPath != null,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new PagedResult<OfferingSessionListItemDto>
|
return new PagedResult<OfferingSessionListItemDto>
|
||||||
@@ -81,6 +85,7 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
Id = s.Id, SessionDate = s.SessionDate, Status = s.Status,
|
Id = s.Id, SessionDate = s.SessionDate, Status = s.Status,
|
||||||
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
|
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
|
||||||
SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes,
|
SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes,
|
||||||
|
HasProof = s.ProofPdfPath != null,
|
||||||
Givings = lines.Select(l => new OfferingGivingLineDto
|
Givings = lines.Select(l => new OfferingGivingLineDto
|
||||||
{
|
{
|
||||||
Id = l.Id, MemberId = l.MemberId,
|
Id = l.Id, MemberId = l.MemberId,
|
||||||
@@ -158,6 +163,41 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
await _db.SaveChangesAsync();
|
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()
|
private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new()
|
||||||
{
|
{
|
||||||
MemberId = line.IsAnonymous ? null : line.MemberId,
|
MemberId = line.IsAnonymous ? null : line.MemberId,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.angular
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ---- build ----
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build # -> dist/client-bridge/browser (production by default)
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
FROM nginx:alpine AS final
|
||||||
|
COPY nginx.app.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist/client-bridge/browser /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location ~* \.(?:js|css|woff2?|png|jpe?g|svg|ico)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+59
@@ -54,6 +54,8 @@
|
|||||||
"@progress/kendo-svg-icons": "^4.5.0",
|
"@progress/kendo-svg-icons": "^4.5.0",
|
||||||
"@progress/kendo-theme-default": "^12.0.0",
|
"@progress/kendo-theme-default": "^12.0.0",
|
||||||
"@progress/kendo-theme-utils": "^12.0.0",
|
"@progress/kendo-theme-utils": "^12.0.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
@@ -3196,6 +3198,24 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -5576,6 +5596,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/browser-image-compression": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uzip": "0.20201231.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.25.3",
|
"version": "4.25.3",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
|
||||||
@@ -9789,6 +9818,12 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||||
@@ -9924,6 +9959,24 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -11544,6 +11597,12 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uzip": {
|
||||||
|
"version": "0.20201231.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
|
||||||
|
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/validate-npm-package-license": {
|
"node_modules/validate-npm-package-license": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||||
|
|||||||
@@ -68,6 +68,8 @@
|
|||||||
"@progress/kendo-svg-icons": "^4.5.0",
|
"@progress/kendo-svg-icons": "^4.5.0",
|
||||||
"@progress/kendo-theme-default": "^12.0.0",
|
"@progress/kendo-theme-default": "^12.0.0",
|
||||||
"@progress/kendo-theme-utils": "^12.0.0",
|
"@progress/kendo-theme-utils": "^12.0.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface OfferingSessionDto {
|
|||||||
systemTotal: number;
|
systemTotal: number;
|
||||||
difference: number;
|
difference: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
hasProof: boolean;
|
||||||
givings: OfferingGivingLineDto[];
|
givings: OfferingGivingLineDto[];
|
||||||
}
|
}
|
||||||
export interface OfferingSessionListItemDto {
|
export interface OfferingSessionListItemDto {
|
||||||
@@ -112,6 +113,7 @@ export interface OfferingSessionListItemDto {
|
|||||||
systemTotal: number;
|
systemTotal: number;
|
||||||
difference: number;
|
difference: number;
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
|
hasProof: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A row held in the client-side batch buffer before submit. */
|
/** A row held in the client-side batch buffer before submit. */
|
||||||
|
|||||||
+59
-1
@@ -50,6 +50,11 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
|
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Proof" [width]="70">
|
||||||
|
<ng-template kendoGridCellTemplate let-s>
|
||||||
|
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
<kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column>
|
<kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column>
|
||||||
<kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
|
<kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
|
||||||
<kendo-grid-column title="" [width]="110">
|
<kendo-grid-column title="" [width]="110">
|
||||||
@@ -152,6 +157,35 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Paper proof -->
|
||||||
|
<section class="card rise" style="--d: 200ms">
|
||||||
|
<div class="card__head">
|
||||||
|
<h2 class="card__title">Paper Proof</h2>
|
||||||
|
<span class="card__zh">紙本證明</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="proof-hint">
|
||||||
|
Attach photos or PDFs of the count sheets / envelopes. Images are compressed and all files
|
||||||
|
are merged into a single PDF when you submit.
|
||||||
|
<br><span>附上點算單/信封的照片或 PDF。圖片會自動壓縮,送出時合併為單一 PDF。</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input type="file" multiple accept="image/*,application/pdf"
|
||||||
|
class="proof-input" (change)="onProofFilesSelected($event)" />
|
||||||
|
|
||||||
|
<ul *ngIf="pendingProofFiles.length" class="proof-list">
|
||||||
|
<li *ngFor="let f of pendingProofFiles; let i = index">
|
||||||
|
<span class="proof-name">{{ f.name }}</span>
|
||||||
|
<button kendoButton fillMode="flat" size="small" (click)="removeProofFile(i)">×</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p *ngIf="editingSessionId != null && pendingProofFiles.length" class="proof-warn">
|
||||||
|
Selecting files here replaces any existing proof for this session.
|
||||||
|
<br><span>於此選擇檔案會取代此 session 既有的證明。</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Reconcile & submit -->
|
<!-- Reconcile & submit -->
|
||||||
<section class="card rise" style="--d: 240ms">
|
<section class="card rise" style="--d: 240ms">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
@@ -230,8 +264,32 @@
|
|||||||
</kendo-grid>
|
</kendo-grid>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Paper proof (view) -->
|
||||||
|
<section class="card rise" style="--d: 200ms">
|
||||||
|
<div class="card__head">
|
||||||
|
<h2 class="card__title">Paper Proof</h2>
|
||||||
|
<span class="card__zh">紙本證明</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input #proofInput type="file" multiple accept="image/*,application/pdf" hidden
|
||||||
|
(change)="replaceProof($event)" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<ng-container *ngIf="v.hasProof; else noProof">
|
||||||
|
<button kendoButton themeColor="primary" (click)="openProof(v.id)" [disabled]="proofBusy">View proof PDF / 檢視證明</button>
|
||||||
|
<button kendoButton (click)="proofInput.click()" [disabled]="proofBusy">Replace / 更換</button>
|
||||||
|
<button kendoButton fillMode="flat" (click)="removeProof()" [disabled]="proofBusy">Remove / 移除</button>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noProof>
|
||||||
|
<span class="proof-none">No proof attached · 尚未附上證明</span>
|
||||||
|
<button kendoButton themeColor="primary" (click)="proofInput.click()" [disabled]="proofBusy">Add proof / 新增證明</button>
|
||||||
|
</ng-template>
|
||||||
|
<span *ngIf="proofBusy" class="proof-busy">Working… · 處理中…</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Session notes -->
|
<!-- Session notes -->
|
||||||
<section *ngIf="v.notes" class="card rise" style="--d: 240ms">
|
<section *ngIf="v.notes" class="card rise" style="--d: 280ms">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Notes</h2>
|
<h2 class="card__title">Notes</h2>
|
||||||
<span class="card__zh">備註</span>
|
<span class="card__zh">備註</span>
|
||||||
|
|||||||
+16
@@ -182,6 +182,22 @@
|
|||||||
.lines-footer { margin-top: 10px; color: var(--ink-soft); font-weight: 600; font-variant-numeric: tabular-nums; }
|
.lines-footer { margin-top: 10px; color: var(--ink-soft); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
.anon-chip { padding: 0.25rem 0.6rem; background: var(--kendo-color-base-subtle, #e6eaef); border-radius: 999px; font-size: 12px; font-weight: 600; }
|
.anon-chip { padding: 0.25rem 0.6rem; background: var(--kendo-color-base-subtle, #e6eaef); border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
.notes-text { margin: 0; color: var(--ink); line-height: 1.5; white-space: pre-wrap; }
|
.notes-text { margin: 0; color: var(--ink); line-height: 1.5; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* ---- Paper proof ---- */
|
||||||
|
.proof-hint { margin: 0 0 12px; font-size: 13px; line-height: 1.5; color: var(--ink-soft); span { color: #9aa7b6; } }
|
||||||
|
.proof-input { display: block; font-size: 14px; }
|
||||||
|
.proof-list {
|
||||||
|
list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px;
|
||||||
|
li {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 4px 10px; background: var(--kendo-color-base-subtle, #f1f4f8);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.proof-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.proof-warn { margin: 10px 0 0; font-size: 12px; line-height: 1.5; color: #b45309; span { color: #d97706; } }
|
||||||
|
.proof-none { font-size: 14px; color: var(--ink-soft); }
|
||||||
|
.proof-busy { font-size: 13px; color: var(--ink-soft); }
|
||||||
.dialog-text { margin: 0; line-height: 1.55; span { color: var(--ink-soft); font-size: 13px; } }
|
.dialog-text { margin: 0; line-height: 1.55; span { color: var(--ink-soft); font-size: 13px; } }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
+81
-6
@@ -1,7 +1,8 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, from, of, map, switchMap } from 'rxjs';
|
||||||
|
import { buildProofPdf } from '../../services/proof-pdf.builder';
|
||||||
import { GridModule } from '@progress/kendo-angular-grid';
|
import { GridModule } from '@progress/kendo-angular-grid';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
@@ -56,6 +57,10 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
showQuickAdd = false;
|
showQuickAdd = false;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
|
|
||||||
|
// Paper-proof attachments staged client-side; merged into one PDF on submit.
|
||||||
|
pendingProofFiles: File[] = [];
|
||||||
|
proofBusy = false;
|
||||||
|
|
||||||
sessions: OfferingSessionListItemDto[] = [];
|
sessions: OfferingSessionListItemDto[] = [];
|
||||||
editingSessionId: number | null = null;
|
editingSessionId: number | null = null;
|
||||||
|
|
||||||
@@ -99,6 +104,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
this.viewSession = null;
|
this.viewSession = null;
|
||||||
this.buffer = [];
|
this.buffer = [];
|
||||||
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
|
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
|
||||||
|
this.pendingProofFiles = [];
|
||||||
this.resetEntry();
|
this.resetEntry();
|
||||||
this.mode = 'workspace';
|
this.mode = 'workspace';
|
||||||
}
|
}
|
||||||
@@ -167,6 +173,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
this.cashTotal = dto.cashTotal;
|
this.cashTotal = dto.cashTotal;
|
||||||
this.checkTotal = dto.checkTotal;
|
this.checkTotal = dto.checkTotal;
|
||||||
this.notes = dto.notes;
|
this.notes = dto.notes;
|
||||||
|
this.pendingProofFiles = [];
|
||||||
this.buffer = dto.givings.map(g => ({
|
this.buffer = dto.givings.map(g => ({
|
||||||
memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount,
|
memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount,
|
||||||
paymentMethod: g.paymentMethod, checkNumber: g.checkNumber,
|
paymentMethod: g.paymentMethod, checkNumber: g.checkNumber,
|
||||||
@@ -254,13 +261,21 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
notes: l.notes,
|
notes: l.notes,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const obs: Observable<unknown> = this.editingSessionId != null
|
const isEdit = this.editingSessionId != null;
|
||||||
? this.api.replace(this.editingSessionId, req)
|
// Save the session first, then resolve to its id so the proof PDF can be attached.
|
||||||
: this.api.create(req);
|
const savedId$: Observable<number> = isEdit
|
||||||
obs.subscribe({
|
? this.api.replace(this.editingSessionId!, req).pipe(map(() => this.editingSessionId!))
|
||||||
|
: this.api.create(req).pipe(map(r => r.id));
|
||||||
|
|
||||||
|
savedId$.pipe(
|
||||||
|
switchMap(id => this.pendingProofFiles.length === 0
|
||||||
|
? of(void 0)
|
||||||
|
: from(buildProofPdf(this.pendingProofFiles)).pipe(
|
||||||
|
switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
|
||||||
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.');
|
alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.');
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.mode = 'landing';
|
this.mode = 'landing';
|
||||||
this.loadSessions();
|
this.loadSessions();
|
||||||
@@ -272,6 +287,65 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Paper proof ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Stage selected attachment files (appends, so multiple picks accumulate). */
|
||||||
|
onProofFilesSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
if (files.length) this.pendingProofFiles = [...this.pendingProofFiles, ...files];
|
||||||
|
input.value = ''; // allow re-selecting the same file
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProofFile(i: number): void {
|
||||||
|
this.pendingProofFiles = this.pendingProofFiles.filter((_, idx) => idx !== i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the stored proof PDF in a new tab (authenticated blob fetch). */
|
||||||
|
openProof(id: number): void {
|
||||||
|
this.api.downloadProof(id).subscribe(blob => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the stored proof from the read-only view (rebuild + upload + refresh). */
|
||||||
|
replaceProof(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
if (!files.length || !this.viewSession) return;
|
||||||
|
const id = this.viewSession.id;
|
||||||
|
this.proofBusy = true;
|
||||||
|
from(buildProofPdf(files)).pipe(
|
||||||
|
switchMap(({ blob }) => this.api.uploadProof(id, blob)),
|
||||||
|
switchMap(() => this.api.getById(id)),
|
||||||
|
).subscribe({
|
||||||
|
next: dto => { this.viewSession = dto; this.proofBusy = false; },
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.proofBusy = false;
|
||||||
|
alert(err?.error?.message ?? 'Proof upload failed.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProof(): void {
|
||||||
|
if (!this.viewSession) return;
|
||||||
|
if (!confirm('Remove the paper proof for this session? / 移除此 session 的紙本證明?')) return;
|
||||||
|
const id = this.viewSession.id;
|
||||||
|
this.proofBusy = true;
|
||||||
|
this.api.deleteProof(id).pipe(
|
||||||
|
switchMap(() => this.api.getById(id)),
|
||||||
|
).subscribe({
|
||||||
|
next: dto => { this.viewSession = dto; this.proofBusy = false; },
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.proofBusy = false;
|
||||||
|
alert(err?.error?.message ?? 'Remove failed.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear the whole working session back to a fresh state (today, empty buffer). */
|
/** Clear the whole working session back to a fresh state (today, empty buffer). */
|
||||||
private resetSession(): void {
|
private resetSession(): void {
|
||||||
this.editingSessionId = null;
|
this.editingSessionId = null;
|
||||||
@@ -279,6 +353,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
this.viewSession = null;
|
this.viewSession = null;
|
||||||
this.buffer = [];
|
this.buffer = [];
|
||||||
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
|
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
|
||||||
|
this.pendingProofFiles = [];
|
||||||
this.sessionDate = new Date();
|
this.sessionDate = new Date();
|
||||||
this.resetEntry();
|
this.resetEntry();
|
||||||
this.checkDate();
|
this.checkDate();
|
||||||
|
|||||||
@@ -36,4 +36,23 @@ export class OfferingSessionApiService {
|
|||||||
replace(id: number, request: CreateOfferingSessionRequest): Observable<void> {
|
replace(id: number, request: CreateOfferingSessionRequest): Observable<void> {
|
||||||
return this.http.put<void>(`${this.endpoint}/${id}`, request);
|
return this.http.put<void>(`${this.endpoint}/${id}`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upload the merged paper-proof PDF (built client-side) for a session. */
|
||||||
|
uploadProof(id: number, pdf: Blob): Observable<void> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', pdf, 'proof.pdf');
|
||||||
|
return this.http.post<void>(`${this.endpoint}/${id}/proof`, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the proof PDF as a Blob via HttpClient so the auth interceptor attaches the JWT.
|
||||||
|
* A bare window.open on the API URL would be an unauthenticated navigation → 401.
|
||||||
|
*/
|
||||||
|
downloadProof(id: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.endpoint}/${id}/proof`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProof(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.endpoint}/${id}/proof`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import imageCompression from 'browser-image-compression';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos).
|
||||||
|
*
|
||||||
|
* - Images are compressed in the browser (resized + re-encoded as JPEG) so the upload stays
|
||||||
|
* small — phone photos are 12MP+ and would otherwise be huge. One image per page.
|
||||||
|
* - Files that are already PDFs are merged through page-by-page, unchanged.
|
||||||
|
* - Other file types are skipped (and reported in `skipped`).
|
||||||
|
*
|
||||||
|
* All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Tunables — adjust if proofs look too soft (raise) or files are too large (lower).
|
||||||
|
const MAX_EDGE_PX = 2000; // longest image edge after compression
|
||||||
|
const JPEG_QUALITY = 0.72; // 0..1
|
||||||
|
const MAX_SIZE_MB = 1; // target ceiling per image
|
||||||
|
|
||||||
|
// US Letter, in PDF points (72pt = 1in).
|
||||||
|
const PAGE_W = 612;
|
||||||
|
const PAGE_H = 792;
|
||||||
|
const MARGIN = 36;
|
||||||
|
|
||||||
|
export interface ProofBuildResult {
|
||||||
|
blob: Blob;
|
||||||
|
/** Names of files that were skipped because the type is unsupported. */
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildProofPdf(files: File[]): Promise<ProofBuildResult> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.type === 'application/pdf') {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const src = await PDFDocument.load(bytes);
|
||||||
|
const pages = await doc.copyPages(src, src.getPageIndices());
|
||||||
|
pages.forEach(p => doc.addPage(p));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const compressed = await imageCompression(file, {
|
||||||
|
maxWidthOrHeight: MAX_EDGE_PX,
|
||||||
|
maxSizeMB: MAX_SIZE_MB,
|
||||||
|
initialQuality: JPEG_QUALITY,
|
||||||
|
fileType: 'image/jpeg',
|
||||||
|
useWebWorker: true,
|
||||||
|
});
|
||||||
|
const jpgBytes = new Uint8Array(await compressed.arrayBuffer());
|
||||||
|
const img = await doc.embedJpg(jpgBytes);
|
||||||
|
|
||||||
|
const page = doc.addPage([PAGE_W, PAGE_H]);
|
||||||
|
const maxW = PAGE_W - MARGIN * 2;
|
||||||
|
const maxH = PAGE_H - MARGIN * 2;
|
||||||
|
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
|
||||||
|
const w = img.width * scale;
|
||||||
|
const h = img.height * scale;
|
||||||
|
page.drawImage(img, {
|
||||||
|
x: (PAGE_W - w) / 2,
|
||||||
|
y: (PAGE_H - h) / 2,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped.push(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await doc.save();
|
||||||
|
// Copy into a fresh ArrayBuffer-backed view so the Blob type is unambiguous.
|
||||||
|
const blob = new Blob([bytes.slice()], { type: 'application/pdf' });
|
||||||
|
return { blob, skipped };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: 'https://your-production-api.com/api'
|
apiUrl: '/api'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# build-push.ps1 — Build both ROLAC images and push them to the Gitea registry.
|
||||||
|
#
|
||||||
|
# Usage (from anywhere):
|
||||||
|
# .\deploy\build-push.ps1 # tags :latest and :<git-sha>
|
||||||
|
# .\deploy\build-push.ps1 -Tag v1.2.0 # also adds an extra :v1.2.0 tag
|
||||||
|
# .\deploy\build-push.ps1 -NoPush # build only, don't push
|
||||||
|
#
|
||||||
|
# Prereqs:
|
||||||
|
# - Docker Desktop running
|
||||||
|
# - docker login git.golife.love (once, with a write:package access token)
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Tag, # optional extra tag, e.g. a release version
|
||||||
|
[switch]$NoPush # build only
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Repo root = parent of this script's folder
|
||||||
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$Registry = 'git.golife.love/chrischen'
|
||||||
|
$Api = "$Registry/rolac-api"
|
||||||
|
$App = "$Registry/rolac-app"
|
||||||
|
|
||||||
|
# Short git sha for an immutable version tag
|
||||||
|
$Sha = (git -C $RepoRoot rev-parse --short HEAD).Trim()
|
||||||
|
Write-Host "Building from commit $Sha" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Assemble the -t arguments for each image
|
||||||
|
$apiTags = @('-t', "${Api}:latest", '-t', "${Api}:$Sha")
|
||||||
|
$appTags = @('-t', "${App}:latest", '-t', "${App}:$Sha")
|
||||||
|
if ($Tag) {
|
||||||
|
$apiTags += @('-t', "${Api}:$Tag")
|
||||||
|
$appTags += @('-t', "${App}:$Tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Building API image" -ForegroundColor Green
|
||||||
|
docker build @apiTags "$RepoRoot\API"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "API build failed" }
|
||||||
|
|
||||||
|
Write-Host "==> Building APP image" -ForegroundColor Green
|
||||||
|
docker build @appTags "$RepoRoot\APP"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "APP build failed" }
|
||||||
|
|
||||||
|
if ($NoPush) {
|
||||||
|
Write-Host "Build complete (push skipped)." -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Pushing API image" -ForegroundColor Green
|
||||||
|
docker push --all-tags $Api
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "API push failed" }
|
||||||
|
|
||||||
|
Write-Host "==> Pushing APP image" -ForegroundColor Green
|
||||||
|
docker push --all-tags $App
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "APP push failed" }
|
||||||
|
|
||||||
|
Write-Host "Done. Pushed :latest and :$Sha$(if($Tag){" and :$Tag"})." -ForegroundColor Cyan
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: git.golife.love/chrischen/rolac-api:${TAG:-latest}
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ConnectionStrings__DefaultConnection: ${DB_CONNECTION}
|
||||||
|
Jwt__SecretKey: ${JWT_SECRET}
|
||||||
|
Cors__AllowedOrigins__0: https://app.rolac.org
|
||||||
|
volumes:
|
||||||
|
- api-storage:/app/App_Data/storage
|
||||||
|
restart: unless-stopped
|
||||||
|
expose: ["8080"]
|
||||||
|
|
||||||
|
app:
|
||||||
|
image: git.golife.love/chrischen/rolac-app:${TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
expose: ["80"]
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports: ["80:80", "443:443"]
|
||||||
|
volumes:
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
- /var/www/certbot:/var/www/certbot:ro
|
||||||
|
depends_on: [api, app]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-storage:
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Deploy to Synology NAS (Container Manager) — LAN / HTTP
|
||||||
|
|
||||||
|
Target: run the ROLAC stack on a Synology NAS, reachable on the LAN at
|
||||||
|
`http://<nas-ip>:8080`, with images built & pushed to the **local Gitea registry**
|
||||||
|
(`git.golife.love`, same NAS) and auto-deployed by a **Gitea act_runner** on push to `main`.
|
||||||
|
|
||||||
|
```
|
||||||
|
browser (LAN) -> http://<nas-ip>:8080
|
||||||
|
│ nginx edge (container, 8080->80)
|
||||||
|
├── / -> app container (Angular static)
|
||||||
|
└── /api/ -> api container (ASP.NET, :8080)
|
||||||
|
api ──> existing PostgreSQL @ 192.168.68.55:49154 (not containerized)
|
||||||
|
```
|
||||||
|
|
||||||
|
Differences vs the Azure plan: no TLS/certbot, edge on **8080** (DSM owns 80/443),
|
||||||
|
reuse the LAN database, deploy via the on-NAS runner (no SSH).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-time NAS setup
|
||||||
|
|
||||||
|
1. **Deploy dir + secrets** (via SSH or File Station):
|
||||||
|
```bash
|
||||||
|
mkdir -p /volume1/docker/rolac/nginx/conf.d /volume1/docker/rolac/data/api-storage
|
||||||
|
cp /path/to/repo/deploy/nas/.env.example /volume1/docker/rolac/.env
|
||||||
|
# edit /volume1/docker/rolac/.env -> real DB user/password + JWT_SECRET + APP_ORIGIN
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Registry token** — in Gitea: Settings → Applications → new token with
|
||||||
|
`read:package` + `write:package`. Log the NAS Docker in once:
|
||||||
|
```bash
|
||||||
|
docker login git.golife.love -u ChrisChen # paste the token
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install the act_runner on the NAS** (Container Manager → Registry → `gitea/act_runner`,
|
||||||
|
or `docker run`). It must:
|
||||||
|
- mount the host Docker socket: `-v /var/run/docker.sock:/var/run/docker.sock`
|
||||||
|
- mount the deploy dir at the same path: `-v /volume1/docker/rolac:/volume1/docker/rolac`
|
||||||
|
- register against Gitea with the label **`nas`** (this is what `runs-on: nas` targets).
|
||||||
|
|
||||||
|
Get a registration token in Gitea: Site/Repo → Settings → Actions → Runners →
|
||||||
|
"Create new runner". Example:
|
||||||
|
```bash
|
||||||
|
docker run -d --restart unless-stopped --name rolac-runner \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /volume1/docker/rolac:/volume1/docker/rolac \
|
||||||
|
-e GITEA_INSTANCE_URL=https://git.golife.love \
|
||||||
|
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \
|
||||||
|
-e GITEA_RUNNER_LABELS=nas \
|
||||||
|
gitea/act_runner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Gitea repo secrets** (Settings → Actions → Secrets):
|
||||||
|
- `REGISTRY_USER` = `ChrisChen`
|
||||||
|
- `REGISTRY_TOKEN` = the package token from step 2
|
||||||
|
|
||||||
|
5. **Enable Actions** for the repo if not already (Settings → Advanced → Actions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day-to-day
|
||||||
|
|
||||||
|
`git push` to `main` → `.gitea/workflows/ci-cd-nas.yml` runs:
|
||||||
|
**test → build both images → push to registry → sync compose/nginx → `docker compose up -d` → health check.**
|
||||||
|
|
||||||
|
Open `http://<nas-ip>:8080` and log in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual deploy (no runner yet)
|
||||||
|
|
||||||
|
From a machine with Docker + `docker login git.golife.love`:
|
||||||
|
```powershell
|
||||||
|
# repo root, build + push (uses deploy/build-push.ps1)
|
||||||
|
.\deploy\build-push.ps1
|
||||||
|
```
|
||||||
|
Then on the NAS:
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/rolac
|
||||||
|
docker compose up -d
|
||||||
|
curl -fsS http://localhost:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **First boot runs DB migrations** against `192.168.68.55` automatically
|
||||||
|
(`Program.cs` calls `MigrateAsync()` + seed). Make sure the DB user has DDL rights;
|
||||||
|
back up before the first run.
|
||||||
|
- **Bind-mount paths**: the runner deploys by running compose at `/volume1/docker/rolac`
|
||||||
|
on the host (socket-mounted), so `./nginx/conf.d` and `./data` resolve to real NAS
|
||||||
|
paths — that's why the runner mounts that dir at the *same* path.
|
||||||
|
- **Uploaded files** persist under `/volume1/docker/rolac/data/api-storage`.
|
||||||
|
- To expose beyond the LAN later, put it behind DSM's reverse proxy (Application Portal)
|
||||||
|
or switch to the Azure `deploy/` files with certbot.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: git.golife.love/chrischen/rolac-api:${TAG:-latest}
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ConnectionStrings__DefaultConnection: ${DB_CONNECTION}
|
||||||
|
Jwt__SecretKey: ${JWT_SECRET}
|
||||||
|
# Same-origin /api means CORS is not triggered by the browser; this is only
|
||||||
|
# a safety net for direct cross-origin calls / tools. Set to your NAS URL.
|
||||||
|
Cors__AllowedOrigins__0: ${APP_ORIGIN:-http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ./data/api-storage:/app/App_Data/storage
|
||||||
|
restart: unless-stopped
|
||||||
|
expose: ["8080"]
|
||||||
|
|
||||||
|
app:
|
||||||
|
image: git.golife.love/chrischen/rolac-app:${TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
expose: ["80"]
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
# DSM already uses 80/443, so the edge is published on 8080 (HTTP, LAN only).
|
||||||
|
ports: ["8080:80"]
|
||||||
|
volumes:
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
depends_on: [api, app]
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# API -> api container. The SPA calls same-origin /api/... (environment.prod.ts).
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else -> the Angular static app (its own nginx does SPA fallback).
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:80;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
server { # HTTP -> HTTPS
|
||||||
|
listen 80;
|
||||||
|
server_name app.rolac.org api.rolac.org;
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||||
|
location / { return 301 https://$host$request_uri; }
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name app.rolac.org;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/app.rolac.org/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/app.rolac.org/privkey.pem;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:80;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user